T07: Cargo workspace scaffold — cb-kernel/cb-events/cb-game-runtime/games-ground/cb-sim, HashMap deny-lint, scenario format + runner stub, Criterion skeleton, Makefile, CI
Some checks failed
ci / check (push) Has been cancelled
Some checks failed
ci / check (push) Has been cancelled
This commit is contained in:
parent
396990539a
commit
467e2c561d
23 changed files with 1625 additions and 0 deletions
114
crates/cb-game-runtime/src/lib.rs
Normal file
114
crates/cb-game-runtime/src/lib.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! cb-game-runtime — round/phase machinery, commit windows, projections,
|
||||
//! and the scenario runner (GameKernel §2.5, §3). Game-agnostic.
|
||||
|
||||
pub mod scenario;
|
||||
|
||||
pub use scenario::{RunOutcome, ScenarioFile};
|
||||
|
||||
use cb_kernel::PlayerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A simultaneous commit window (GameKernel K12): the runtime opens it
|
||||
/// naming who must submit; submissions are commitment events hidden from
|
||||
/// projections until reveal.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommitWindow<C> {
|
||||
/// Players who must submit, and their commitment once received.
|
||||
pending: BTreeMap<PlayerId, Option<C>>,
|
||||
}
|
||||
|
||||
impl<C> CommitWindow<C> {
|
||||
pub fn open(players: impl IntoIterator<Item = PlayerId>) -> Self {
|
||||
Self {
|
||||
pending: players.into_iter().map(|p| (p, None)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a commitment. Errors on players outside the window or on a
|
||||
/// duplicate submission (GameKernel K12).
|
||||
pub fn submit(&mut self, player: PlayerId, commitment: C) -> Result<(), CommitError> {
|
||||
match self.pending.get_mut(&player) {
|
||||
None => Err(CommitError::NotInWindow(player)),
|
||||
Some(slot @ None) => {
|
||||
*slot = Some(commitment);
|
||||
Ok(())
|
||||
}
|
||||
Some(Some(_)) => Err(CommitError::AlreadyCommitted(player)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.pending.values().all(Option::is_some)
|
||||
}
|
||||
|
||||
/// Consume the window at reveal, yielding commitments in PlayerId
|
||||
/// order (deterministic iteration, GameKernel K6).
|
||||
pub fn reveal(self) -> Result<Vec<(PlayerId, C)>, CommitError> {
|
||||
if !self.is_complete() {
|
||||
return Err(CommitError::Incomplete);
|
||||
}
|
||||
Ok(self
|
||||
.pending
|
||||
.into_iter()
|
||||
.map(|(p, c)| (p, c.expect("checked complete")))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CommitError {
|
||||
NotInWindow(PlayerId),
|
||||
AlreadyCommitted(PlayerId),
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for CommitError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
CommitError::NotInWindow(p) => write!(f, "player {p} is not in this window"),
|
||||
CommitError::AlreadyCommitted(p) => write!(f, "player {p} already committed"),
|
||||
CommitError::Incomplete => write!(f, "window is not complete"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-player projection (GameKernel K13): a total function from state to
|
||||
/// what one seat may see. Implemented by game packages; the runtime only
|
||||
/// fixes the shape so projections can never feed back into validation.
|
||||
pub trait Project {
|
||||
type View: Serialize;
|
||||
fn project(&self, viewer: Viewer) -> Self::View;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Viewer {
|
||||
Player(PlayerId),
|
||||
Spectator,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn commit_window_gates_and_reveals_in_order() {
|
||||
let mut w: CommitWindow<u8> = CommitWindow::open([PlayerId(0), PlayerId(1)]);
|
||||
assert_eq!(
|
||||
w.submit(PlayerId(2), 9),
|
||||
Err(CommitError::NotInWindow(PlayerId(2)))
|
||||
);
|
||||
w.submit(PlayerId(1), 7).unwrap();
|
||||
assert_eq!(
|
||||
w.submit(PlayerId(1), 8),
|
||||
Err(CommitError::AlreadyCommitted(PlayerId(1)))
|
||||
);
|
||||
assert!(!w.is_complete());
|
||||
w.submit(PlayerId(0), 5).unwrap();
|
||||
assert!(w.is_complete());
|
||||
assert_eq!(
|
||||
w.reveal().unwrap(),
|
||||
vec![(PlayerId(0), 5), (PlayerId(1), 7)]
|
||||
);
|
||||
}
|
||||
}
|
||||
131
crates/cb-game-runtime/src/scenario.rs
Normal file
131
crates/cb-game-runtime/src/scenario.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
//! 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.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One scenario file (`scenarios/<game>/<slug>.yaml`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioFile {
|
||||
pub scenario: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Numbered rule IDs this scenario covers (feeds M-D1-COV / AM-1).
|
||||
pub covers: Vec<String>,
|
||||
/// Marks scenarios encoding a PROVISIONAL U-item default
|
||||
/// (GroundRules §Underdetermined): a ground-game ruling flips the
|
||||
/// scenario, not the kernel.
|
||||
#[serde(default)]
|
||||
pub provisional: bool,
|
||||
pub seed: u64,
|
||||
pub setup: Setup,
|
||||
pub commands: Vec<CommandStep>,
|
||||
pub expect: Expect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Setup {
|
||||
pub players: u8,
|
||||
/// Named setup preset from the game spec (e.g. "standard-3p").
|
||||
pub preset: String,
|
||||
/// Explicit state overrides applied after the preset (dot-paths).
|
||||
#[serde(default)]
|
||||
pub patch: BTreeMap<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CommandStep {
|
||||
pub actor: String,
|
||||
pub cmd: String,
|
||||
#[serde(default)]
|
||||
pub args: BTreeMap<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Expect {
|
||||
/// Ordered subsequence of event kinds that must occur.
|
||||
#[serde(default)]
|
||||
pub events: Vec<BTreeMap<String, serde_yaml::Value>>,
|
||||
/// Partial end-state assertions: dot-path → expected value.
|
||||
#[serde(default)]
|
||||
pub state: BTreeMap<String, serde_yaml::Value>,
|
||||
/// Indices into `commands` that must be rejected.
|
||||
#[serde(default)]
|
||||
pub rejects: Vec<usize>,
|
||||
/// Optional full-state golden hash.
|
||||
#[serde(default)]
|
||||
pub state_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl ScenarioFile {
|
||||
pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
|
||||
serde_yaml::from_str(yaml)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
scenario: ground/smoke-setup
|
||||
description: Parses the documented scenario shape end to end.
|
||||
covers: [GR-S02, GR-S03]
|
||||
provisional: false
|
||||
seed: 42
|
||||
setup:
|
||||
players: 3
|
||||
preset: standard-3p
|
||||
patch:
|
||||
"players.0.stress": 4
|
||||
commands:
|
||||
- actor: P1
|
||||
cmd: select_action
|
||||
args: { action: ATTACK, target: P2 }
|
||||
expect:
|
||||
state:
|
||||
"round": 1
|
||||
rejects: []
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn scenario_format_roundtrips() {
|
||||
let parsed = ScenarioFile::from_yaml(SAMPLE).unwrap();
|
||||
assert_eq!(parsed.scenario, "ground/smoke-setup");
|
||||
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 unknown_fields_are_rejected() {
|
||||
let bad = SAMPLE.replace("seed: 42", "seed: 42\ntypo_field: 1");
|
||||
assert!(ScenarioFile::from_yaml(&bad).is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue