Some checks failed
ci / check (push) Failing after 3s
Two passes have now had the opportunity to give this type a second consumer and declined: CB-WP-0008's bots and CLI drive GROUND's inline commit/reveal, and this 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 and extending a date because it is inconvenient are the same error in opposite directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
5.2 KiB
Rust
147 lines
5.2 KiB
Rust
//! 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<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)]
|
|
);
|
|
}
|
|
}
|