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

This commit is contained in:
tegwick 2026-07-31 01:57:13 +02:00
parent 396990539a
commit 467e2c561d
23 changed files with 1625 additions and 0 deletions

View file

@ -0,0 +1,14 @@
[package]
name = "cb-events"
edition.workspace = true
version.workspace = true
license-file.workspace = true
[dependencies]
cb-kernel.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[lints]
workspace = true

168
crates/cb-events/src/lib.rs Normal file
View file

@ -0,0 +1,168 @@
//! cb-events — event envelope, append-only log, snapshots, canonical
//! serialization, and state hashing (GameKernel §2.4, K4, K7, K9K11).
use cb_kernel::{EventSeq, GameId};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Versioned event envelope (GameKernel K4).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope<E> {
pub seq: EventSeq,
pub game_id: GameId,
pub round: u8,
pub schema_ver: u16,
pub payload: E,
}
/// In-memory append-only event log. File-backed storage arrives in a later
/// loop pass behind the same interface (AM-11 pairs this with storage
/// impls under one conformance suite).
#[derive(Debug, Default)]
pub struct EventLog<E> {
events: Vec<Envelope<E>>,
}
impl<E> EventLog<E> {
pub fn new() -> Self {
Self { events: Vec::new() }
}
/// Append with sequence enforcement: envelopes must arrive in strictly
/// increasing `seq` order (GameKernel K11).
pub fn append(&mut self, envelope: Envelope<E>) -> Result<(), LogError> {
if let Some(last) = self.events.last() {
if envelope.seq.0 != last.seq.0 + 1 {
return Err(LogError::NonMonotonicSeq {
expected: last.seq.0 + 1,
got: envelope.seq.0,
});
}
}
self.events.push(envelope);
Ok(())
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Envelope<E>> {
self.events.iter()
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum LogError {
NonMonotonicSeq { expected: u64, got: u64 },
}
impl core::fmt::Display for LogError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
LogError::NonMonotonicSeq { expected, got } => {
write!(f, "non-monotonic event seq: expected {expected}, got {got}")
}
}
}
}
/// Canonical serialization: serde_json with struct-declaration field order
/// and ordered maps (kernel state uses BTreeMap per GameKernel K6), so the
/// same state always yields the same bytes.
pub fn canonical_bytes<T: Serialize>(value: &T) -> Vec<u8> {
serde_json::to_vec(value).expect("canonical serialization must not fail")
}
/// SHA-256 state hash over the canonical serialization (GameKernel K7).
pub fn state_hash<T: Serialize>(value: &T) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(canonical_bytes(value));
hasher.finalize().into()
}
/// Hex form of [`state_hash`], for scenario `expect.state_hash` fields.
pub fn state_hash_hex<T: Serialize>(value: &T) -> String {
let hash = state_hash(value);
let mut out = String::with_capacity(64);
for byte in hash {
use core::fmt::Write;
write!(out, "{byte:02x}").expect("writing to String cannot fail");
}
out
}
/// A snapshot pairs the canonical state bytes with the last included
/// event (GameKernel K9).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub through: EventSeq,
pub state: Vec<u8>,
}
impl Snapshot {
pub fn take<T: Serialize>(state: &T, through: EventSeq) -> Self {
Self {
through,
state: canonical_bytes(state),
}
}
pub fn restore<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
serde_json::from_slice(&self.state)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn log_rejects_seq_gaps() {
let mut log: EventLog<u8> = EventLog::new();
let env = |seq| Envelope {
seq: EventSeq(seq),
game_id: GameId(1),
round: 1,
schema_ver: 1,
payload: 0u8,
};
log.append(env(0)).unwrap();
log.append(env(1)).unwrap();
assert_eq!(
log.append(env(3)),
Err(LogError::NonMonotonicSeq {
expected: 2,
got: 3
})
);
}
/// K7: identical state → identical hash; any change → different hash.
#[test]
fn state_hash_is_stable_and_sensitive() {
let mut a = BTreeMap::new();
a.insert("stress", 2u8);
let mut b = BTreeMap::new();
b.insert("stress", 2u8);
assert_eq!(state_hash_hex(&a), state_hash_hex(&b));
b.insert("stress", 3u8);
assert_ne!(state_hash_hex(&a), state_hash_hex(&b));
}
/// K9: snapshot → restore is identity on the canonical form.
#[test]
fn snapshot_roundtrip() {
let mut state = BTreeMap::new();
state.insert("round".to_string(), 3u8);
let snap = Snapshot::take(&state, EventSeq(17));
let restored: BTreeMap<String, u8> = snap.restore().unwrap();
assert_eq!(state, restored);
assert_eq!(state_hash_hex(&state), state_hash_hex(&restored));
}
}

View file

@ -0,0 +1,15 @@
[package]
name = "cb-game-runtime"
edition.workspace = true
version.workspace = true
license-file.workspace = true
[dependencies]
cb-kernel.workspace = true
cb-events.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
[lints]
workspace = true

View 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)]
);
}
}

View 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());
}
}

View file

