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:
parent
0e92535f11
commit
85d93a9e3c
6 changed files with 372 additions and 2 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
50
scenarios/ground/gr-e02-shared-ground.yaml
Normal file
50
scenarios/ground/gr-e02-shared-ground.yaml
Normal 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: []
|
||||
54
scenarios/ground/gr-e04-coalitions.yaml
Normal file
54
scenarios/ground/gr-e04-coalitions.yaml
Normal 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: []
|
||||
29
scenarios/ground/gr-f02-no-gate.yaml
Normal file
29
scenarios/ground/gr-f02-no-gate.yaml
Normal 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]
|
||||
|
|
@ -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: []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue