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>,
/// GR-D03/D04: the mandatory target a DARVO stage needs this round.
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
/// 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<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
/// 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<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
/// 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,
}
}

View file

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

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