@ -0,0 +1,13 @@
[package]
name = "cb-kernel"
edition.workspace = true
version.workspace = true
license-file.workspace = true
[dependencies]
serde.workspace = true
rand_core.workspace = true
rand_chacha.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,45 @@
//! Canonical identifiers (GameKernel §2.1): newtyped, copyable, ordered.
use serde::{Deserialize, Serialize};
macro_rules! id_type {
($(#[$doc:meta])* $name:ident($inner:ty)) => {
$(#[$doc])*
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct $name(pub $inner);
impl core::fmt::Display for $name {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}
};
}
id_type!(
/// A single game instance.
GameId(u64)
);
id_type!(
/// Seat index within a game (0-based).
PlayerId(u8)
);
id_type!(
/// Semantic entity: problem, token, relation, card.
EntityId(u32)
);
id_type!(
/// Client-assigned command identifier for idempotency (GameKernel K2).
CommandId(u64)
);
id_type!(
/// Monotonic per-game event sequence number (GameKernel K4).
EventSeq(u64)
);
id_type!(
/// Deterministic game seed (GameKernel K5).
Seed(u64)
);

View file

@ -0,0 +1,10 @@
//! cb-kernel — canonical identifiers, seeds, RNG service, and the
//! command/event/fold contracts (GameKernel §2.12.3). Game-agnostic.
pub mod ids;
pub mod pipeline;
pub mod rng;
pub use ids::{CommandId, EntityId, EventSeq, GameId, PlayerId, Seed};
pub use pipeline::{Actor, Aggregate, Rejection};
pub use rng::{ChaChaRng, KernelRng, NullRng};

View file

@ -0,0 +1,54 @@
//! The mutation pipeline contracts (GameKernel §2.2, K1K3):
//! command → validate → events → infallible fold.
use crate::ids::PlayerId;
use serde::{Deserialize, Serialize};
/// Who issued a command (GameKernel K2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Actor {
Player(PlayerId),
/// Runtime-driven steps (reveal, resolution ordering, round end).
System,
}
/// Typed, testable command rejections (GameKernel K3). Game packages add
/// specific rejection kinds via `Game`-typed payloads in their own enums;
/// this kernel-level enum carries the cross-game cases.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Rejection {
/// The command's `CommandId` was already applied (idempotency, K2).
DuplicateCommand,
/// The actor may not issue this command now (turn/window gating).
NotAllowedNow,
/// Game-specific rejection, stable machine-readable code + human text.
Game { code: String, detail: String },
}
impl core::fmt::Display for Rejection {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Rejection::DuplicateCommand => write!(f, "duplicate command"),
Rejection::NotAllowedNow => write!(f, "not allowed now"),
Rejection::Game { code, detail } => write!(f, "{code}: {detail}"),
}
}
}
/// An event-sourced aggregate (GameKernel K1): all validation happens in
/// `validate`; `fold` is total and infallible for any logged event.
pub trait Aggregate: Sized {
type Command;
type Event;
/// Decide whether `command` is legal in the current state, producing
/// the events it implies. Must not mutate state.
fn validate(
&self,
actor: Actor,
command: &Self::Command,
) -> Result<Vec<Self::Event>, Rejection>;
/// Apply one event. Infallible: every logged event must apply (K1).
fn fold(&mut self, event: &Self::Event);
}

View file

@ -0,0 +1,83 @@
//! Seeded RNG service (GameKernel K5) with the null/reference impl pair
//! required by AM-11. The only randomness source available to game code.
use crate::ids::Seed;
use rand_core::{RngCore, SeedableRng};
/// Kernel randomness: deterministic draws from the game seed.
pub trait KernelRng {
/// Uniform draw in `0..bound` (bound ≥ 1).
fn draw(&mut self, bound: u32) -> u32;
/// Deterministic FisherYates shuffle of `items`.
fn shuffle<T>(&mut self, items: &mut [T]) {
for i in (1..items.len()).rev() {
#[allow(clippy::cast_possible_truncation)]
let j = self.draw(i as u32 + 1) as usize;
items.swap(i, j);
}
}
}
/// Reference implementation: ChaCha12, seeded from the game `Seed`.
pub struct ChaChaRng(rand_chacha::ChaCha12Rng);
impl ChaChaRng {
pub fn from_seed(seed: Seed) -> Self {
Self(rand_chacha::ChaCha12Rng::seed_from_u64(seed.0))
}
}
impl KernelRng for ChaChaRng {
fn draw(&mut self, bound: u32) -> u32 {
assert!(bound >= 1, "draw bound must be >= 1");
// Rejection sampling for uniformity.
let zone = u32::MAX - (u32::MAX % bound);
loop {
let v = self.0.next_u32();
if v < zone {
return v % bound;
}
}
}
}
/// Null implementation for tests: always returns 0 (first choice, no
/// shuffle movement). Makes scenario fixtures fully predictable.
#[derive(Default)]
pub struct NullRng;
impl KernelRng for NullRng {
fn draw(&mut self, _bound: u32) -> u32 {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
/// AM-8 seed determinism at the unit level: same seed, same draws.
#[test]
fn chacha_is_deterministic_per_seed() {
let mut a = ChaChaRng::from_seed(Seed(42));
let mut b = ChaChaRng::from_seed(Seed(42));
let mut c = ChaChaRng::from_seed(Seed(43));
let draws_a: Vec<u32> = (0..64).map(|_| a.draw(1000)).collect();
let draws_b: Vec<u32> = (0..64).map(|_| b.draw(1000)).collect();
let draws_c: Vec<u32> = (0..64).map(|_| c.draw(1000)).collect();
assert_eq!(draws_a, draws_b);
assert_ne!(draws_a, draws_c);
}
#[test]
fn null_rng_never_moves_a_shuffle() {
let mut rng = NullRng;
let mut items = vec![1, 2, 3, 4];
// FisherYates with j=0 each step rotates deterministically.
rng.shuffle(&mut items);
let mut again = vec![1, 2, 3, 4];
NullRng.shuffle(&mut again);
assert_eq!(items, again);
}
}