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

115 lines
3.6 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.
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)]
);
}
}