clay-borg/crates/cb-game-runtime/src/lib.rs

121 lines
3.8 KiB
Rust
Raw Normal View History

//! cb-game-runtime — round/phase machinery, commit windows, projections,
//! and the scenario runner (GameKernel §2.5, §3). Game-agnostic.
AM-4: gate scenario YAML, retarget on audited source, re-measure Adopts both remediations from CB-EV-0001 §4 (maintainer decision). Option A — serde_yaml is now optional behind cb-game-runtime's `scenarios` feature. The scenario module, the ScenarioGame impl and the string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both configurations compile and lint clean under -D warnings. A trap worth recording: `default-features = false` on a *member* dependency is silently ignored when the workspace dependency does not specify it. The first attempt gated nothing while looking correct — the build succeeded and cargo tree still showed all six YAML crates. Fixed by setting it on the workspace dependency. This is the positive-control failure mode in miniature: success was not evidence the change applied. Retarget — AM-4 now measures third-party source under audit, split by build configuration, replacing a crate count that was unreachable without undoing K5/K7 and that does not compare across ecosystems. Re-measured via the new `make dep-weight`, whose own positive control refuses to report when any crate's source cannot be located: shipped runtime 23 crates 246,250 lines target <=250,000 met dev toolchain 29 crates 317,021 lines target <=350,000 met own source 3,408 lines Scenario tooling costs 70,771 lines a shipped game never compiles — the split the single number was hiding. Targets are set at current measurement plus headroom, so they bind on future growth rather than retroactively passing what had failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
#[cfg(feature = "scenarios")]
pub mod scenario;
CB-WP-0006 T06: K10 replay bundles, --replay, and AM-7 re-earned INTENT design decision 8 of 10, unimplemented for six passes. cb-sim had no flag parsing at all, so --replay had nowhere to go. The bundle is manifest + commands.log + initial.snapshot + expected.yaml, dev-only behind the scenarios feature and charged to AM-4b. The command stream goes through the K11 framing built in T05, so a truncated bundle is detected rather than replayed short — the two tasks compose rather than duplicating. The reviewer's D2 correction was real: this was not "a directory of four files". Pass carried only the end state, RunOutcome::Failed was a formatted String, and scenario.rs created an EventLog, appended to it and never read it. All three had to change. The first round trip failed to reproduce, and the cause is worth keeping: state_hash_hex over a serde_json::Value is a different canonical form than over the typed aggregate — Value's map is key-sorted, a struct serializes in declaration order. The bundle was written with one basis and verified with the other. A round trip written to recompute its own comparison value would have PASSED this bug; it failed because the recorded hash came from the producing process, which is control 2's entire purpose. make replay-test implements ADR-0005 §6's four controls, 14/14: a committed deliberately-failing fixture outside the corpus with covers: [] so it neither fails `make sim` nor inflates AM-1; a tampered recorded hash must fail; a log short by one byte and a corrupted length prefix must be rejected; and a mutated manifest seed must fail — which bites only because replay re-derives the initial state from seed+setup and checks it against the recorded snapshot, since restoring from the snapshot alone would leave the seed inert. Plus a control on the controls: the bundle must still replay after every mutation is reverted. AM-7's hash-identical clause is re-earned. The probe records a hash per per-game segment and replays each from its own genesis; folding from the wrong seed now fails. That is the clause ADR-0005 §4 withdrew as mutation-proven inert. The scaling >= 0.9x clause is still unenforced, so AM-7 stays PARTIAL — reported, not rounded up. Kernel coverage 15/18 -> 16/18. facts-check immediately caught the spec's copy of that number going stale, on a number that moved the same hour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:05:37 +02:00
/// K10 replay bundles. Dev-only: a shipped game runtime writes no bundles.
#[cfg(feature = "scenarios")]
pub mod replay;
AM-4: gate scenario YAML, retarget on audited source, re-measure Adopts both remediations from CB-EV-0001 §4 (maintainer decision). Option A — serde_yaml is now optional behind cb-game-runtime's `scenarios` feature. The scenario module, the ScenarioGame impl and the string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both configurations compile and lint clean under -D warnings. A trap worth recording: `default-features = false` on a *member* dependency is silently ignored when the workspace dependency does not specify it. The first attempt gated nothing while looking correct — the build succeeded and cargo tree still showed all six YAML crates. Fixed by setting it on the workspace dependency. This is the positive-control failure mode in miniature: success was not evidence the change applied. Retarget — AM-4 now measures third-party source under audit, split by build configuration, replacing a crate count that was unreachable without undoing K5/K7 and that does not compare across ecosystems. Re-measured via the new `make dep-weight`, whose own positive control refuses to report when any crate's source cannot be located: shipped runtime 23 crates 246,250 lines target <=250,000 met dev toolchain 29 crates 317,021 lines target <=350,000 met own source 3,408 lines Scenario tooling costs 70,771 lines a shipped game never compiles — the split the single number was hiding. Targets are set at current measurement plus headroom, so they bind on future growth rather than retroactively passing what had failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
#[cfg(feature = "scenarios")]
pub use scenario::{parse_actor, run, CommandStep, RunOutcome, ScenarioFile, ScenarioGame, Setup};
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)]
);
}
}