T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner: - ScenarioGame trait: games own setup presets and the command vocabulary, the runner owns execution, assertions, and determinism. - K8 double-run: every scenario runs twice on the same seed and fails on state-hash divergence. - K4/K11: applied events go through Envelope into EventLog, so seq monotonicity is enforced on the real path, not just in unit tests. - setup.patch was parsed and silently dropped; the runner now applies it generically and errors on a path that does not exist, so a typo in a scenario can never pass as a no-op. - Assertions: dot-path state lookup over objects and arrays, ordered event subsequence matching by field subset, exact rejects-set match. GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead, Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and Freedom spend, GR-A13 targeting legality. cb-sim dispatches by the scenario's game prefix and reports rule coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
be52250850
commit
a09d76f370
7 changed files with 848 additions and 47 deletions
21
.claude/ralph-loop.local.md
Normal file
21
.claude/ralph-loop.local.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
active: true
|
||||
iteration: 2
|
||||
session_id: 8cbd5701-a096-45a4-a419-9b7b1c9419bc
|
||||
max_iterations: 20
|
||||
completion_promise: "HEUREKA"
|
||||
workplan_id: CB-WP-0001
|
||||
workplan_file: workplans/CB-WP-0001-inner-loop.md
|
||||
started_at: "2026-07-31T00:08:23Z"
|
||||
---
|
||||
|
||||
Read the workplan at `workplans/CB-WP-0001-inner-loop.md`.
|
||||
|
||||
If every task has `status: done` AND frontmatter `status: done`:
|
||||
run `rm -f .claude/ralph-loop.local.md` first (deactivates the loop so the stop hook exits cleanly),
|
||||
then output <promise>HEUREKA</promise>.
|
||||
|
||||
Otherwise implement the next `todo` task as described in the workplan.
|
||||
Set task `in_progress` when starting, `done` when complete.
|
||||
When all tasks are done set frontmatter `status: done`.
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
pub mod scenario;
|
||||
|
||||
pub use scenario::{RunOutcome, ScenarioFile};
|
||||
pub use scenario::{parse_actor, run, CommandStep, RunOutcome, ScenarioFile, ScenarioGame, Setup};
|
||||
|
||||
use cb_kernel::PlayerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! Scenario file format and runner scaffold (MetricsAndScenarios §2,
|
||||
//! GameKernel K17). The format parses fully; execution against a game
|
||||
//! aggregate is wired up in T08 — until then `run` reports Unimplemented.
|
||||
//! Scenario file format and runner (MetricsAndScenarios §2, GameKernel
|
||||
//! K17). Games opt in by implementing [`ScenarioGame`]; [`run`] executes a
|
||||
//! scenario twice with the same seed and fails on hash divergence (K8).
|
||||
|
||||
use cb_events::{state_hash_hex, Envelope, EventLog};
|
||||
use cb_kernel::{Actor, Aggregate, EventSeq, GameId, PlayerId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// One scenario file (`scenarios/<game>/<slug>.yaml`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -71,21 +73,227 @@ impl ScenarioFile {
|
|||
/// Result of executing one scenario (twice, per the K8 double-run rule).
|
||||
#[derive(Debug)]
|
||||
pub enum RunOutcome {
|
||||
Passed {
|
||||
covers: Vec<String>,
|
||||
},
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
/// Scaffold state: parsing works, execution lands in T08.
|
||||
Unimplemented,
|
||||
Passed { covers: Vec<String> },
|
||||
Failed { reason: String },
|
||||
}
|
||||
|
||||
/// Runner scaffold. T08 replaces the body with: build aggregate from
|
||||
/// setup, apply commands via validate/fold, check expectations, run twice
|
||||
/// and compare hashes, emit a replay bundle on failure.
|
||||
pub fn run(_scenario: &ScenarioFile) -> RunOutcome {
|
||||
RunOutcome::Unimplemented
|
||||
/// A game aggregate the scenario runner can drive (GameKernel K17). The
|
||||
/// game owns setup presets and the command vocabulary; the runner owns
|
||||
/// execution, assertions, and the determinism check.
|
||||
pub trait ScenarioGame: Aggregate + Serialize + serde::de::DeserializeOwned + Sized {
|
||||
/// Build the initial state for a named preset (GR-S rules for GROUND).
|
||||
fn setup(setup: &Setup, seed: u64) -> Result<Self, String>;
|
||||
|
||||
/// Resolve one scenario step into an actor and a typed command.
|
||||
fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String>;
|
||||
|
||||
/// Current round, for the event envelope (GameKernel K4).
|
||||
fn round(&self) -> u8;
|
||||
}
|
||||
|
||||
/// `P1` → `PlayerId(0)`, `SYSTEM` → [`Actor::System`]. Seat numbering is
|
||||
/// 1-based in scenario files and 0-based in state, matching the
|
||||
/// `players.0.*` assertion paths.
|
||||
pub fn parse_actor(actor: &str) -> Result<Actor, String> {
|
||||
if actor == "SYSTEM" {
|
||||
return Ok(Actor::System);
|
||||
}
|
||||
actor
|
||||
.strip_prefix('P')
|
||||
.and_then(|n| n.parse::<u8>().ok())
|
||||
.filter(|n| *n >= 1)
|
||||
.map(|n| Actor::Player(PlayerId(n - 1)))
|
||||
.ok_or_else(|| format!("unparseable actor {actor:?} (expected P<n> or SYSTEM)"))
|
||||
}
|
||||
|
||||
/// One execution pass: returns the end state as JSON, its hash, the
|
||||
/// emitted events, and the indices of rejected commands.
|
||||
struct Pass {
|
||||
state: serde_json::Value,
|
||||
hash: String,
|
||||
events: Vec<serde_json::Value>,
|
||||
rejected: BTreeSet<usize>,
|
||||
}
|
||||
|
||||
fn execute<G>(scenario: &ScenarioFile) -> Result<Pass, String>
|
||||
where
|
||||
G: ScenarioGame,
|
||||
G::Event: Serialize,
|
||||
{
|
||||
let mut state = G::setup(&scenario.setup, scenario.seed)?;
|
||||
// The runner owns `setup.patch` so no game reimplements it: overrides
|
||||
// are applied to the canonical form and read back.
|
||||
if !scenario.setup.patch.is_empty() {
|
||||
state = apply_patch(&state, &scenario.setup.patch)?;
|
||||
}
|
||||
let mut log: EventLog<G::Event> = EventLog::new();
|
||||
let mut events = Vec::new();
|
||||
let mut rejected = BTreeSet::new();
|
||||
let mut seq = 0u64;
|
||||
|
||||
for (index, step) in scenario.commands.iter().enumerate() {
|
||||
let (actor, command) = G::parse_command(step)?;
|
||||
match state.validate(actor, &command) {
|
||||
Err(_) => {
|
||||
rejected.insert(index);
|
||||
}
|
||||
Ok(produced) => {
|
||||
for event in produced {
|
||||
events.push(
|
||||
serde_json::to_value(&event).map_err(|e| format!("event encode: {e}"))?,
|
||||
);
|
||||
state.fold(&event);
|
||||
// K4/K11: everything applied is logged in envelope order.
|
||||
log.append(Envelope {
|
||||
seq: EventSeq(seq),
|
||||
game_id: GameId(0),
|
||||
round: state.round(),
|
||||
schema_ver: 1,
|
||||
payload: event,
|
||||
})
|
||||
.map_err(|e| format!("event log: {e}"))?;
|
||||
seq += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Pass {
|
||||
state: serde_json::to_value(&state).map_err(|e| format!("state encode: {e}"))?,
|
||||
hash: state_hash_hex(&state),
|
||||
events,
|
||||
rejected,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a scenario against game `G`: run twice with the same seed,
|
||||
/// compare hashes (K8), then check the `expect` block.
|
||||
pub fn run<G>(scenario: &ScenarioFile) -> RunOutcome
|
||||
where
|
||||
G: ScenarioGame,
|
||||
G::Event: Serialize,
|
||||
{
|
||||
let first = match execute::<G>(scenario) {
|
||||
Ok(pass) => pass,
|
||||
Err(reason) => return RunOutcome::Failed { reason },
|
||||
};
|
||||
let second = match execute::<G>(scenario) {
|
||||
Ok(pass) => pass,
|
||||
Err(reason) => return RunOutcome::Failed { reason },
|
||||
};
|
||||
|
||||
if first.hash != second.hash {
|
||||
return RunOutcome::Failed {
|
||||
reason: format!(
|
||||
"K8 divergence: run 1 hash {} != run 2 hash {}",
|
||||
first.hash, second.hash
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if let Err(reason) = check(scenario, &first) {
|
||||
return RunOutcome::Failed { reason };
|
||||
}
|
||||
|
||||
RunOutcome::Passed {
|
||||
covers: scenario.covers.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn check(scenario: &ScenarioFile, pass: &Pass) -> Result<(), String> {
|
||||
let expected_rejects: BTreeSet<usize> = scenario.expect.rejects.iter().copied().collect();
|
||||
if pass.rejected != expected_rejects {
|
||||
return Err(format!(
|
||||
"rejects mismatch: expected {expected_rejects:?}, got {:?}",
|
||||
pass.rejected
|
||||
));
|
||||
}
|
||||
|
||||
for (path, want) in &scenario.expect.state {
|
||||
let want = to_json(want)?;
|
||||
let got = lookup(&pass.state, path)
|
||||
.ok_or_else(|| format!("state path {path:?} not found in end state"))?;
|
||||
if got != &want {
|
||||
return Err(format!("state {path:?}: expected {want}, got {got}"));
|
||||
}
|
||||
}
|
||||
|
||||
// `expect.events` is an ordered subsequence: each entry must match a
|
||||
// later event than the previous one, by field subset.
|
||||
let mut cursor = 0usize;
|
||||
for want in &scenario.expect.events {
|
||||
let want = to_json(want)?;
|
||||
let found = pass.events[cursor..]
|
||||
.iter()
|
||||
.position(|event| is_subset(&want, event));
|
||||
match found {
|
||||
Some(offset) => cursor += offset + 1,
|
||||
None => return Err(format!("expected event {want} not found in order")),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(want) = &scenario.expect.state_hash {
|
||||
if want != &pass.hash {
|
||||
return Err(format!(
|
||||
"state_hash mismatch: expected {want}, got {}",
|
||||
pass.hash
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply `setup.patch` dot-path overrides to a freshly built state. Every
|
||||
/// path must already exist — a typo is an error, never a silent no-op.
|
||||
fn apply_patch<G: ScenarioGame>(
|
||||
state: &G,
|
||||
patch: &BTreeMap<String, serde_yaml::Value>,
|
||||
) -> Result<G, String> {
|
||||
let mut json = serde_json::to_value(state).map_err(|e| format!("state encode: {e}"))?;
|
||||
for (path, value) in patch {
|
||||
let value = to_json(value)?;
|
||||
let slot = lookup_mut(&mut json, path)
|
||||
.ok_or_else(|| format!("patch path {path:?} does not exist in the initial state"))?;
|
||||
*slot = value;
|
||||
}
|
||||
serde_json::from_value(json).map_err(|e| format!("patch produced invalid state: {e}"))
|
||||
}
|
||||
|
||||
fn lookup_mut<'v>(
|
||||
root: &'v mut serde_json::Value,
|
||||
path: &str,
|
||||
) -> Option<&'v mut serde_json::Value> {
|
||||
path.split('.').try_fold(root, |node, segment| match node {
|
||||
serde_json::Value::Object(map) => map.get_mut(segment),
|
||||
serde_json::Value::Array(items) => items.get_mut(segment.parse::<usize>().ok()?),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(value).map_err(|e| format!("expectation encode: {e}"))
|
||||
}
|
||||
|
||||
/// Dot-path lookup: object keys and array indices, so `players.0.stress`
|
||||
/// reads the same whether the container is a map or a sequence.
|
||||
fn lookup<'v>(root: &'v serde_json::Value, path: &str) -> Option<&'v serde_json::Value> {
|
||||
path.split('.').try_fold(root, |node, segment| match node {
|
||||
serde_json::Value::Object(map) => map.get(segment),
|
||||
serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Field-subset match: every key in `want` must be present and equal in
|
||||
/// `got`, so scenarios can assert on the fields they care about.
|
||||
fn is_subset(want: &serde_json::Value, got: &serde_json::Value) -> bool {
|
||||
match (want, got) {
|
||||
(serde_json::Value::Object(w), serde_json::Value::Object(g)) => w
|
||||
.iter()
|
||||
.all(|(key, value)| g.get(key).is_some_and(|found| is_subset(value, found))),
|
||||
_ => want == got,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -120,7 +328,23 @@ expect:
|
|||
assert_eq!(parsed.covers, vec!["GR-S02", "GR-S03"]);
|
||||
assert_eq!(parsed.setup.players, 3);
|
||||
assert_eq!(parsed.commands.len(), 1);
|
||||
assert!(matches!(run(&parsed), RunOutcome::Unimplemented));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actors_parse_1_based_into_0_based_seats() {
|
||||
assert_eq!(parse_actor("P1").unwrap(), Actor::Player(PlayerId(0)));
|
||||
assert_eq!(parse_actor("P3").unwrap(), Actor::Player(PlayerId(2)));
|
||||
assert_eq!(parse_actor("SYSTEM").unwrap(), Actor::System);
|
||||
assert!(parse_actor("P0").is_err());
|
||||
assert!(parse_actor("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_paths_index_objects_and_arrays() {
|
||||
let state = serde_json::json!({ "players": { "0": { "stress": 2 } }, "deck": [7, 9] });
|
||||
assert_eq!(lookup(&state, "players.0.stress").unwrap(), &2);
|
||||
assert_eq!(lookup(&state, "deck.1").unwrap(), &9);
|
||||
assert!(lookup(&state, "players.9.stress").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
|
||||
//! GameKernel K15–K16). T07 scaffolds the state shell; validate/fold per
|
||||
//! GR-rule land in T08 with rule IDs cross-referenced in doc comments.
|
||||
//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
|
||||
//! comment, giving a greppable rule→code→scenario chain.
|
||||
|
||||
use cb_kernel::PlayerId;
|
||||
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
|
||||
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
|
|
@ -11,8 +12,12 @@ use std::collections::BTreeMap;
|
|||
pub struct PlayerState {
|
||||
/// GR-F01: clamped 0–5.
|
||||
pub stress: u8,
|
||||
/// GR-F03.
|
||||
/// GR-F03: the Freedom token is READY until spent.
|
||||
pub freedom_ready: bool,
|
||||
/// GR-R03: set when Freedom is spent this round, lifting the stress
|
||||
/// gate for this Select step only. Cleared at round End.
|
||||
#[serde(default)]
|
||||
pub freedom_gate_lifted: bool,
|
||||
/// GR-D01/D02: OFF or the pending/active stage.
|
||||
pub darvo: DarvoStage,
|
||||
pub hand: Vec<SolutionCard>,
|
||||
|
|
@ -75,6 +80,357 @@ pub struct GroundState {
|
|||
pub solution_discard: Vec<SolutionCard>,
|
||||
/// Focus placements: sequence owner → target (GR-T03).
|
||||
pub focus: BTreeMap<PlayerId, PlayerId>,
|
||||
/// GR-R01: which of the four steps the round is in.
|
||||
pub step: RoundStep,
|
||||
/// GR-R02: face-down selections, hidden until Reveal.
|
||||
pub selections: BTreeMap<PlayerId, Selection>,
|
||||
}
|
||||
|
||||
/// GR-R01: Select → Reveal → Resolve → End.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RoundStep {
|
||||
Select,
|
||||
Reveal,
|
||||
Resolve,
|
||||
End,
|
||||
}
|
||||
|
||||
/// GR-R02: one player's face-down choice, with its target where the
|
||||
/// Action requires one (GR-A13).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Selection {
|
||||
pub action: Action,
|
||||
pub target: Option<PlayerId>,
|
||||
pub problem: Option<u32>,
|
||||
}
|
||||
|
||||
/// The five Actions (GR-A01..A13). GROUND's mode is chosen at Reveal
|
||||
/// (GR-R05), not at Select, so it is not part of the selection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
Investigate,
|
||||
Solve,
|
||||
Support,
|
||||
Attack,
|
||||
Ground,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn parse(raw: &str) -> Result<Self, String> {
|
||||
match raw {
|
||||
"INVESTIGATE" => Ok(Action::Investigate),
|
||||
"SOLVE" => Ok(Action::Solve),
|
||||
"SUPPORT" => Ok(Action::Support),
|
||||
"ATTACK" => Ok(Action::Attack),
|
||||
"GROUND" => Ok(Action::Ground),
|
||||
other => Err(format!("unknown action {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// GR-R03: the stress gate admits only ATTACK and GROUND.
|
||||
fn allowed_under_stress_gate(self) -> bool {
|
||||
matches!(self, Action::Attack | Action::Ground)
|
||||
}
|
||||
|
||||
/// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and
|
||||
/// SOLVE target a Problem; GROUND targets neither at Select.
|
||||
fn requires_player_target(self) -> bool {
|
||||
matches!(self, Action::Support | Action::Attack)
|
||||
}
|
||||
|
||||
fn requires_problem_target(self) -> bool {
|
||||
matches!(self, Action::Investigate | Action::Solve)
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands accepted by the GROUND aggregate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GroundCommand {
|
||||
/// GR-R02: choose an Action face down.
|
||||
SelectAction {
|
||||
action: Action,
|
||||
target: Option<PlayerId>,
|
||||
problem: Option<u32>,
|
||||
},
|
||||
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
|
||||
SpendFreedom,
|
||||
}
|
||||
|
||||
/// Events the aggregate emits. `fold` is total over these (K1).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum GroundEvent {
|
||||
ActionSelected {
|
||||
player: PlayerId,
|
||||
selection: Selection,
|
||||
},
|
||||
FreedomSpent {
|
||||
player: PlayerId,
|
||||
},
|
||||
}
|
||||
|
||||
impl GroundState {
|
||||
/// GR-R03: a player at Stress 4–5 is gated unless Freedom is spent.
|
||||
/// Spending flips the token to SPENT, so the gate returns next round.
|
||||
fn stress_gated(&self, player: &PlayerState) -> bool {
|
||||
let _ = self;
|
||||
player.stress >= 4 && !player.freedom_gate_lifted
|
||||
}
|
||||
|
||||
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
|
||||
self.players.get(&id).ok_or(Rejection::Game {
|
||||
code: "no-such-seat".into(),
|
||||
detail: format!("player {id} is not in this game"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Aggregate for GroundState {
|
||||
type Command = GroundCommand;
|
||||
type Event = GroundEvent;
|
||||
|
||||
fn validate(
|
||||
&self,
|
||||
actor: Actor,
|
||||
command: &Self::Command,
|
||||
) -> Result<Vec<Self::Event>, Rejection> {
|
||||
let Actor::Player(id) = actor else {
|
||||
return Err(Rejection::NotAllowedNow);
|
||||
};
|
||||
let player = self.player(id)?;
|
||||
|
||||
match command {
|
||||
// GR-R02: one face-down choice per player, during Select only.
|
||||
GroundCommand::SelectAction {
|
||||
action,
|
||||
target,
|
||||
problem,
|
||||
} => {
|
||||
if self.step != RoundStep::Select {
|
||||
return Err(Rejection::NotAllowedNow);
|
||||
}
|
||||
if self.selections.contains_key(&id) {
|
||||
return Err(Rejection::DuplicateCommand);
|
||||
}
|
||||
if self.stress_gated(player) && !action.allowed_under_stress_gate() {
|
||||
return Err(Rejection::Game {
|
||||
code: "stress-gate".into(),
|
||||
detail: "GR-R03: at Stress 4–5 only ATTACK or GROUND may be selected"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
self.check_targeting(id, *action, *target, *problem)?;
|
||||
Ok(vec![GroundEvent::ActionSelected {
|
||||
player: id,
|
||||
selection: Selection {
|
||||
action: *action,
|
||||
target: *target,
|
||||
problem: *problem,
|
||||
},
|
||||
}])
|
||||
}
|
||||
// GR-R03: spendable during Select, before Reveal, once.
|
||||
GroundCommand::SpendFreedom => {
|
||||
if self.step != RoundStep::Select {
|
||||
return Err(Rejection::NotAllowedNow);
|
||||
}
|
||||
if !player.freedom_ready {
|
||||
return Err(Rejection::Game {
|
||||
code: "freedom-spent".into(),
|
||||
detail: "GR-F03: the Freedom token is already SPENT".into(),
|
||||
});
|
||||
}
|
||||
Ok(vec![GroundEvent::FreedomSpent { player: id }])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fold(&mut self, event: &Self::Event) {
|
||||
match event {
|
||||
GroundEvent::ActionSelected { player, selection } => {
|
||||
self.selections.insert(*player, *selection);
|
||||
}
|
||||
GroundEvent::FreedomSpent { player } => {
|
||||
if let Some(state) = self.players.get_mut(player) {
|
||||
state.freedom_ready = false;
|
||||
state.freedom_gate_lifted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GroundState {
|
||||
/// GR-A13 targeting legality, shared by every Action.
|
||||
fn check_targeting(
|
||||
&self,
|
||||
actor: PlayerId,
|
||||
action: Action,
|
||||
target: Option<PlayerId>,
|
||||
problem: Option<u32>,
|
||||
) -> Result<(), Rejection> {
|
||||
let bad = |detail: String| Rejection::Game {
|
||||
code: "bad-target".into(),
|
||||
detail,
|
||||
};
|
||||
|
||||
if action.requires_player_target() {
|
||||
let target = target.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a target")))?;
|
||||
if target == actor {
|
||||
return Err(bad(
|
||||
"GR-A13: SUPPORT and ATTACK target another player".into()
|
||||
));
|
||||
}
|
||||
if !self.players.contains_key(&target) {
|
||||
return Err(bad(format!("GR-A13: player {target} is not in this game")));
|
||||
}
|
||||
} else if target.is_some() {
|
||||
return Err(bad(format!("GR-A13: {action:?} takes no player target")));
|
||||
}
|
||||
|
||||
if action.requires_problem_target() {
|
||||
let problem =
|
||||
problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?;
|
||||
if !self.problems.contains_key(&problem) {
|
||||
return Err(bad(format!("GR-A13: no Problem {problem}")));
|
||||
}
|
||||
} else if problem.is_some() {
|
||||
return Err(bad(format!("GR-A13: {action:?} takes no Problem target")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// GR-S01: hidden-Problem priorities admitted per player count.
|
||||
fn problem_priorities(players: u8) -> Result<u8, String> {
|
||||
match players {
|
||||
2 => Ok(2),
|
||||
3..=4 => Ok(3),
|
||||
5..=6 => Ok(4),
|
||||
other => Err(format!("GR-S01: unsupported player count {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// GR-S04: the 24 core Solution cards, 6 per suit, in canonical order
|
||||
/// before the seeded shuffle.
|
||||
fn core_solution_deck() -> Vec<SolutionCard> {
|
||||
[Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]
|
||||
.into_iter()
|
||||
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ScenarioGame for GroundState {
|
||||
/// GR-S01..S04. The `standard-Np` presets differ only in seat count;
|
||||
/// Problem content is scenario data, so the preset uses the canonical
|
||||
/// fixture below (suit cycling by priority) until scenario decks are
|
||||
/// 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 priorities = problem_priorities(seats)?;
|
||||
let mut rng = ChaChaRng::from_seed(Seed(seed));
|
||||
|
||||
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
|
||||
let mut deck = core_solution_deck();
|
||||
rng.shuffle(&mut deck);
|
||||
|
||||
// GR-S02: Stress 2, Freedom READY, DARVO OFF, two Solution cards.
|
||||
let mut players = BTreeMap::new();
|
||||
for seat in 0..seats {
|
||||
let hand = deck.split_off(deck.len() - 2);
|
||||
players.insert(
|
||||
PlayerId(seat),
|
||||
PlayerState {
|
||||
stress: 2,
|
||||
freedom_ready: true,
|
||||
freedom_gate_lifted: false,
|
||||
darvo: DarvoStage::Off,
|
||||
hand,
|
||||
protection: 0,
|
||||
blame_from: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// GR-S01: priority 1 is the Surface Problem, face up; the rest
|
||||
// start face down.
|
||||
let suits = [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change];
|
||||
let problems = (1..=u32::from(priorities))
|
||||
.map(|priority| {
|
||||
(
|
||||
priority,
|
||||
ProblemState {
|
||||
suit: suits[(priority as usize - 1) % suits.len()],
|
||||
value: priority as u8,
|
||||
face_up: priority == 1,
|
||||
denied: false,
|
||||
claimed_by: None,
|
||||
protected_this_round: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// GR-S03: seeded-random Lead, Round 1.
|
||||
let lead = PlayerId(rng.draw(u32::from(seats)) as u8);
|
||||
|
||||
Ok(GroundState {
|
||||
round: 1,
|
||||
lead,
|
||||
players,
|
||||
relations: BTreeMap::new(),
|
||||
problems,
|
||||
solution_deck: deck,
|
||||
solution_discard: vec![],
|
||||
focus: BTreeMap::new(),
|
||||
step: RoundStep::Select,
|
||||
selections: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String> {
|
||||
let actor = parse_actor(&step.actor)?;
|
||||
let arg_str = |key: &str| -> Result<String, String> {
|
||||
step.args
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str().map(str::to_string))
|
||||
.ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd))
|
||||
};
|
||||
let arg_u64 = |key: &str| -> Option<u64> { step.args.get(key).and_then(|v| v.as_u64()) };
|
||||
|
||||
let command = match step.cmd.as_str() {
|
||||
"select_action" => {
|
||||
let action = Action::parse(&arg_str("action")?)?;
|
||||
let target = match step.args.get("target") {
|
||||
Some(_) => match parse_actor(&arg_str("target")?)? {
|
||||
Actor::Player(id) => Some(id),
|
||||
Actor::System => return Err("target may not be SYSTEM".into()),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
GroundCommand::SelectAction {
|
||||
action,
|
||||
target,
|
||||
problem: arg_u64("problem").map(|p| p as u32),
|
||||
}
|
||||
}
|
||||
"spend_freedom" => GroundCommand::SpendFreedom,
|
||||
other => return Err(format!("unknown command {other:?}")),
|
||||
};
|
||||
Ok((actor, command))
|
||||
}
|
||||
|
||||
fn round(&self) -> u8 {
|
||||
self.round
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -91,6 +447,7 @@ mod tests {
|
|||
PlayerState {
|
||||
stress: 2,
|
||||
freedom_ready: true,
|
||||
freedom_gate_lifted: false,
|
||||
darvo: DarvoStage::Off,
|
||||
hand: vec![SolutionCard { suit: Suit::Repair }],
|
||||
protection: 0,
|
||||
|
|
@ -102,9 +459,122 @@ mod tests {
|
|||
solution_deck: vec![],
|
||||
solution_discard: vec![],
|
||||
focus: BTreeMap::new(),
|
||||
step: RoundStep::Select,
|
||||
selections: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_3p(seed: u64) -> GroundState {
|
||||
GroundState::setup(
|
||||
&Setup {
|
||||
players: 3,
|
||||
preset: "standard-3p".into(),
|
||||
patch: BTreeMap::new(),
|
||||
},
|
||||
seed,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// GR-S02/S04: every seat starts at Stress 2 with two dealt cards,
|
||||
/// and the deck loses exactly what was dealt.
|
||||
#[test]
|
||||
fn setup_deals_per_gr_s02_and_s04() {
|
||||
let state = setup_3p(42);
|
||||
assert_eq!(state.players.len(), 3);
|
||||
assert_eq!(state.round, 1);
|
||||
for player in state.players.values() {
|
||||
assert_eq!(player.stress, 2);
|
||||
assert!(player.freedom_ready);
|
||||
assert_eq!(player.darvo, DarvoStage::Off);
|
||||
assert_eq!(player.hand.len(), 2);
|
||||
}
|
||||
assert_eq!(state.solution_deck.len(), 24 - 6);
|
||||
// GR-S01: 3 players → priorities 1–3, priority 1 face up.
|
||||
assert_eq!(state.problems.len(), 3);
|
||||
assert!(state.problems[&1].face_up);
|
||||
assert!(!state.problems[&2].face_up);
|
||||
}
|
||||
|
||||
/// GR-S03/S04: the same seed reproduces setup exactly; a different
|
||||
/// seed does not.
|
||||
#[test]
|
||||
fn setup_is_seed_deterministic() {
|
||||
assert_eq!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(42)));
|
||||
assert_ne!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(7)));
|
||||
}
|
||||
|
||||
/// GR-R02: one selection per player per round.
|
||||
#[test]
|
||||
fn second_selection_is_a_duplicate() {
|
||||
let mut state = setup_3p(42);
|
||||
let cmd = GroundCommand::SelectAction {
|
||||
action: Action::Attack,
|
||||
target: Some(PlayerId(1)),
|
||||
problem: None,
|
||||
};
|
||||
let events = state.validate(Actor::Player(PlayerId(0)), &cmd).unwrap();
|
||||
for event in &events {
|
||||
state.fold(event);
|
||||
}
|
||||
assert_eq!(
|
||||
state.validate(Actor::Player(PlayerId(0)), &cmd),
|
||||
Err(Rejection::DuplicateCommand)
|
||||
);
|
||||
}
|
||||
|
||||
/// GR-R03: the stress gate blocks SUPPORT at Stress 4, and spending
|
||||
/// Freedom lifts it.
|
||||
#[test]
|
||||
fn stress_gate_blocks_until_freedom_is_spent() {
|
||||
let mut state = setup_3p(42);
|
||||
state.players.get_mut(&PlayerId(0)).unwrap().stress = 4;
|
||||
let support = GroundCommand::SelectAction {
|
||||
action: Action::Support,
|
||||
target: Some(PlayerId(1)),
|
||||
problem: None,
|
||||
};
|
||||
let attack = GroundCommand::SelectAction {
|
||||
action: Action::Attack,
|
||||
target: Some(PlayerId(1)),
|
||||
problem: None,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
state.validate(Actor::Player(PlayerId(0)), &support),
|
||||
Err(Rejection::Game { ref code, .. }) if code == "stress-gate"
|
||||
));
|
||||
// ATTACK is always admitted by the gate.
|
||||
assert!(state.validate(Actor::Player(PlayerId(0)), &attack).is_ok());
|
||||
|
||||
let spent = state
|
||||
.validate(Actor::Player(PlayerId(0)), &GroundCommand::SpendFreedom)
|
||||
.unwrap();
|
||||
for event in &spent {
|
||||
state.fold(event);
|
||||
}
|
||||
assert!(!state.players[&PlayerId(0)].freedom_ready);
|
||||
assert!(state.validate(Actor::Player(PlayerId(0)), &support).is_ok());
|
||||
}
|
||||
|
||||
/// GR-A13: SUPPORT and ATTACK may not target their own player.
|
||||
#[test]
|
||||
fn self_targeting_is_rejected() {
|
||||
let state = setup_3p(42);
|
||||
let result = state.validate(
|
||||
Actor::Player(PlayerId(0)),
|
||||
&GroundCommand::SelectAction {
|
||||
action: Action::Attack,
|
||||
target: Some(PlayerId(0)),
|
||||
problem: None,
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(Rejection::Game { ref code, .. }) if code == "bad-target"
|
||||
));
|
||||
}
|
||||
|
||||
/// K7 on the real aggregate: hash stable across clones, sensitive to
|
||||
/// semantic change.
|
||||
#[test]
|
||||
|
|
|
|||
35
scenarios/ground/gr-r02-select.yaml
Normal file
35
scenarios/ground/gr-r02-select.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
scenario: ground/gr-r02-select
|
||||
description: >
|
||||
Select step: each player commits exactly one face-down Action, and a
|
||||
second commit from the same player is rejected as a duplicate.
|
||||
covers: [GR-R02, GR-A13]
|
||||
provisional: false
|
||||
seed: 42
|
||||
setup:
|
||||
players: 3
|
||||
preset: standard-3p
|
||||
commands:
|
||||
- actor: P1
|
||||
cmd: select_action
|
||||
args: { action: ATTACK, target: P2 }
|
||||
- actor: P2
|
||||
cmd: select_action
|
||||
args: { action: SUPPORT, target: P3 }
|
||||
- actor: P3
|
||||
cmd: select_action
|
||||
args: { action: INVESTIGATE, problem: 2 }
|
||||
# GR-R02: one selection per player per round.
|
||||
- actor: P1
|
||||
cmd: select_action
|
||||
args: { action: GROUND }
|
||||
expect:
|
||||
events:
|
||||
- kind: ActionSelected
|
||||
player: 0
|
||||
- kind: ActionSelected
|
||||
player: 1
|
||||
state:
|
||||
"selections.0.action": Attack
|
||||
"selections.1.action": Support
|
||||
"selections.2.problem": 2
|
||||
rejects: [3]
|
||||
34
scenarios/ground/gr-r03-stress-gate.yaml
Normal file
34
scenarios/ground/gr-r03-stress-gate.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
scenario: ground/gr-r03-stress-gate
|
||||
description: >
|
||||
Stress gate: at Stress 4 only ATTACK or GROUND may be selected, until
|
||||
the READY Freedom token is spent, which admits any one Action.
|
||||
covers: [GR-R03, GR-F03]
|
||||
provisional: false
|
||||
seed: 42
|
||||
setup:
|
||||
players: 3
|
||||
preset: standard-3p
|
||||
patch:
|
||||
"players.0.stress": 4
|
||||
commands:
|
||||
# Gated: SUPPORT is not admitted at Stress 4.
|
||||
- actor: P1
|
||||
cmd: select_action
|
||||
args: { action: SUPPORT, target: P2 }
|
||||
- actor: P1
|
||||
cmd: spend_freedom
|
||||
# The same Action is now legal, and the token reads SPENT.
|
||||
- actor: P1
|
||||
cmd: select_action
|
||||
args: { action: SUPPORT, target: P2 }
|
||||
expect:
|
||||
events:
|
||||
- kind: FreedomSpent
|
||||
player: 0
|
||||
- kind: ActionSelected
|
||||
player: 0
|
||||
state:
|
||||
"players.0.stress": 4
|
||||
"players.0.freedom_ready": false
|
||||
"selections.0.action": Support
|
||||
rejects: [0]
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
//! cb-sim — scenario runner binary (GameKernel K17; precursor of `cb sim`).
|
||||
//! T07 scope: parse and validate scenario files, report coverage tags.
|
||||
//! T08 wires execution. Exit codes: 0 all passed, 1 failures, 2 not yet
|
||||
//! executable (parse-only), 64 usage error.
|
||||
//! Parses scenario files, dispatches each to its game by the `<game>/`
|
||||
//! prefix of its `scenario` field, and executes it. Exit codes: 0 all
|
||||
//! passed, 1 failures, 2 unknown game, 64 usage error.
|
||||
|
||||
use cb_game_runtime::{scenario, RunOutcome, ScenarioFile};
|
||||
use games_ground::GroundState;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
|
@ -12,8 +13,10 @@ fn main() {
|
|||
std::process::exit(64);
|
||||
}
|
||||
|
||||
let mut unimplemented = false;
|
||||
let mut unknown_game = false;
|
||||
let mut failed = false;
|
||||
let mut passed = 0usize;
|
||||
let mut covered: Vec<String> = Vec::new();
|
||||
|
||||
for path in &args {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
|
|
@ -24,36 +27,50 @@ fn main() {
|
|||
continue;
|
||||
}
|
||||
};
|
||||
match ScenarioFile::from_yaml(&text) {
|
||||
let sc = match ScenarioFile::from_yaml(&text) {
|
||||
Err(e) => {
|
||||
eprintln!("{path}: parse error: {e}");
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
Ok(sc) => sc,
|
||||
};
|
||||
|
||||
let outcome = match sc.scenario.split('/').next() {
|
||||
Some("ground") => scenario::run::<GroundState>(&sc),
|
||||
_ => {
|
||||
println!("SKIP {} — no game registered for this prefix", sc.scenario);
|
||||
unknown_game = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
RunOutcome::Passed { covers } => {
|
||||
println!(
|
||||
"PASS {} covers={}{}",
|
||||
sc.scenario,
|
||||
covers.join(","),
|
||||
if sc.provisional { " provisional" } else { "" }
|
||||
);
|
||||
covered.extend(covers);
|
||||
passed += 1;
|
||||
}
|
||||
RunOutcome::Failed { reason } => {
|
||||
println!("FAIL {} — {reason}", sc.scenario);
|
||||
failed = true;
|
||||
}
|
||||
Ok(sc) => match scenario::run(&sc) {
|
||||
RunOutcome::Passed { covers } => {
|
||||
println!("PASS {} covers={}", sc.scenario, covers.join(","));
|
||||
}
|
||||
RunOutcome::Failed { reason } => {
|
||||
println!("FAIL {} — {reason}", sc.scenario);
|
||||
failed = true;
|
||||
}
|
||||
RunOutcome::Unimplemented => {
|
||||
println!(
|
||||
"PARSED {} covers={}{} (runner not yet implemented — T08)",
|
||||
sc.scenario,
|
||||
sc.covers.join(","),
|
||||
if sc.provisional { " provisional" } else { "" }
|
||||
);
|
||||
unimplemented = true;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
covered.sort();
|
||||
covered.dedup();
|
||||
println!("{passed} passed, {} rules covered", covered.len());
|
||||
|
||||
if failed {
|
||||
std::process::exit(1);
|
||||
}
|
||||
if unimplemented {
|
||||
if unknown_game {
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue