T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a09d76f370
commit
b58a9139aa
7 changed files with 647 additions and 4 deletions
|
|
@ -253,9 +253,32 @@ fn apply_patch<G: ScenarioGame>(
|
||||||
let mut json = serde_json::to_value(state).map_err(|e| format!("state encode: {e}"))?;
|
let mut json = serde_json::to_value(state).map_err(|e| format!("state encode: {e}"))?;
|
||||||
for (path, value) in patch {
|
for (path, value) in patch {
|
||||||
let value = to_json(value)?;
|
let value = to_json(value)?;
|
||||||
let slot = lookup_mut(&mut json, path)
|
let (parent_path, key) = path.rsplit_once('.').unwrap_or(("", path.as_str()));
|
||||||
.ok_or_else(|| format!("patch path {path:?} does not exist in the initial state"))?;
|
// The parent must exist — a typo mid-path is an error, never a
|
||||||
*slot = value;
|
// silent no-op. The final key may be new, so scenarios can seed
|
||||||
|
// open-ended maps (relations, focus) that start empty.
|
||||||
|
let parent = if parent_path.is_empty() {
|
||||||
|
&mut json
|
||||||
|
} else {
|
||||||
|
lookup_mut(&mut json, parent_path).ok_or_else(|| {
|
||||||
|
format!("patch path {path:?}: {parent_path:?} does not exist in the initial state")
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
match parent {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
map.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(items) => {
|
||||||
|
let index = key
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| format!("patch path {path:?}: {key:?} is not an array index"))?;
|
||||||
|
let slot = items
|
||||||
|
.get_mut(index)
|
||||||
|
.ok_or_else(|| format!("patch path {path:?}: index {index} out of range"))?;
|
||||||
|
*slot = value;
|
||||||
|
}
|
||||||
|
_ => return Err(format!("patch path {path:?}: parent is not a container")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
serde_json::from_value(json).map_err(|e| format!("patch produced invalid state: {e}"))
|
serde_json::from_value(json).map_err(|e| format!("patch produced invalid state: {e}"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,54 @@ pub enum Relation {
|
||||||
Rivalry,
|
Rivalry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GR-O05: an unordered player pair, canonically ordered low→high.
|
||||||
|
///
|
||||||
|
/// Serialized as `"a-b"` rather than as a tuple: canonical form is JSON
|
||||||
|
/// (GameKernel K7), and JSON object keys must be strings — a tuple key
|
||||||
|
/// makes `state_hash` fail on any state that holds a relation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct Pair(pub PlayerId, pub PlayerId);
|
||||||
|
|
||||||
|
impl Pair {
|
||||||
|
pub fn new(a: PlayerId, b: PlayerId) -> Self {
|
||||||
|
if a <= b {
|
||||||
|
Pair(a, b)
|
||||||
|
} else {
|
||||||
|
Pair(b, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, player: PlayerId) -> bool {
|
||||||
|
self.0 == player || self.1 == player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for Pair {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
write!(f, "{}-{}", self.0 .0, self.1 .0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Pair {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.collect_str(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Pair {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let raw = String::deserialize(deserializer)?;
|
||||||
|
let (a, b) = raw
|
||||||
|
.split_once('-')
|
||||||
|
.ok_or_else(|| serde::de::Error::custom(format!("bad relation key {raw:?}")))?;
|
||||||
|
let parse = |s: &str| {
|
||||||
|
s.parse::<u8>()
|
||||||
|
.map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}")))
|
||||||
|
};
|
||||||
|
Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered
|
/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered
|
||||||
/// collections only (GameKernel K6).
|
/// collections only (GameKernel K6).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -74,7 +122,7 @@ pub struct GroundState {
|
||||||
pub lead: PlayerId,
|
pub lead: PlayerId,
|
||||||
pub players: BTreeMap<PlayerId, PlayerState>,
|
pub players: BTreeMap<PlayerId, PlayerState>,
|
||||||
/// Keyed by (low, high) player pair.
|
/// Keyed by (low, high) player pair.
|
||||||
pub relations: BTreeMap<(PlayerId, PlayerId), Relation>,
|
pub relations: BTreeMap<Pair, Relation>,
|
||||||
pub problems: BTreeMap<u32, ProblemState>,
|
pub problems: BTreeMap<u32, ProblemState>,
|
||||||
pub solution_deck: Vec<SolutionCard>,
|
pub solution_deck: Vec<SolutionCard>,
|
||||||
pub solution_discard: Vec<SolutionCard>,
|
pub solution_discard: Vec<SolutionCard>,
|
||||||
|
|
@ -154,6 +202,14 @@ pub enum GroundCommand {
|
||||||
},
|
},
|
||||||
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
|
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
|
||||||
SpendFreedom,
|
SpendFreedom,
|
||||||
|
/// GR-R04: reveal all selections simultaneously. System-driven.
|
||||||
|
Reveal,
|
||||||
|
/// GR-R06/R07: resolve revealed Actions in fixed step order, Lead
|
||||||
|
/// first. System-driven.
|
||||||
|
Resolve,
|
||||||
|
/// GR-R08: clamp Stress, trigger DARVO, rotate Lead, advance the
|
||||||
|
/// Round marker. System-driven.
|
||||||
|
EndRound,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events the aggregate emits. `fold` is total over these (K1).
|
/// Events the aggregate emits. `fold` is total over these (K1).
|
||||||
|
|
@ -167,6 +223,44 @@ pub enum GroundEvent {
|
||||||
FreedomSpent {
|
FreedomSpent {
|
||||||
player: PlayerId,
|
player: PlayerId,
|
||||||
},
|
},
|
||||||
|
/// GR-R04.
|
||||||
|
Revealed,
|
||||||
|
/// GR-F01/U2: absolute post-clamp Stress, so `fold` stays trivial.
|
||||||
|
StressSet {
|
||||||
|
player: PlayerId,
|
||||||
|
stress: u8,
|
||||||
|
},
|
||||||
|
/// GR-F04.
|
||||||
|
FreedomReadied {
|
||||||
|
player: PlayerId,
|
||||||
|
},
|
||||||
|
/// GR-L02/L03: endpoints ordered low→high (GR-O05).
|
||||||
|
RelationFormed {
|
||||||
|
pair: Pair,
|
||||||
|
relation: Relation,
|
||||||
|
},
|
||||||
|
/// GR-L04.
|
||||||
|
RelationBroken {
|
||||||
|
pair: Pair,
|
||||||
|
},
|
||||||
|
/// GR-A09: Protection absorbed an Attack.
|
||||||
|
AttackCancelled {
|
||||||
|
attacker: PlayerId,
|
||||||
|
target: PlayerId,
|
||||||
|
},
|
||||||
|
/// GR-D01: Stress 5 at End with the marker OFF.
|
||||||
|
DarvoTriggered {
|
||||||
|
player: PlayerId,
|
||||||
|
},
|
||||||
|
/// GR-R01: the round advanced to `step`.
|
||||||
|
StepAdvanced {
|
||||||
|
step: RoundStep,
|
||||||
|
},
|
||||||
|
/// GR-R08: Lead rotated and the Round marker advanced.
|
||||||
|
RoundEnded {
|
||||||
|
round: u8,
|
||||||
|
next_lead: PlayerId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroundState {
|
impl GroundState {
|
||||||
|
|
@ -177,6 +271,39 @@ impl GroundState {
|
||||||
player.stress >= 4 && !player.freedom_gate_lifted
|
player.stress >= 4 && !player.freedom_gate_lifted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
|
||||||
|
self.relations.get(&Pair::new(a, b)).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GR-L01: two relation slots per player.
|
||||||
|
fn has_free_slot(&self, player: PlayerId) -> bool {
|
||||||
|
let used = self
|
||||||
|
.relations
|
||||||
|
.keys()
|
||||||
|
.filter(|pair| pair.contains(player))
|
||||||
|
.count();
|
||||||
|
used < 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GR-R07: resolution order starts at the Lead and continues
|
||||||
|
/// clockwise (ascending seat, wrapping).
|
||||||
|
fn seat_order(&self) -> Vec<PlayerId> {
|
||||||
|
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
|
||||||
|
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
|
||||||
|
seats[start..]
|
||||||
|
.iter()
|
||||||
|
.chain(&seats[..start])
|
||||||
|
.copied()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GR-F01 with the U2 default: clamp on every application, so no
|
||||||
|
/// intermediate value escapes 0–5.
|
||||||
|
fn stress_after(&self, player: PlayerId, delta: i16) -> u8 {
|
||||||
|
let current = self.players.get(&player).map_or(0, |p| i16::from(p.stress));
|
||||||
|
current.saturating_add(delta).clamp(0, 5) as u8
|
||||||
|
}
|
||||||
|
|
||||||
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
|
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
|
||||||
self.players.get(&id).ok_or(Rejection::Game {
|
self.players.get(&id).ok_or(Rejection::Game {
|
||||||
code: "no-such-seat".into(),
|
code: "no-such-seat".into(),
|
||||||
|
|
@ -194,6 +321,40 @@ impl Aggregate for GroundState {
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
command: &Self::Command,
|
command: &Self::Command,
|
||||||
) -> Result<Vec<Self::Event>, Rejection> {
|
) -> Result<Vec<Self::Event>, Rejection> {
|
||||||
|
// GR-R04/R06/R08 are runtime-driven, not player-issued.
|
||||||
|
match command {
|
||||||
|
GroundCommand::Reveal => {
|
||||||
|
if self.step != RoundStep::Select || actor != Actor::System {
|
||||||
|
return Err(Rejection::NotAllowedNow);
|
||||||
|
}
|
||||||
|
if self.selections.len() != self.players.len() {
|
||||||
|
return Err(Rejection::Game {
|
||||||
|
code: "select-incomplete".into(),
|
||||||
|
detail: "GR-R04: every player must select before Reveal".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(vec![
|
||||||
|
GroundEvent::Revealed,
|
||||||
|
GroundEvent::StepAdvanced {
|
||||||
|
step: RoundStep::Reveal,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
GroundCommand::Resolve => {
|
||||||
|
if self.step != RoundStep::Reveal || actor != Actor::System {
|
||||||
|
return Err(Rejection::NotAllowedNow);
|
||||||
|
}
|
||||||
|
return Ok(self.resolution_events());
|
||||||
|
}
|
||||||
|
GroundCommand::EndRound => {
|
||||||
|
if self.step != RoundStep::Resolve || actor != Actor::System {
|
||||||
|
return Err(Rejection::NotAllowedNow);
|
||||||
|
}
|
||||||
|
return Ok(self.end_round_events());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
let Actor::Player(id) = actor else {
|
let Actor::Player(id) = actor else {
|
||||||
return Err(Rejection::NotAllowedNow);
|
return Err(Rejection::NotAllowedNow);
|
||||||
};
|
};
|
||||||
|
|
@ -242,6 +403,9 @@ impl Aggregate for GroundState {
|
||||||
}
|
}
|
||||||
Ok(vec![GroundEvent::FreedomSpent { player: id }])
|
Ok(vec![GroundEvent::FreedomSpent { player: id }])
|
||||||
}
|
}
|
||||||
|
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
|
||||||
|
unreachable!("system commands are handled above")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,11 +420,224 @@ impl Aggregate for GroundState {
|
||||||
state.freedom_gate_lifted = true;
|
state.freedom_gate_lifted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
GroundEvent::Revealed => {}
|
||||||
|
GroundEvent::StressSet { player, stress } => {
|
||||||
|
if let Some(state) = self.players.get_mut(player) {
|
||||||
|
state.stress = *stress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GroundEvent::FreedomReadied { player } => {
|
||||||
|
if let Some(state) = self.players.get_mut(player) {
|
||||||
|
state.freedom_ready = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GroundEvent::RelationFormed { pair, relation } => {
|
||||||
|
self.relations.insert(*pair, *relation);
|
||||||
|
}
|
||||||
|
GroundEvent::RelationBroken { pair } => {
|
||||||
|
self.relations.remove(pair);
|
||||||
|
}
|
||||||
|
GroundEvent::AttackCancelled { target, .. } => {
|
||||||
|
if let Some(state) = self.players.get_mut(target) {
|
||||||
|
state.protection = state.protection.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GroundEvent::DarvoTriggered { player } => {
|
||||||
|
if let Some(state) = self.players.get_mut(player) {
|
||||||
|
state.darvo = DarvoStage::Deny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GroundEvent::StepAdvanced { step } => {
|
||||||
|
self.step = *step;
|
||||||
|
}
|
||||||
|
GroundEvent::RoundEnded { round, next_lead } => {
|
||||||
|
self.round = *round;
|
||||||
|
self.lead = *next_lead;
|
||||||
|
self.selections.clear();
|
||||||
|
for state in self.players.values_mut() {
|
||||||
|
// GR-R03: the gate lift lasts one Select step only.
|
||||||
|
state.freedom_gate_lifted = false;
|
||||||
|
}
|
||||||
|
for problem in self.problems.values_mut() {
|
||||||
|
// GR-A11: OU protection lasts one round.
|
||||||
|
problem.protected_this_round = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroundState {
|
impl GroundState {
|
||||||
|
/// GR-R06/R07: resolve in fixed step order, Lead first within a step.
|
||||||
|
///
|
||||||
|
/// Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE)
|
||||||
|
/// are not yet implemented — their Actions resolve as no-ops and no
|
||||||
|
/// scenario claims coverage of them.
|
||||||
|
fn resolution_events(&self) -> Vec<GroundEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
// A working copy so slot counts and Stress reflect earlier
|
||||||
|
// effects within the same resolution, per GR-R07 ordering.
|
||||||
|
let mut work = self.clone();
|
||||||
|
|
||||||
|
// Relations as they stood before this round's Support step
|
||||||
|
// (GR-L05): a Bond formed now is not "pre-existing".
|
||||||
|
let pre_existing = self.relations.clone();
|
||||||
|
|
||||||
|
// Step 2 — Support (GR-A03/A04/A05).
|
||||||
|
for actor in self.seat_order() {
|
||||||
|
let Some(selection) = self.selections.get(&actor) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if selection.action != Action::Support {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(target) = selection.target else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match pre_existing.get(&Pair::new(actor, target)).copied() {
|
||||||
|
// GR-A04: Support through an existing Bond.
|
||||||
|
Some(Relation::Bond) => {
|
||||||
|
let stress = work.stress_after(target, -2);
|
||||||
|
events.push(GroundEvent::StressSet {
|
||||||
|
player: target,
|
||||||
|
stress,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
// GR-F04: a Bond Support readies the target's token.
|
||||||
|
if !work.players[&target].freedom_ready {
|
||||||
|
events.push(GroundEvent::FreedomReadied { player: target });
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GR-A05: Support through a Rivalry. The target's
|
||||||
|
// flip-or-break choice needs a consent command and is
|
||||||
|
// deferred; only the Stress effect applies here.
|
||||||
|
Some(Relation::Rivalry) => {
|
||||||
|
let stress = work.stress_after(target, -1);
|
||||||
|
events.push(GroundEvent::StressSet {
|
||||||
|
player: target,
|
||||||
|
stress,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
}
|
||||||
|
// GR-A03: no relation. GR-L02 requires the target's
|
||||||
|
// consent to form a Bond, so no relation forms here.
|
||||||
|
None => {
|
||||||
|
let stress = work.stress_after(target, -1);
|
||||||
|
events.push(GroundEvent::StressSet {
|
||||||
|
player: target,
|
||||||
|
stress,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 — Attack (GR-A06..A09).
|
||||||
|
for actor in self.seat_order() {
|
||||||
|
let Some(selection) = self.selections.get(&actor) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if selection.action != Action::Attack {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(target) = selection.target else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GR-A09: Protection absorbs the Attack entirely.
|
||||||
|
if work.players[&target].protection > 0 {
|
||||||
|
events.push(GroundEvent::AttackCancelled {
|
||||||
|
attacker: actor,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pair = Pair::new(actor, target);
|
||||||
|
match work.relation_between(actor, target) {
|
||||||
|
// GR-A07: Attack through a Bond — +2 and the Bond flips.
|
||||||
|
Some(Relation::Bond) => {
|
||||||
|
let stress = work.stress_after(target, 2);
|
||||||
|
events.push(GroundEvent::StressSet {
|
||||||
|
player: target,
|
||||||
|
stress,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
events.push(GroundEvent::RelationFormed {
|
||||||
|
pair,
|
||||||
|
relation: Relation::Rivalry,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
}
|
||||||
|
// GR-A08: Attack through a Rivalry — +2 and it breaks.
|
||||||
|
Some(Relation::Rivalry) => {
|
||||||
|
let stress = work.stress_after(target, 2);
|
||||||
|
events.push(GroundEvent::StressSet {
|
||||||
|
player: target,
|
||||||
|
stress,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
events.push(GroundEvent::RelationBroken { pair });
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
}
|
||||||
|
// GR-A06: no relation — +1, and a Rivalry forms without
|
||||||
|
// consent if both endpoints have a slot (GR-L01/L03).
|
||||||
|
None => {
|
||||||
|
let stress = work.stress_after(target, 1);
|
||||||
|
events.push(GroundEvent::StressSet {
|
||||||
|
player: target,
|
||||||
|
stress,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
if work.has_free_slot(actor) && work.has_free_slot(target) {
|
||||||
|
events.push(GroundEvent::RelationFormed {
|
||||||
|
pair,
|
||||||
|
relation: Relation::Rivalry,
|
||||||
|
});
|
||||||
|
work.fold(events.last().expect("just pushed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(GroundEvent::StepAdvanced {
|
||||||
|
step: RoundStep::Resolve,
|
||||||
|
});
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GR-R08: Stress is already clamped on every application (U2), so
|
||||||
|
/// End only triggers DARVO, rotates the Lead, and advances the Round.
|
||||||
|
fn end_round_events(&self) -> Vec<GroundEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
// GR-D01: Stress 5 with the marker OFF starts a sequence. In
|
||||||
|
// Lead order, so two simultaneous triggers are ordered (U9).
|
||||||
|
for seat in self.seat_order() {
|
||||||
|
let player = &self.players[&seat];
|
||||||
|
if player.stress == 5 && player.darvo == DarvoStage::Off {
|
||||||
|
events.push(GroundEvent::DarvoTriggered { player: seat });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
|
||||||
|
let next_lead = seats
|
||||||
|
.iter()
|
||||||
|
.position(|s| *s == self.lead)
|
||||||
|
.map_or(self.lead, |i| seats[(i + 1) % seats.len()]);
|
||||||
|
|
||||||
|
events.push(GroundEvent::RoundEnded {
|
||||||
|
round: self.round + 1,
|
||||||
|
next_lead,
|
||||||
|
});
|
||||||
|
events.push(GroundEvent::StepAdvanced {
|
||||||
|
step: RoundStep::Select,
|
||||||
|
});
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
/// GR-A13 targeting legality, shared by every Action.
|
/// GR-A13 targeting legality, shared by every Action.
|
||||||
fn check_targeting(
|
fn check_targeting(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -423,6 +800,9 @@ impl ScenarioGame for GroundState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"spend_freedom" => GroundCommand::SpendFreedom,
|
"spend_freedom" => GroundCommand::SpendFreedom,
|
||||||
|
"reveal" => GroundCommand::Reveal,
|
||||||
|
"resolve" => GroundCommand::Resolve,
|
||||||
|
"end_round" => GroundCommand::EndRound,
|
||||||
other => return Err(format!("unknown command {other:?}")),
|
other => return Err(format!("unknown command {other:?}")),
|
||||||
};
|
};
|
||||||
Ok((actor, command))
|
Ok((actor, command))
|
||||||
|
|
@ -464,6 +844,26 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: relation keys must serialize as JSON object keys.
|
||||||
|
/// With a tuple key, `state_hash` panicked on any state holding a
|
||||||
|
/// relation — that is, on almost every real game state.
|
||||||
|
#[test]
|
||||||
|
fn state_with_relations_hashes() {
|
||||||
|
let mut state = tiny_state();
|
||||||
|
state
|
||||||
|
.relations
|
||||||
|
.insert(Pair::new(PlayerId(1), PlayerId(0)), Relation::Bond);
|
||||||
|
let hash = state_hash_hex(&state);
|
||||||
|
assert_eq!(hash.len(), 64);
|
||||||
|
// GR-O05: the key is canonically ordered, so either construction
|
||||||
|
// order yields the same state and the same hash.
|
||||||
|
let mut mirrored = tiny_state();
|
||||||
|
mirrored
|
||||||
|
.relations
|
||||||
|
.insert(Pair::new(PlayerId(0), PlayerId(1)), Relation::Bond);
|
||||||
|
assert_eq!(hash, state_hash_hex(&mirrored));
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_3p(seed: u64) -> GroundState {
|
fn setup_3p(seed: u64) -> GroundState {
|
||||||
GroundState::setup(
|
GroundState::setup(
|
||||||
&Setup {
|
&Setup {
|
||||||
|
|
|
||||||
48
scenarios/ground/gr-a04-bond-support.yaml
Normal file
48
scenarios/ground/gr-a04-bond-support.yaml
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
scenario: ground/gr-a04-bond-support
|
||||||
|
description: >
|
||||||
|
Support through an existing Bond: −2 Stress and the target's Freedom
|
||||||
|
token is readied (GR-A04, GR-F04). A Support with no relation forms no
|
||||||
|
Bond, because GR-L02 requires the target's consent.
|
||||||
|
covers: [GR-A04, GR-F04, GR-L02, GR-L05]
|
||||||
|
provisional: true
|
||||||
|
seed: 42
|
||||||
|
setup:
|
||||||
|
players: 3
|
||||||
|
preset: standard-3p
|
||||||
|
patch:
|
||||||
|
"lead": 0
|
||||||
|
"relations.0-1": Bond
|
||||||
|
"players.1.stress": 3
|
||||||
|
"players.1.freedom_ready": false
|
||||||
|
commands:
|
||||||
|
- actor: P1
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: SUPPORT, target: P2 }
|
||||||
|
# No relation with P3: Stress drops but no Bond forms without consent.
|
||||||
|
- actor: P2
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: SUPPORT, target: P3 }
|
||||||
|
- actor: P3
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: GROUND }
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: reveal
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: resolve
|
||||||
|
expect:
|
||||||
|
events:
|
||||||
|
- kind: StressSet
|
||||||
|
player: 1
|
||||||
|
stress: 1
|
||||||
|
- kind: FreedomReadied
|
||||||
|
player: 1
|
||||||
|
- kind: StressSet
|
||||||
|
player: 2
|
||||||
|
stress: 1
|
||||||
|
state:
|
||||||
|
"players.1.stress": 1
|
||||||
|
"players.1.freedom_ready": true
|
||||||
|
"players.2.stress": 1
|
||||||
|
# No Bond formed with P3: the relation map is unchanged.
|
||||||
|
"relations": { "0-1": Bond }
|
||||||
|
rejects: []
|
||||||
38
scenarios/ground/gr-a07-bond-flip.yaml
Normal file
38
scenarios/ground/gr-a07-bond-flip.yaml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
scenario: ground/gr-a07-bond-flip
|
||||||
|
description: >
|
||||||
|
Attack through an existing Bond: +2 Stress and the Bond flips to a
|
||||||
|
Rivalry (GR-A07, GR-L04).
|
||||||
|
covers: [GR-A07, GR-L04, GR-O05]
|
||||||
|
provisional: false
|
||||||
|
seed: 42
|
||||||
|
setup:
|
||||||
|
players: 3
|
||||||
|
preset: standard-3p
|
||||||
|
patch:
|
||||||
|
"lead": 0
|
||||||
|
"relations.0-1": Bond
|
||||||
|
commands:
|
||||||
|
- actor: P1
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: ATTACK, target: P2 }
|
||||||
|
- actor: P2
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: GROUND }
|
||||||
|
- actor: P3
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: GROUND }
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: reveal
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: resolve
|
||||||
|
expect:
|
||||||
|
events:
|
||||||
|
- kind: StressSet
|
||||||
|
player: 1
|
||||||
|
stress: 4
|
||||||
|
- kind: RelationFormed
|
||||||
|
relation: Rivalry
|
||||||
|
state:
|
||||||
|
"players.1.stress": 4
|
||||||
|
"relations.0-1": Rivalry
|
||||||
|
rejects: []
|
||||||
42
scenarios/ground/gr-a08-rivalry-break.yaml
Normal file
42
scenarios/ground/gr-a08-rivalry-break.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
scenario: ground/gr-a08-rivalry-break
|
||||||
|
description: >
|
||||||
|
Attack through an existing Rivalry: +2 Stress and the relation breaks
|
||||||
|
(GR-A08, GR-L04). Protection absorbs a separate Attack (GR-A09).
|
||||||
|
covers: [GR-A08, GR-A09, GR-T01]
|
||||||
|
provisional: false
|
||||||
|
seed: 42
|
||||||
|
setup:
|
||||||
|
players: 3
|
||||||
|
preset: standard-3p
|
||||||
|
patch:
|
||||||
|
"lead": 0
|
||||||
|
"relations.0-1": Rivalry
|
||||||
|
"players.2.protection": 1
|
||||||
|
commands:
|
||||||
|
- actor: P1
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: ATTACK, target: P2 }
|
||||||
|
# P3 is protected, so this Attack is cancelled and costs the token.
|
||||||
|
- actor: P2
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: ATTACK, target: P3 }
|
||||||
|
- actor: P3
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: GROUND }
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: reveal
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: resolve
|
||||||
|
expect:
|
||||||
|
events:
|
||||||
|
- kind: StressSet
|
||||||
|
player: 1
|
||||||
|
stress: 4
|
||||||
|
- kind: RelationBroken
|
||||||
|
- kind: AttackCancelled
|
||||||
|
target: 2
|
||||||
|
state:
|
||||||
|
"players.1.stress": 4
|
||||||
|
"players.2.stress": 2
|
||||||
|
"players.2.protection": 0
|
||||||
|
rejects: []
|
||||||
42
scenarios/ground/gr-d01-darvo-trigger.yaml
Normal file
42
scenarios/ground/gr-d01-darvo-trigger.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
scenario: ground/gr-d01-darvo-trigger
|
||||||
|
description: >
|
||||||
|
DARVO trigger: a player who ends the round at Stress 5 with the marker
|
||||||
|
OFF is set to DENY (GR-D01, GR-R08). Stress clamps at 5 on every
|
||||||
|
application (GR-F01 under the U2 default).
|
||||||
|
covers: [GR-D01, GR-R08, GR-F01]
|
||||||
|
provisional: true
|
||||||
|
seed: 42
|
||||||
|
setup:
|
||||||
|
players: 3
|
||||||
|
preset: standard-3p
|
||||||
|
patch:
|
||||||
|
"lead": 0
|
||||||
|
"players.1.stress": 4
|
||||||
|
commands:
|
||||||
|
- actor: P1
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: ATTACK, target: P2 }
|
||||||
|
- actor: P2
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: GROUND }
|
||||||
|
- actor: P3
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: GROUND }
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: reveal
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: resolve
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: end_round
|
||||||
|
expect:
|
||||||
|
events:
|
||||||
|
- kind: StressSet
|
||||||
|
player: 1
|
||||||
|
stress: 5
|
||||||
|
- kind: DarvoTriggered
|
||||||
|
player: 1
|
||||||
|
state:
|
||||||
|
"players.1.stress": 5
|
||||||
|
"players.1.darvo": Deny
|
||||||
|
"round": 2
|
||||||
|
rejects: []
|
||||||
50
scenarios/ground/gr-r06-round-resolve.yaml
Normal file
50
scenarios/ground/gr-r06-round-resolve.yaml
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
scenario: ground/gr-r06-round-resolve
|
||||||
|
description: >
|
||||||
|
A full round: Select, Reveal, Resolve in GR-R06 step order, End. An
|
||||||
|
unrelated Attack raises Stress and forms a Rivalry; a Support with no
|
||||||
|
relation lowers Stress without forming a Bond.
|
||||||
|
covers: [GR-R01, GR-R04, GR-R06, GR-R07, GR-R08, GR-A03, GR-A06, GR-L01, GR-L03, GR-F01]
|
||||||
|
provisional: false
|
||||||
|
seed: 42
|
||||||
|
setup:
|
||||||
|
players: 3
|
||||||
|
preset: standard-3p
|
||||||
|
patch:
|
||||||
|
"lead": 0
|
||||||
|
commands:
|
||||||
|
- actor: P1
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: ATTACK, target: P2 }
|
||||||
|
- actor: P2
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: SUPPORT, target: P3 }
|
||||||
|
- actor: P3
|
||||||
|
cmd: select_action
|
||||||
|
args: { action: GROUND }
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: reveal
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: resolve
|
||||||
|
- actor: SYSTEM
|
||||||
|
cmd: end_round
|
||||||
|
expect:
|
||||||
|
events:
|
||||||
|
- kind: Revealed
|
||||||
|
# GR-R06: Support (step 2) resolves before Attack (step 5).
|
||||||
|
- kind: StressSet
|
||||||
|
player: 2
|
||||||
|
stress: 1
|
||||||
|
- kind: StressSet
|
||||||
|
player: 1
|
||||||
|
stress: 3
|
||||||
|
- kind: RelationFormed
|
||||||
|
relation: Rivalry
|
||||||
|
- kind: RoundEnded
|
||||||
|
round: 2
|
||||||
|
state:
|
||||||
|
"round": 2
|
||||||
|
"lead": 1
|
||||||
|
"players.1.stress": 3
|
||||||
|
"players.2.stress": 1
|
||||||
|
"step": Select
|
||||||
|
rejects: []
|
||||||
Loading…
Add table
Add a link
Reference in a new issue