T08 iter 6: game end and the three scoring modes; AM-1 at 100%

- GR-R09: after Round 5's End the game ends and scoring runs instead of
  the round advancing.
- GR-E01: claimed Problems sum their printed values against the
  player-count threshold (2p 5, 3-4p 7, 5-6p 9 in dataset 0.1).
- GR-E02 SHARED GROUND: shared score, Mastery reduced per Blame token
  and per Denied Problem.
- GR-E03 COMMON PROBLEM: personal score is claimed value less Blame,
  tiebroken by lower Stress then more Bonds.
- GR-E04 BONDED COALITIONS: connected components over Bonds only, so
  Rivalries do not connect and an unbonded player is a coalition of
  one; tiebroken by lower combined Stress then fewer Blame.

Ties yield every tied candidate rather than an arbitrary pick, which is
what "shared victory" in GR-E03/E04 asks for.

AM-1 rule coverage is now 58/58 (100%), 21 scenarios, 17 tests.

Caveat recorded rather than papered over: GR-E02's "successes" is not
defined in dataset 0.1. It is implemented as the count of claimed
Problems and both scoring scenarios are marked provisional, so a
ground-game ruling flips a scenario rather than the kernel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-31 02:33:27 +02:00
parent 0e92535f11
commit 85d93a9e3c
6 changed files with 372 additions and 2 deletions

View file

@ -140,11 +140,49 @@ pub struct GroundState {
pub support_responses: BTreeMap<PlayerId, SupportResponse>, pub support_responses: BTreeMap<PlayerId, SupportResponse>,
/// GR-D03/D04: the mandatory target a DARVO stage needs this round. /// GR-D03/D04: the mandatory target a DARVO stage needs this round.
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>, pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
/// 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<Outcome>,
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of /// GR-S04/U4: retained so a deck reshuffle stays a pure function of
/// state, keeping `validate` deterministic without holding RNG state. /// state, keeping `validate` deterministic without holding RNG state.
pub seed: u64, 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<PlayerId, i32>,
/// GR-E04: each Bond-connected group and its combined score.
pub coalitions: Vec<Coalition>,
/// GR-E02: only meaningful in SHARED GROUND.
pub mastery: Option<i32>,
pub winners: Vec<PlayerId>,
}
/// GR-E04: one Bond-connected group. Unbonded players are solo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Coalition {
pub members: Vec<PlayerId>,
pub score: i32,
}
/// GR-D03/D04: what a DARVO stage acts on this round. DENY names a /// 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 /// Problem, ATTACK names a player; REVERSE takes its target from the
/// Focus token placed by the ATTACK stage. /// Focus token placed by the ATTACK stage.
@ -470,6 +508,10 @@ pub enum GroundEvent {
DarvoEnded { DarvoEnded {
player: PlayerId, player: PlayerId,
}, },
/// GR-R09/E01..E04: the game ended and scoring ran.
GameEnded {
outcome: Outcome,
},
/// GR-R01: the round advanced to `step`. /// GR-R01: the round advanced to `step`.
StepAdvanced { StepAdvanced {
step: RoundStep, step: RoundStep,
@ -921,6 +963,10 @@ impl Aggregate for GroundState {
// GR-D06: an unresolved Focus token comes back. // GR-D06: an unresolved Focus token comes back.
self.focus.remove(player); self.focus.remove(player);
} }
GroundEvent::GameEnded { outcome } => {
self.outcome = Some(outcome.clone());
self.step = RoundStep::End;
}
GroundEvent::StepAdvanced { step } => { GroundEvent::StepAdvanced { step } => {
self.step = *step; self.step = *step;
} }
@ -1381,6 +1427,14 @@ impl GroundState {
.position(|s| *s == self.lead) .position(|s| *s == self.lead)
.map_or(self.lead, |i| seats[(i + 1) % seats.len()]); .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 { events.push(GroundEvent::RoundEnded {
round: self.round + 1, round: self.round + 1,
next_lead, next_lead,
@ -1391,6 +1445,179 @@ impl GroundState {
events 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<PlayerId, i32> = 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<PlayerId> = 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<T: Copy, K: Ord>(&self, candidates: Vec<T>, key: impl Fn(T) -> K) -> Vec<T> {
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<PlayerId, i32>) -> Vec<Coalition> {
let mut remaining: Vec<PlayerId> = 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<PlayerId> = 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 /// GR-A11/A12: the sub-choice must name something that exists and
/// is in a state the choice can act on. /// is in a state the choice can act on.
fn check_ground_choice( fn check_ground_choice(
@ -1608,6 +1835,8 @@ impl ScenarioGame for GroundState {
ground_choices: BTreeMap::new(), ground_choices: BTreeMap::new(),
support_responses: BTreeMap::new(), support_responses: BTreeMap::new(),
darvo_targets: BTreeMap::new(), darvo_targets: BTreeMap::new(),
mode: ScoringMode::SharedGround,
outcome: None,
seed, seed,
}) })
} }
@ -1709,6 +1938,8 @@ mod tests {
ground_choices: BTreeMap::new(), ground_choices: BTreeMap::new(),
support_responses: BTreeMap::new(), support_responses: BTreeMap::new(),
darvo_targets: BTreeMap::new(), darvo_targets: BTreeMap::new(),
mode: ScoringMode::SharedGround,
outcome: None,
seed: 0, seed: 0,
} }
} }

View file

@ -3,7 +3,7 @@ description: >
SOLVE discards a Solution of the Problem's suit and claims it 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 (GR-A02). A second solver of the same Problem the same round spends
nothing, because an earlier resolver in Lead order already claimed it. 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 provisional: false
seed: 42 seed: 42
setup: setup:

View file

@ -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: []

View file

@ -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: []

View file

@ -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]

View file

@ -2,7 +2,7 @@ scenario: ground/smoke-setup
description: > description: >
Smoke scenario for the T07 scaffold: exercises the documented file format 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. 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 provisional: false
seed: 42 seed: 42
setup: setup:
@ -14,4 +14,10 @@ expect:
"round": 1 "round": 1
"players.0.stress": 2 "players.0.stress": 2
"players.0.freedom_ready": true "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: [] rejects: []