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
Some checks failed
ci / check (push) Has been cancelled
This commit is contained in:
parent
396990539a
commit
467e2c561d
23 changed files with 1625 additions and 0 deletions
45
crates/cb-kernel/src/ids.rs
Normal file
45
crates/cb-kernel/src/ids.rs
Normal 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)
|
||||
);
|
||||
10
crates/cb-kernel/src/lib.rs
Normal file
10
crates/cb-kernel/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//! cb-kernel — canonical identifiers, seeds, RNG service, and the
|
||||
//! command/event/fold contracts (GameKernel §2.1–2.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};
|
||||
54
crates/cb-kernel/src/pipeline.rs
Normal file
54
crates/cb-kernel/src/pipeline.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! The mutation pipeline contracts (GameKernel §2.2, K1–K3):
|
||||
//! 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);
|
||||
}
|
||||
83
crates/cb-kernel/src/rng.rs
Normal file
83
crates/cb-kernel/src/rng.rs
Normal 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 Fisher–Yates 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];
|
||||
// Fisher–Yates 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue