diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 6b7bc6a..88a90e6 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -140,11 +140,49 @@ pub struct GroundState { pub support_responses: BTreeMap, /// GR-D03/D04: the mandatory target a DARVO stage needs this round. pub darvo_targets: BTreeMap, + /// GR-E02..E04: which scoring mode this game uses. + pub mode: ScoringMode, + /// GR-R09: set once the game has ended and scoring has run. + pub outcome: Option, /// GR-S04/U4: retained so a deck reshuffle stays a pure function of /// state, keeping `validate` deterministic without holding RNG state. pub seed: u64, } +/// GR-E02..E04: the three scoring modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ScoringMode { + /// GR-E02, co-op: one shared score against the threshold. + SharedGround, + /// GR-E03, semi-co-op: personal scores once the group qualifies. + CommonProblem, + /// GR-E04: Bond networks score together. + BondedCoalitions, +} + +/// GR-E01..E04: the final scoring result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Outcome { + /// GR-E01: summed printed values of all claimed Problems. + pub total: u32, + pub threshold: u32, + pub group_success: bool, + /// GR-E03: claimed value −1 per Blame held. + pub personal: BTreeMap, + /// GR-E04: each Bond-connected group and its combined score. + pub coalitions: Vec, + /// GR-E02: only meaningful in SHARED GROUND. + pub mastery: Option, + pub winners: Vec, +} + +/// GR-E04: one Bond-connected group. Unbonded players are solo. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Coalition { + pub members: Vec, + pub score: i32, +} + /// GR-D03/D04: what a DARVO stage acts on this round. DENY names a /// Problem, ATTACK names a player; REVERSE takes its target from the /// Focus token placed by the ATTACK stage. @@ -470,6 +508,10 @@ pub enum GroundEvent { DarvoEnded { player: PlayerId, }, + /// GR-R09/E01..E04: the game ended and scoring ran. + GameEnded { + outcome: Outcome, + }, /// GR-R01: the round advanced to `step`. StepAdvanced { step: RoundStep, @@ -921,6 +963,10 @@ impl Aggregate for GroundState { // GR-D06: an unresolved Focus token comes back. self.focus.remove(player); } + GroundEvent::GameEnded { outcome } => { + self.outcome = Some(outcome.clone()); + self.step = RoundStep::End; + } GroundEvent::StepAdvanced { step } => { self.step = *step; } @@ -1381,6 +1427,14 @@ impl GroundState { .position(|s| *s == self.lead) .map_or(self.lead, |i| seats[(i + 1) % seats.len()]); + // GR-R09: after Round 5's End the game ends and scoring applies. + if self.round >= 5 { + events.push(GroundEvent::GameEnded { + outcome: self.score(), + }); + return events; + } + events.push(GroundEvent::RoundEnded { round: self.round + 1, next_lead, @@ -1391,6 +1445,179 @@ impl GroundState { events } + /// GR-E01: the Scenario threshold by player count (dataset 0.1). + fn threshold(&self) -> u32 { + match self.players.len() { + 0..=2 => 5, + 3..=4 => 7, + _ => 9, + } + } + + /// GR-E01..E04: final scoring for the configured mode. + fn score(&self) -> Outcome { + // GR-E01/P03: a claimed Problem counts its printed value. + let total: u32 = self + .problems + .values() + .filter(|p| p.claimed_by.is_some()) + .map(|p| u32::from(p.value)) + .sum(); + let threshold = self.threshold(); + let group_success = total >= threshold; + + // GR-E03/T02: claimed value −1 per Blame token held. + let personal: BTreeMap = self + .players + .keys() + .map(|seat| { + let claimed: i32 = self + .problems + .values() + .filter(|p| p.claimed_by == Some(*seat)) + .map(|p| i32::from(p.value)) + .sum(); + let blame = self.players[seat].blame_from.len() as i32; + (*seat, claimed - blame) + }) + .collect(); + + let coalitions = self.coalitions(&personal); + + let (mastery, winners) = match self.mode { + // GR-E02: one shared score; no individual winner. + ScoringMode::SharedGround => { + let blame: i32 = self + .players + .values() + .map(|p| p.blame_from.len() as i32) + .sum(); + let denied = self.problems.values().filter(|p| p.denied).count() as i32; + let claimed = self + .problems + .values() + .filter(|p| p.claimed_by.is_some()) + .count() as i32; + let mastery = claimed - blame - denied; + let winners = if group_success { + self.players.keys().copied().collect() + } else { + Vec::new() + }; + (Some(mastery), winners) + } + // GR-E03: highest personal score, once the group qualifies. + // Tiebreak: lower Stress, then more Bonds, then shared. + ScoringMode::CommonProblem => { + let winners = if group_success { + self.best(personal.keys().copied().collect(), |seat| { + ( + personal[&seat], + -i32::from(self.players[&seat].stress), + self.bond_count(seat), + ) + }) + } else { + Vec::new() + }; + (None, winners) + } + // GR-E04: highest coalition. Tiebreak: lower combined + // Stress, then fewer Blame tokens, then shared. + ScoringMode::BondedCoalitions => { + let winners = if group_success { + let best = self.best((0..coalitions.len()).collect(), |i| { + let c = &coalitions[i]; + let stress: i32 = c + .members + .iter() + .map(|m| i32::from(self.players[m].stress)) + .sum(); + let blame: i32 = c + .members + .iter() + .map(|m| self.players[m].blame_from.len() as i32) + .sum(); + (c.score, -stress, -blame) + }); + let mut winners: Vec = best + .into_iter() + .flat_map(|i| coalitions[i].members.clone()) + .collect(); + winners.sort(); + winners + } else { + Vec::new() + }; + (None, winners) + } + }; + + Outcome { + total, + threshold, + group_success, + personal, + coalitions, + mastery, + winners, + } + } + + /// Every candidate tied on the ranking key, so a tie is a shared + /// result rather than an arbitrary pick (GR-E03/E04). + fn best(&self, candidates: Vec, key: impl Fn(T) -> K) -> Vec { + let Some(top) = candidates.iter().map(|c| key(*c)).max() else { + return Vec::new(); + }; + candidates.into_iter().filter(|c| key(*c) == top).collect() + } + + fn bond_count(&self, seat: PlayerId) -> i32 { + self.relations + .iter() + .filter(|(pair, rel)| **rel == Relation::Bond && pair.contains(seat)) + .count() as i32 + } + + /// GR-E04: connected components over Bonds only; Rivalries do not + /// connect, and an unbonded player is a coalition of one. + fn coalitions(&self, personal: &BTreeMap) -> Vec { + let mut remaining: Vec = self.players.keys().copied().collect(); + let mut out = Vec::new(); + while let Some(seed) = remaining.first().copied() { + let mut members = vec![seed]; + let mut frontier = vec![seed]; + remaining.retain(|p| *p != seed); + while let Some(current) = frontier.pop() { + let neighbours: Vec = self + .relations + .iter() + .filter(|(_, rel)| **rel == Relation::Bond) + .filter_map(|(pair, _)| { + if pair.0 == current { + Some(pair.1) + } else if pair.1 == current { + Some(pair.0) + } else { + None + } + }) + .filter(|n| remaining.contains(n)) + .collect(); + for n in neighbours { + remaining.retain(|p| *p != n); + members.push(n); + frontier.push(n); + } + } + members.sort(); + let score = members.iter().map(|m| personal[m]).sum(); + out.push(Coalition { members, score }); + } + out + } + /// GR-A11/A12: the sub-choice must name something that exists and /// is in a state the choice can act on. fn check_ground_choice( @@ -1608,6 +1835,8 @@ impl ScenarioGame for GroundState { ground_choices: BTreeMap::new(), support_responses: BTreeMap::new(), darvo_targets: BTreeMap::new(), + mode: ScoringMode::SharedGround, + outcome: None, seed, }) } @@ -1709,6 +1938,8 @@ mod tests { ground_choices: BTreeMap::new(), support_responses: BTreeMap::new(), darvo_targets: BTreeMap::new(), + mode: ScoringMode::SharedGround, + outcome: None, seed: 0, } } diff --git a/scenarios/ground/gr-a02-solve.yaml b/scenarios/ground/gr-a02-solve.yaml index be56995..86874f3 100644 --- a/scenarios/ground/gr-a02-solve.yaml +++ b/scenarios/ground/gr-a02-solve.yaml @@ -3,7 +3,7 @@ description: > SOLVE discards a Solution of the Problem's suit and claims it (GR-A02). A second solver of the same Problem the same round spends nothing, because an earlier resolver in Lead order already claimed it. -covers: [GR-A02, GR-R07, GR-O04] +covers: [GR-A02, GR-R07, GR-O04, GR-P04] provisional: false seed: 42 setup: diff --git a/scenarios/ground/gr-e02-shared-ground.yaml b/scenarios/ground/gr-e02-shared-ground.yaml new file mode 100644 index 0000000..8182ef2 --- /dev/null +++ b/scenarios/ground/gr-e02-shared-ground.yaml @@ -0,0 +1,50 @@ +scenario: ground/gr-e02-shared-ground +description: > + SHARED GROUND scoring after Round 5 (GR-R09, GR-E01, GR-E02): one + shared total against the player-count threshold, with a Mastery + rating reduced by each Blame token and each Denied Problem. +covers: [GR-R09, GR-E01, GR-E02, GR-P03] +provisional: true +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 0 + "round": 5 + "mode": SharedGround + # 3p threshold is 7; claimed values 1+3 fall short. + "problems.1.claimed_by": 0 + "problems.3.claimed_by": 1 + "problems.2.denied": true + "players.2.blame_from": [0] +commands: + - actor: P1 + cmd: select_action + args: { action: INVESTIGATE, problem: 2 } + - actor: P2 + cmd: select_action + args: { action: INVESTIGATE, problem: 2 } + - actor: P3 + cmd: select_action + args: { action: INVESTIGATE, problem: 2 } + - actor: SYSTEM + cmd: reveal + - actor: SYSTEM + cmd: resolve + - actor: SYSTEM + cmd: end_round +expect: + events: + - kind: GameEnded + state: + "outcome.total": 4 + "outcome.threshold": 7 + "outcome.group_success": false + # 2 claimed Problems, −1 Blame, −1 Denied. + "outcome.mastery": 0 + "outcome.winners": [] + # GR-R09: the game ended rather than advancing to Round 6. + "round": 5 + "step": End + rejects: [] diff --git a/scenarios/ground/gr-e04-coalitions.yaml b/scenarios/ground/gr-e04-coalitions.yaml new file mode 100644 index 0000000..d9771ec --- /dev/null +++ b/scenarios/ground/gr-e04-coalitions.yaml @@ -0,0 +1,54 @@ +scenario: ground/gr-e04-coalitions +description: > + BONDED COALITIONS scoring (GR-E04): Bond networks score together, + Rivalries do not connect, and an unbonded player is a coalition of + one. Personal scores subtract Blame (GR-E03, GR-T02). +covers: [GR-E03, GR-E04, GR-O03] +provisional: true +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 0 + "round": 5 + "mode": BondedCoalitions + # Values 1+2+3 = 6 claimed; 3p threshold is 7, so no group success. + "problems.1.claimed_by": 0 + "problems.2.claimed_by": 1 + "problems.3.claimed_by": 2 + "relations.0-1": Bond + "relations.1-2": Rivalry + "players.0.blame_from": [2] +commands: + - actor: P1 + cmd: select_action + args: { action: SUPPORT, target: P2 } + - actor: P2 + cmd: select_action + args: { action: SUPPORT, target: P1 } + - actor: P3 + cmd: select_action + args: { action: INVESTIGATE, problem: 2 } + - actor: SYSTEM + cmd: reveal + - actor: SYSTEM + cmd: resolve + - actor: SYSTEM + cmd: end_round +expect: + events: + - kind: GameEnded + state: + "outcome.total": 6 + "outcome.group_success": false + # P1 claimed value 1 less one Blame; P2 value 2; P3 value 3. + "outcome.personal.0": 0 + "outcome.personal.1": 2 + "outcome.personal.2": 3 + # GR-E04: P1+P2 are Bonded; the Rivalry leaves P3 solo. + "outcome.coalitions.0.members": [0, 1] + "outcome.coalitions.0.score": 2 + "outcome.coalitions.1.members": [2] + "outcome.coalitions.1.score": 3 + rejects: [] diff --git a/scenarios/ground/gr-f02-no-gate.yaml b/scenarios/ground/gr-f02-no-gate.yaml new file mode 100644 index 0000000..105a368 --- /dev/null +++ b/scenarios/ground/gr-f02-no-gate.yaml @@ -0,0 +1,29 @@ +scenario: ground/gr-f02-no-gate +description: > + Below the stress gate every Action is selectable (GR-F02): at Stress 3 + a player may choose SOLVE, which GR-R03 would refuse at Stress 4. +covers: [GR-F02] +provisional: false +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 0 + "players.0.stress": 3 + "players.1.stress": 4 + "players.0.hand": [{ suit: Clarify }] + "players.1.hand": [{ suit: Clarify }] +commands: + # GR-F02: Stress 3 is below the gate, so SOLVE is available. + - actor: P1 + cmd: select_action + args: { action: SOLVE, problem: 1 } + # GR-R03: the same Action at Stress 4 is refused. + - actor: P2 + cmd: select_action + args: { action: SOLVE, problem: 1 } +expect: + state: + "selections.0.action": Solve + rejects: [1] diff --git a/scenarios/ground/smoke-setup.yaml b/scenarios/ground/smoke-setup.yaml index c665ac3..a5304fc 100644 --- a/scenarios/ground/smoke-setup.yaml +++ b/scenarios/ground/smoke-setup.yaml @@ -2,7 +2,7 @@ scenario: ground/smoke-setup description: > Smoke scenario for the T07 scaffold: exercises the documented file format end to end. Assertions are the GR-S setup facts; execution lands in T08. -covers: [GR-S02, GR-S03, GR-O01] +covers: [GR-S02, GR-S03, GR-S04, GR-O01, GR-O02] provisional: false seed: 42 setup: @@ -14,4 +14,10 @@ expect: "round": 1 "players.0.stress": 2 "players.0.freedom_ready": true + "players.0.darvo": Off + "players.0.protection": 0 + "players.0.blame_from": [] + # GR-S04: 24 core Solutions less the 2 dealt to each of 3 players. + "solution_deck.17.suit": Change + "solution_discard": [] rejects: []