CB-WP-0047: all four boards, and every mode named on the page
Some checks failed
ci / check (push) Failing after 3s
Some checks failed
ci / check (push) Failing after 3s
The modes were already implemented; nothing had ever COMPARED them. The scenarios were not implemented at all: edition::deal has taken a scenario_id since it was written and the only caller passed the literal "SCN_01", so 15 of 20 Problem cards had never been dealt by anything. The seam was the whole mechanism and it sat unused, with nothing red because nothing asked. Scenario is now state (serde default SCN_01, so all 26 recordings replay unchanged), selected by preset `scn-03-4p` with `standard-Np` still meaning SCN_01, and by --scenario/SCENARIO= accepting ids, numbers or titles, validated against the edition rather than a pattern. The threshold now comes off the Scenario card, closing F25's hardcoded 5/7/9. The first version of that control was worthless and mutation said so: all four scenarios print 5/7/9, so reverting to the bands left it green. Split threshold_from() so it can be handed a card that disagrees. The header read `scoring CommonProblem` where the Mode card is titled COMMON PROBLEM, PERSONAL EDGE -- the defect CB-WP-0034 deleted from the move buttons, still standing on the line that says what winning means. The coverage probe was matching that Debug output and went red when it was fixed: third instance (CB-WP-0024, CB-WP-0034). Page now carries the premise, the mode's rules text, and the tiebreak. scenario-panel plays 4x3x3. Findings: SCN_01 and SCN_02 are the same board (identical cells, pinned by a characterisation test); SCN_04 is the hard board at 2p (52% vs 67/73%, the only deck needing two Repair); and group success is EXACTLY equal across all three modes in all 36 cells, because greedy never reads state.mode -- filed F27, the two competitive modes are scoring lenses over cooperative play. F28: SHARED GROUND's mastery subtracts penalties from the claimed COUNT where the mode card's shared score is claimed VALUE. Raised, not fixed; scoring is ground-game's to rule on. Also fixes design.py reporting a backticked path as no reproduction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1d3f1bfe60
commit
d30938b259
19 changed files with 1351 additions and 109 deletions
|
|
@ -474,6 +474,11 @@ pub fn darvo_stages() -> Result<Vec<DarvoStageText>, String> {
|
|||
pub struct ScenarioText {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// What the game is ABOUT, in the edition's words (CB-WP-0047).
|
||||
///
|
||||
/// Vendored and unread until the four scenarios became selectable:
|
||||
/// with one scenario hardcoded there was nothing to tell apart.
|
||||
pub premise: String,
|
||||
pub surface_problem_id: String,
|
||||
pub hidden_problem_ids: Vec<String>,
|
||||
/// GR-E01 by seat band, as the EDITION states it. The engine has its
|
||||
|
|
@ -494,6 +499,7 @@ pub fn scenarios() -> Result<Vec<ScenarioText>, String> {
|
|||
out.push(ScenarioText {
|
||||
id: t.get(row, "scenario_id")?.to_string(),
|
||||
title: t.get(row, "title")?.to_string(),
|
||||
premise: t.get(row, "premise")?.to_string(),
|
||||
surface_problem_id: t.get(row, "surface_problem_id")?.to_string(),
|
||||
hidden_problem_ids: t
|
||||
.get(row, "hidden_problem_ids")?
|
||||
|
|
@ -1007,6 +1013,91 @@ mod card_text_tests {
|
|||
///
|
||||
/// Same shape as F24's solution deck. `inert`, role `default`: green
|
||||
/// because they agree, red the moment either side moves.
|
||||
/// **All four scenarios deal, and they are not the same board**
|
||||
/// (CB-WP-0047).
|
||||
///
|
||||
/// `setup` passed the literal `"SCN_01"`, so 15 of the 20 Problem
|
||||
/// cards had never been dealt by anything. This deals every scenario
|
||||
/// at every seat band and holds the deal to the Scenario card.
|
||||
#[test]
|
||||
fn every_scenario_deals_the_board_its_card_states() {
|
||||
for s in scenarios().expect("Scenarios.csv") {
|
||||
for (players, k) in [(2u8, 2usize), (4, 3), (6, 4)] {
|
||||
let dealt =
|
||||
deal(&s.id, players).unwrap_or_else(|e| panic!("{} at {players}p: {e}", s.id));
|
||||
assert_eq!(
|
||||
dealt.len(),
|
||||
k + 1,
|
||||
"{} at {players}p: Surface + {k} hidden is {} cards",
|
||||
s.id,
|
||||
k + 1
|
||||
);
|
||||
assert_eq!(
|
||||
dealt.iter().filter(|p| p.surface).count(),
|
||||
1,
|
||||
"{}: exactly one Surface Problem is dealt face up",
|
||||
s.id
|
||||
);
|
||||
// The card states the available total; the deal must be
|
||||
// able to reach the threshold or the board is unwinnable.
|
||||
let available: u32 = dealt.iter().map(|p| u32::from(p.value)).sum();
|
||||
let threshold = match players {
|
||||
0..=2 => s.thresholds.0,
|
||||
3..=4 => s.thresholds.1,
|
||||
_ => s.thresholds.2,
|
||||
};
|
||||
assert!(
|
||||
available >= threshold,
|
||||
"{} at {players}p: {available} points available against a \
|
||||
threshold of {threshold} — the group cannot win in principle",
|
||||
s.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Two of the four scenarios are the same board** (CB-WP-0047).
|
||||
///
|
||||
/// SCN_01 and SCN_02 have identical suit-and-value profiles at every
|
||||
/// priority, so they play identically and differ only in prose. Not
|
||||
/// a defect — a designed reskin is a legitimate choice — but it is a
|
||||
/// fact about what "four scenarios" buys, and measuring them as four
|
||||
/// independent boards would be measuring two of them twice.
|
||||
///
|
||||
/// This is a **characterisation** test: it pins what is true today so
|
||||
/// that a change to either deck is a decision rather than a drift.
|
||||
#[test]
|
||||
fn which_scenarios_are_mechanically_distinct() {
|
||||
let profile = |id: &str| -> Vec<(u8, String, u8)> {
|
||||
let mut v: Vec<_> = problems_of(id)
|
||||
.expect(id)
|
||||
.into_iter()
|
||||
.map(|p| (p.priority, format!("{:?}", p.suit), p.value))
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
assert_eq!(
|
||||
profile("SCN_01"),
|
||||
profile("SCN_02"),
|
||||
"SCN_01 and SCN_02 diverged — they were identical boards; \
|
||||
if this is intended, the panel now measures four real boards"
|
||||
);
|
||||
for pair in [
|
||||
("SCN_01", "SCN_03"),
|
||||
("SCN_01", "SCN_04"),
|
||||
("SCN_03", "SCN_04"),
|
||||
] {
|
||||
assert_ne!(
|
||||
profile(pair.0),
|
||||
profile(pair.1),
|
||||
"{} and {} became the same board",
|
||||
pair.0,
|
||||
pair.1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_engine_agrees_with_the_editions_own_numbers() {
|
||||
for s in scenarios().expect("Scenarios.csv") {
|
||||
|
|
|
|||
|
|
@ -200,6 +200,21 @@ pub struct GroundState {
|
|||
/// variants existed still loads, as baseline — which is what it was.
|
||||
#[serde(default)]
|
||||
pub variant: Variant,
|
||||
/// Which of the edition's four Scenarios is on the table
|
||||
/// (CB-WP-0047).
|
||||
///
|
||||
/// **The engine dealt `SCN_01` and only `SCN_01`** for the whole life
|
||||
/// of this repo — the other three decks were vendored, gated, and
|
||||
/// never played. Fifteen of the twenty Problem cards had never
|
||||
/// reached a table.
|
||||
///
|
||||
/// In the state for the same reason `variant` is: the threshold and
|
||||
/// the whole board follow from it, so a recording that did not carry
|
||||
/// it could not be replayed. `#[serde(default)]` returns `SCN_01`,
|
||||
/// which is what every recording written before this field existed
|
||||
/// actually played.
|
||||
#[serde(default = "default_scenario")]
|
||||
pub scenario: String,
|
||||
/// 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
|
||||
|
|
@ -207,6 +222,53 @@ pub struct GroundState {
|
|||
pub seed: u64,
|
||||
}
|
||||
|
||||
/// GR-E01 by seat band, as the engine used to hardcode it.
|
||||
///
|
||||
/// **Kept as the fallback, and only as the fallback.** All four
|
||||
/// scenarios print 5/7/9, so this and the edition agree today — which is
|
||||
/// exactly why a test comparing the two numbers proves nothing. See
|
||||
/// `threshold_from`.
|
||||
fn seat_band_threshold(seats: usize) -> u32 {
|
||||
match seats {
|
||||
0..=2 => 5,
|
||||
3..=4 => 7,
|
||||
_ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// The threshold a Scenario card states, or the seat band if the edition
|
||||
/// does not have that card (CB-WP-0047).
|
||||
///
|
||||
/// **Split out so it can be tested against a card that disagrees.** The
|
||||
/// first version read the edition inline, and mutating it back to the
|
||||
/// hardcoded bands left every test green — because all four scenarios
|
||||
/// print 5/7/9 and the two paths are observationally identical on every
|
||||
/// input the edition can supply. A control that cannot separate the
|
||||
/// thing it is about from its fallback is not a control; taking a list
|
||||
/// as an argument lets one be written.
|
||||
#[cfg(feature = "scenarios")]
|
||||
fn threshold_from(list: &[crate::edition::ScenarioText], scenario: &str, seats: usize) -> u32 {
|
||||
match list.iter().find(|s| s.id == scenario) {
|
||||
Some(s) => {
|
||||
let (two, three_four, five_six) = s.thresholds;
|
||||
match seats {
|
||||
0..=2 => two,
|
||||
3..=4 => three_four,
|
||||
_ => five_six,
|
||||
}
|
||||
}
|
||||
None => seat_band_threshold(seats),
|
||||
}
|
||||
}
|
||||
|
||||
/// The scenario every recording written before CB-WP-0047 played.
|
||||
///
|
||||
/// **Not "the first scenario" — the one that was actually dealt.** The
|
||||
/// distinction matters if the edition ever reorders `Scenarios.csv`.
|
||||
fn default_scenario() -> String {
|
||||
"SCN_01".to_string()
|
||||
}
|
||||
|
||||
/// GR-E02..E04: the three scoring modes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ScoringMode {
|
||||
|
|
@ -1731,13 +1793,31 @@ impl GroundState {
|
|||
events
|
||||
}
|
||||
|
||||
/// GR-E01: the Scenario threshold by player count (dataset 0.1).
|
||||
/// GR-E01: the Scenario threshold, **off the Scenario card**.
|
||||
///
|
||||
/// This was `match seats { 0..=2 => 5, 3..=4 => 7, _ => 9 }` — the
|
||||
/// engine's own copy of a number four cards already print, which is
|
||||
/// exactly what F25 was raised about. It was right only because all
|
||||
/// four scenarios happen to agree, and a fifth scenario with a
|
||||
/// different threshold would have been scored against the old one
|
||||
/// with nothing to say so.
|
||||
///
|
||||
/// The bands survive as the fallback for a state whose scenario the
|
||||
/// edition cannot supply. **That path is unreachable through
|
||||
/// `setup`**, which refuses an unknown scenario before dealing, and
|
||||
/// a test holds it unreachable — a fallback nothing can reach is the
|
||||
/// only kind that cannot silently answer for the real one.
|
||||
#[cfg(feature = "scenarios")]
|
||||
fn threshold(&self) -> u32 {
|
||||
match self.players.len() {
|
||||
0..=2 => 5,
|
||||
3..=4 => 7,
|
||||
_ => 9,
|
||||
}
|
||||
let list = crate::edition::scenarios().unwrap_or_default();
|
||||
threshold_from(&list, &self.scenario, self.players.len())
|
||||
}
|
||||
|
||||
/// Without the `scenarios` feature there is no edition to read, so
|
||||
/// the seat bands are the whole answer rather than a fallback.
|
||||
#[cfg(not(feature = "scenarios"))]
|
||||
fn threshold(&self) -> u32 {
|
||||
seat_band_threshold(self.players.len())
|
||||
}
|
||||
|
||||
/// GR-E01..E04: final scoring for the configured mode.
|
||||
|
|
@ -2055,6 +2135,61 @@ impl GroundState {
|
|||
// GR-S04's deck now lives in `edition::solution_deck` (ADR-0011),
|
||||
// beside the Problem data it is dealt against.
|
||||
|
||||
/// Which Scenario a `Setup` preset names, and for how many seats
|
||||
/// (CB-WP-0047).
|
||||
///
|
||||
/// | preset | scenario |
|
||||
/// |---|---|
|
||||
/// | `standard-4p` | `SCN_01` |
|
||||
/// | `scn-03-4p` | `SCN_03` |
|
||||
///
|
||||
/// **`standard-Np` keeps meaning exactly what it meant**, which is not a
|
||||
/// convenience: twenty-six recorded scenarios name it, and a grammar
|
||||
/// that redefined it would have moved every one of their boards while
|
||||
/// their hashes still claimed to pin them (ADR-0019's discipline, and
|
||||
/// the reason `serde(default)` on the field returns `SCN_01`).
|
||||
///
|
||||
/// The seat count is checked here rather than after the deal, because
|
||||
/// `deal` refuses an unknown scenario and a preset naming the wrong seat
|
||||
/// count would otherwise be reported as a scenario problem.
|
||||
#[cfg(feature = "scenarios")]
|
||||
fn parse_preset(preset: &str, seats: u8) -> Result<String, String> {
|
||||
let suffix = format!("-{seats}p");
|
||||
let Some(head) = preset.strip_suffix(&suffix) else {
|
||||
return Err(format!(
|
||||
"preset {preset:?} does not match {seats} players \
|
||||
(expected {:?} or e.g. {:?})",
|
||||
format!("standard{suffix}"),
|
||||
format!("scn-02{suffix}"),
|
||||
));
|
||||
};
|
||||
let id = match head {
|
||||
"standard" => default_scenario(),
|
||||
other => {
|
||||
let n = other.strip_prefix("scn-").ok_or_else(|| {
|
||||
format!("preset {preset:?}: expected \"standard\" or \"scn-0N\", got {other:?}")
|
||||
})?;
|
||||
format!("SCN_{n}")
|
||||
}
|
||||
};
|
||||
// **Checked against the edition, not against a pattern.** `SCN_09`
|
||||
// matches the shape and is not a scenario; dealing it would fail
|
||||
// later with a message about Problems rather than about the preset.
|
||||
let known = crate::edition::scenarios()?;
|
||||
if !known.iter().any(|s| s.id == id) {
|
||||
return Err(format!(
|
||||
"preset {preset:?} names {id}, which the edition does not have \
|
||||
(it has {})",
|
||||
known
|
||||
.iter()
|
||||
.map(|s| s.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(feature = "scenarios")]
|
||||
impl ScenarioGame for GroundState {
|
||||
/// GR-S01..S04. The `standard-Np` presets differ only in seat count;
|
||||
|
|
@ -2063,17 +2198,13 @@ impl ScenarioGame for GroundState {
|
|||
/// modelled.
|
||||
fn setup(setup: &Setup, seed: u64) -> Result<Self, String> {
|
||||
let seats = setup.players;
|
||||
let expected = format!("standard-{seats}p");
|
||||
if setup.preset != expected {
|
||||
return Err(format!(
|
||||
"preset {:?} does not match {seats} players (expected {expected:?})",
|
||||
setup.preset
|
||||
));
|
||||
}
|
||||
let scenario = parse_preset(&setup.preset, seats)?;
|
||||
// GR-S01 as ruled 2026-08-04: Surface always, plus hidden
|
||||
// priorities 1..=k. The values and suits come from the edition
|
||||
// (ADR-0011); the engine used to invent both.
|
||||
let dealt = crate::edition::deal("SCN_01", seats)?;
|
||||
// (ADR-0011); the engine used to invent both. **And now the
|
||||
// scenario does too** — this argument was the literal `"SCN_01"`
|
||||
// for the whole life of the repo (CB-WP-0047).
|
||||
let dealt = crate::edition::deal(&scenario, seats)?;
|
||||
let mut rng = ChaChaRng::from_seed(Seed(seed));
|
||||
|
||||
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
|
||||
|
|
@ -2139,6 +2270,7 @@ impl ScenarioGame for GroundState {
|
|||
support_responses: BTreeMap::new(),
|
||||
darvo_targets: BTreeMap::new(),
|
||||
mode: ScoringMode::SharedGround,
|
||||
scenario,
|
||||
// Baseline. The driver overwrites this after setup and
|
||||
// before the hash is taken, which is the route `mode` uses
|
||||
// (`table.rs`) — so a recorded session replays under the
|
||||
|
|
@ -3343,6 +3475,7 @@ mod tests {
|
|||
darvo_targets: BTreeMap::new(),
|
||||
mode: ScoringMode::SharedGround,
|
||||
variant: Variant::Baseline,
|
||||
scenario: default_scenario(),
|
||||
outcome: None,
|
||||
seed: 0,
|
||||
}
|
||||
|
|
@ -3380,6 +3513,204 @@ mod tests {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
/// **The mastery rating counts cards; the Mode card counts points**
|
||||
/// (F28, CB-WP-0047).
|
||||
///
|
||||
/// MODE_COOP reads: *"All claimed Problem cards form one shared
|
||||
/// score. … For a mastery rating, subtract 1 for each Blame token
|
||||
/// still in play and 1 for each Denied Problem."*
|
||||
///
|
||||
/// The shared score is the claimed **value** — `total`, which is what
|
||||
/// the threshold is compared against two lines earlier. `mastery`
|
||||
/// subtracts the same penalties from the claimed **count**. Both
|
||||
/// readings fit the sentence; they disagree on every game where a
|
||||
/// 3-point Problem is claimed, which is most of them.
|
||||
///
|
||||
/// **A characterisation test, not a correction.** Scoring is
|
||||
/// `ground-game`'s to rule on (ADR-0015). This pins what the engine
|
||||
/// does today and shows the size of the disagreement, so the ruling
|
||||
/// has a number in front of it.
|
||||
#[test]
|
||||
fn the_mastery_rating_and_the_shared_score_count_different_things() {
|
||||
let mut s = setup_3p(11);
|
||||
s.mode = ScoringMode::SharedGround;
|
||||
// Two claimed Problems worth 2 and 3 — five points, two cards.
|
||||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||||
s.problems.insert(
|
||||
1,
|
||||
ProblemState {
|
||||
suit: Suit::Repair,
|
||||
value: 2,
|
||||
face_up: true,
|
||||
denied: false,
|
||||
claimed_by: Some(seats[0]),
|
||||
protected_this_round: false,
|
||||
owner: None,
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
s.problems.insert(
|
||||
2,
|
||||
ProblemState {
|
||||
suit: Suit::Change,
|
||||
value: 3,
|
||||
face_up: true,
|
||||
denied: false,
|
||||
claimed_by: Some(seats[1]),
|
||||
protected_this_round: false,
|
||||
owner: None,
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
let o = s.score();
|
||||
assert_eq!(o.total, 5, "the shared score is claimed VALUE");
|
||||
assert_eq!(
|
||||
o.mastery,
|
||||
Some(2),
|
||||
"mastery is the claimed COUNT, with no penalties in play"
|
||||
);
|
||||
// The disagreement, stated as a number rather than as a worry.
|
||||
assert_ne!(
|
||||
i32::try_from(o.total).unwrap(),
|
||||
o.mastery.unwrap(),
|
||||
"the two readings agree on this board, so F28 has no bite here \
|
||||
and the example needs replacing"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every scenario is reachable through `setup`** (CB-WP-0047).
|
||||
///
|
||||
/// `setup` passed the literal `"SCN_01"`. `deal` had taken a
|
||||
/// scenario id since the day it was written and no caller ever
|
||||
/// passed a different one — the parameter was the whole seam and it
|
||||
/// sat unused, which is why three decks went unplayed without any
|
||||
/// gate noticing.
|
||||
#[test]
|
||||
fn every_scenario_can_be_set_up_and_carries_its_own_board() {
|
||||
use cb_game_runtime::{ScenarioGame, Setup};
|
||||
let mut boards = std::collections::BTreeSet::new();
|
||||
for s in crate::edition::scenarios().expect("Scenarios.csv") {
|
||||
let preset = if s.id == "SCN_01" {
|
||||
"standard-4p".to_string()
|
||||
} else {
|
||||
format!("scn-{}-4p", s.id.rsplit('_').next().unwrap())
|
||||
};
|
||||
let state = GroundState::setup(
|
||||
&Setup {
|
||||
players: 4,
|
||||
preset: preset.clone(),
|
||||
patch: Default::default(),
|
||||
},
|
||||
7,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{preset}: {e}"));
|
||||
assert_eq!(state.scenario, s.id, "{preset} dealt the wrong scenario");
|
||||
boards.insert(
|
||||
state
|
||||
.problems
|
||||
.values()
|
||||
.map(|p| (format!("{:?}", p.suit), p.value))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
// SCN_01 and SCN_02 are the same board by design, so four
|
||||
// scenarios yield THREE distinct boards. Asserting 4 here would
|
||||
// be asserting a fact about the edition that is not true.
|
||||
assert_eq!(
|
||||
boards.len(),
|
||||
3,
|
||||
"the four scenarios yield {} distinct boards; SCN_01 and SCN_02 \
|
||||
were identical and the other two differ",
|
||||
boards.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The preset grammar, including what it must keep meaning.
|
||||
#[test]
|
||||
fn the_preset_names_a_scenario_and_a_seat_count() {
|
||||
assert_eq!(parse_preset("standard-4p", 4).unwrap(), "SCN_01");
|
||||
assert_eq!(parse_preset("scn-03-6p", 6).unwrap(), "SCN_03");
|
||||
// Twenty-six recordings name `standard-Np`; if this ever stopped
|
||||
// meaning SCN_01 every one of them would replay a different board
|
||||
// while its hash still claimed to pin the old one.
|
||||
assert_eq!(parse_preset("standard-2p", 2).unwrap(), "SCN_01");
|
||||
// A preset that looks like an id but names no card is refused
|
||||
// HERE, so the error is about the preset and not about Problems.
|
||||
assert!(parse_preset("scn-09-4p", 4).is_err());
|
||||
assert!(parse_preset("standard-4p", 3).is_err());
|
||||
assert!(parse_preset("nonsense-4p", 4).is_err());
|
||||
}
|
||||
|
||||
/// **The threshold comes off the Scenario card** (CB-WP-0047, F25).
|
||||
#[test]
|
||||
fn the_threshold_is_the_editions_and_the_fallback_is_unreachable() {
|
||||
use cb_game_runtime::{ScenarioGame, Setup};
|
||||
for s in crate::edition::scenarios().expect("Scenarios.csv") {
|
||||
for (players, want) in [
|
||||
(2u8, s.thresholds.0),
|
||||
(4, s.thresholds.1),
|
||||
(6, s.thresholds.2),
|
||||
] {
|
||||
let preset = if s.id == "SCN_01" {
|
||||
format!("standard-{players}p")
|
||||
} else {
|
||||
format!("scn-{}-{players}p", s.id.rsplit('_').next().unwrap())
|
||||
};
|
||||
let state = GroundState::setup(
|
||||
&Setup {
|
||||
players,
|
||||
preset,
|
||||
patch: Default::default(),
|
||||
},
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state.threshold(),
|
||||
want,
|
||||
"{} at {players}p: the engine scored against its own number",
|
||||
s.id
|
||||
);
|
||||
}
|
||||
}
|
||||
// **The real control.** Every shipped scenario prints 5/7/9, so
|
||||
// the loop above passes whether the number came off the card or
|
||||
// off the old hardcoded bands — verified by mutation: reverting
|
||||
// to the bands left it green. This asks a card that DISAGREES.
|
||||
let odd = crate::edition::ScenarioText {
|
||||
id: "SCN_XX".into(),
|
||||
title: "a card that disagrees".into(),
|
||||
premise: String::new(),
|
||||
surface_problem_id: String::new(),
|
||||
hidden_problem_ids: vec![],
|
||||
thresholds: (4, 6, 8),
|
||||
starting_stress: String::new(),
|
||||
round_track: String::new(),
|
||||
};
|
||||
for (seats, want) in [(2usize, 4), (4, 6), (6, 8)] {
|
||||
assert_eq!(
|
||||
threshold_from(std::slice::from_ref(&odd), "SCN_XX", seats),
|
||||
want,
|
||||
"the threshold did not come off the Scenario card"
|
||||
);
|
||||
}
|
||||
// And the fallback is what answers for a card the edition lacks.
|
||||
assert_eq!(threshold_from(&[], "SCN_XX", 4), 7);
|
||||
|
||||
// That fallback is unreachable through `setup`, which refuses an
|
||||
// unknown scenario before dealing. A fallback nothing can reach
|
||||
// is the only kind that cannot silently answer for the real one.
|
||||
assert!(GroundState::setup(
|
||||
&Setup {
|
||||
players: 4,
|
||||
preset: "scn-09-4p".into(),
|
||||
patch: Default::default(),
|
||||
},
|
||||
3
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// GR-S02/S04: every seat starts at Stress 2 with two dealt cards,
|
||||
/// and the deck loses exactly what was dealt.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ pub struct GroundView {
|
|||
/// change as "no changes" after playing the default.
|
||||
#[serde(default)]
|
||||
pub variant: crate::Variant,
|
||||
/// Which Scenario is on the table (CB-WP-0047).
|
||||
///
|
||||
/// The player was told the premise by nothing: the same four boards
|
||||
/// would have arrived unannounced. Same reasoning as `variant` —
|
||||
/// CB-WP-0044 shipped a rules change the page could not name and the
|
||||
/// maintainer reported it as "no changes".
|
||||
#[serde(default = "crate::default_scenario")]
|
||||
pub scenario: String,
|
||||
pub players: BTreeMap<PlayerId, PlayerView>,
|
||||
pub relations: BTreeMap<Pair, Relation>,
|
||||
pub problems: BTreeMap<u32, ProblemView>,
|
||||
|
|
@ -178,6 +186,7 @@ impl Project for GroundState {
|
|||
step: self.step,
|
||||
mode: self.mode,
|
||||
variant: self.variant,
|
||||
scenario: self.scenario.clone(),
|
||||
players: self
|
||||
.players
|
||||
.iter()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue