diff --git a/crates/cb-game-runtime/src/scenario.rs b/crates/cb-game-runtime/src/scenario.rs index 7ced0c9..1a6aa08 100644 --- a/crates/cb-game-runtime/src/scenario.rs +++ b/crates/cb-game-runtime/src/scenario.rs @@ -253,9 +253,32 @@ fn apply_patch( let mut json = serde_json::to_value(state).map_err(|e| format!("state encode: {e}"))?; for (path, value) in patch { let value = to_json(value)?; - let slot = lookup_mut(&mut json, path) - .ok_or_else(|| format!("patch path {path:?} does not exist in the initial state"))?; - *slot = value; + let (parent_path, key) = path.rsplit_once('.').unwrap_or(("", path.as_str())); + // The parent must exist — a typo mid-path is an error, never a + // 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::() + .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}")) } diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 59d9212..0a45117 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -66,6 +66,54 @@ pub enum Relation { 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(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Pair { + fn deserialize>(deserializer: D) -> Result { + 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::() + .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 /// collections only (GameKernel K6). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -74,7 +122,7 @@ pub struct GroundState { pub lead: PlayerId, pub players: BTreeMap, /// Keyed by (low, high) player pair. - pub relations: BTreeMap<(PlayerId, PlayerId), Relation>, + pub relations: BTreeMap, pub problems: BTreeMap, pub solution_deck: Vec, pub solution_discard: Vec, @@ -154,6 +202,14 @@ pub enum GroundCommand { }, /// GR-R03: spend the READY Freedom token to bypass the stress gate. 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). @@ -167,6 +223,44 @@ pub enum GroundEvent { FreedomSpent { 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 { @@ -177,6 +271,39 @@ impl GroundState { player.stress >= 4 && !player.freedom_gate_lifted } + fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option { + 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 { + let seats: Vec = 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> { self.players.get(&id).ok_or(Rejection::Game { code: "no-such-seat".into(), @@ -194,6 +321,40 @@ impl Aggregate for GroundState { actor: Actor, command: &Self::Command, ) -> Result, 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 { return Err(Rejection::NotAllowedNow); }; @@ -242,6 +403,9 @@ impl Aggregate for GroundState { } 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; } } + 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 { + /// 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 { + 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 { + 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 = 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. fn check_targeting( &self, @@ -423,6 +800,9 @@ impl ScenarioGame for GroundState { } } "spend_freedom" => GroundCommand::SpendFreedom, + "reveal" => GroundCommand::Reveal, + "resolve" => GroundCommand::Resolve, + "end_round" => GroundCommand::EndRound, other => return Err(format!("unknown command {other:?}")), }; 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 { GroundState::setup( &Setup { diff --git a/scenarios/ground/gr-a04-bond-support.yaml b/scenarios/ground/gr-a04-bond-support.yaml new file mode 100644 index 0000000..c4f0443 --- /dev/null +++ b/scenarios/ground/gr-a04-bond-support.yaml @@ -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: [] diff --git a/scenarios/ground/gr-a07-bond-flip.yaml b/scenarios/ground/gr-a07-bond-flip.yaml new file mode 100644 index 0000000..24b00c1 --- /dev/null +++ b/scenarios/ground/gr-a07-bond-flip.yaml @@ -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: [] diff --git a/scenarios/ground/gr-a08-rivalry-break.yaml b/scenarios/ground/gr-a08-rivalry-break.yaml new file mode 100644 index 0000000..fa5e21a --- /dev/null +++ b/scenarios/ground/gr-a08-rivalry-break.yaml @@ -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: [] diff --git a/scenarios/ground/gr-d01-darvo-trigger.yaml b/scenarios/ground/gr-d01-darvo-trigger.yaml new file mode 100644 index 0000000..1b7bc13 --- /dev/null +++ b/scenarios/ground/gr-d01-darvo-trigger.yaml @@ -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: [] diff --git a/scenarios/ground/gr-r06-round-resolve.yaml b/scenarios/ground/gr-r06-round-resolve.yaml new file mode 100644 index 0000000..de7f5cd --- /dev/null +++ b/scenarios/ground/gr-r06-round-resolve.yaml @@ -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: []