//! cb-game-runtime — round/phase machinery, commit windows, projections, //! and the scenario runner (GameKernel §2.5, §3). Game-agnostic. #[cfg(feature = "scenarios")] pub mod scenario; /// K10 replay bundles. Dev-only: a shipped game runtime writes no bundles. #[cfg(feature = "scenarios")] pub mod replay; #[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. /// /// **PROVISIONAL — zero non-test users as of 2026-08-01 (K14, GameKernel /// §2.5a).** GROUND implements the same contract inline in its own /// aggregate. This type is the *extracted* form, kept because it /// documents the seam stage 3 and stage 4 will need — but INTENT says a /// concept becomes canonical only *"after surviving a second concrete /// use"*, and this has survived none. /// /// **Delete it if no second game uses it by 2026-12-31.** A primitive /// with one hypothetical user and a test that exercises only itself is /// the AM-11 shape, and this project has paid for that shape twice. /// /// **Second-use log** (CB-WP-0010 T03). Two passes have now had the /// opportunity and declined: /// /// * **CB-WP-0008** — bots and `cb-play` drive GROUND's inline /// commit/reveal. A second *consumer* of the aggregate did not become /// a consumer of this type (CB-EV-0007 §2). /// * **CB-WP-0010** — the consolidation pass reviewed it and changed /// nothing. /// /// **The date is not moved and the type is not deleted early.** The /// stated condition is a second *game*, and no second game has been /// attempted, so the test has not run — only the opportunity has passed. /// Deleting on a test that was never run, or extending the date because /// it is inconvenient, are the same error in opposite directions, and /// InnerLoop §Step 4 forbids the second by name. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitWindow { /// Players who must submit, and their commitment once received. pending: BTreeMap>, } impl CommitWindow { pub fn open(players: impl IntoIterator) -> 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, 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 = 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)] ); } }