diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..e4b4a11 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,18 @@ +name: ci +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: docker + container: + image: rust:1.97 + steps: + - uses: actions/checkout@v4 + - run: rustup component add rustfmt clippy + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace + - run: cargo run -q -p cb-sim -- scenarios/ground/*.yaml || test $? -eq 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9c3cdc9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,605 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cb-events" +version = "0.1.0" +dependencies = [ + "cb-kernel", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "cb-game-runtime" +version = "0.1.0" +dependencies = [ + "cb-events", + "cb-kernel", + "serde", + "serde_json", + "serde_yaml", +] + +[[package]] +name = "cb-kernel" +version = "0.1.0" +dependencies = [ + "rand_chacha", + "rand_core", + "serde", +] + +[[package]] +name = "cb-sim" +version = "0.1.0" +dependencies = [ + "cb-game-runtime", + "games-ground", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "games-ground" +version = "0.1.0" +dependencies = [ + "cb-events", + "cb-game-runtime", + "cb-kernel", + "criterion", + "serde", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..38d8031 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[workspace] +resolver = "2" +members = [ + "crates/cb-kernel", + "crates/cb-events", + "crates/cb-game-runtime", + "games/ground", + "tools/cb-sim", +] + +[workspace.package] +edition = "2021" +version = "0.1.0" +license-file = "LICENSE" + +[workspace.dependencies] +cb-kernel = { path = "crates/cb-kernel" } +cb-events = { path = "crates/cb-events" } +cb-game-runtime = { path = "crates/cb-game-runtime" } +games-ground = { path = "games/ground" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +rand_core = "0.6" +rand_chacha = "0.3" +criterion = { version = "0.5", default-features = false } + +# Determinism discipline (GameKernel K6): HashMap/HashSet are deny-linted +# workspace-wide; semantic state must use ordered collections. +[workspace.lints.clippy] +disallowed_types = "deny" + +[profile.release] +lto = "thin" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1cf6521 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +# One command surface (InnerLoop §agentic-efficiency #3). Deterministic, +# greppable output; precursor of the `cb` CLI. + +CARGO := cargo + +.PHONY: check test sim bench deps loc all + +## fmt + clippy (deny warnings) + HashMap deny-lint +check: + $(CARGO) fmt --all --check + $(CARGO) clippy --workspace --all-targets -- -D warnings + +## unit + scenario-format tests +test: + $(CARGO) test --workspace + +## run all GROUND scenarios through cb-sim +sim: + $(CARGO) run -q -p cb-sim -- scenarios/ground/*.yaml + +## Criterion benches (AM-6/AM-7 wiring) +bench: + $(CARGO) bench -p games-ground + +## AM-4: transitive crate count (excludes dev/build deps) +deps: + @$(CARGO) tree --workspace -e normal --prefix none | sed 's/ (\*)//' | sort -u | grep -vE '^(cb-|games-|cb_|$$)' | tee /dev/stderr | wc -l + +## AM-2/AM-3 input: source LOC per crate (excludes tests would need tokei) +loc: + @for d in crates/cb-kernel crates/cb-events crates/cb-game-runtime games/ground tools/cb-sim; do \ + printf '%-28s %s\n' $$d "$$(find $$d/src -name '*.rs' | xargs cat | grep -vcE '^\s*(//|$$)')"; \ + done + +all: check test sim diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..33be32b --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +disallowed-types = [ + { path = "std::collections::HashMap", reason = "GameKernel K6: iteration order nondeterminism — use BTreeMap" }, + { path = "std::collections::HashSet", reason = "GameKernel K6: iteration order nondeterminism — use BTreeSet" }, +] diff --git a/crates/cb-events/Cargo.toml b/crates/cb-events/Cargo.toml new file mode 100644 index 0000000..836202a --- /dev/null +++ b/crates/cb-events/Cargo.toml @@ -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 diff --git a/crates/cb-events/src/lib.rs b/crates/cb-events/src/lib.rs new file mode 100644 index 0000000..e3c438e --- /dev/null +++ b/crates/cb-events/src/lib.rs @@ -0,0 +1,168 @@ +//! cb-events — event envelope, append-only log, snapshots, canonical +//! serialization, and state hashing (GameKernel §2.4, K4, K7, K9–K11). + +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 { + 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 { + events: Vec>, +} + +impl EventLog { + 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) -> 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> { + 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(value: &T) -> Vec { + 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(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(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, +} + +impl Snapshot { + pub fn take(state: &T, through: EventSeq) -> Self { + Self { + through, + state: canonical_bytes(state), + } + } + + pub fn restore(&self) -> Result { + 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 = 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 = snap.restore().unwrap(); + assert_eq!(state, restored); + assert_eq!(state_hash_hex(&state), state_hash_hex(&restored)); + } +} diff --git a/crates/cb-game-runtime/Cargo.toml b/crates/cb-game-runtime/Cargo.toml new file mode 100644 index 0000000..7f596fa --- /dev/null +++ b/crates/cb-game-runtime/Cargo.toml @@ -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 diff --git a/crates/cb-game-runtime/src/lib.rs b/crates/cb-game-runtime/src/lib.rs new file mode 100644 index 0000000..6484c9c --- /dev/null +++ b/crates/cb-game-runtime/src/lib.rs @@ -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 { + /// 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)] + ); + } +} diff --git a/crates/cb-game-runtime/src/scenario.rs b/crates/cb-game-runtime/src/scenario.rs new file mode 100644 index 0000000..5d45cf5 --- /dev/null +++ b/crates/cb-game-runtime/src/scenario.rs @@ -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//.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, + /// 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, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CommandStep { + pub actor: String, + pub cmd: String, + #[serde(default)] + pub args: BTreeMap, +} + +#[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>, + /// Partial end-state assertions: dot-path → expected value. + #[serde(default)] + pub state: BTreeMap, + /// Indices into `commands` that must be rejected. + #[serde(default)] + pub rejects: Vec, + /// Optional full-state golden hash. + #[serde(default)] + pub state_hash: Option, +} + +impl ScenarioFile { + pub fn from_yaml(yaml: &str) -> Result { + 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, + }, + 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()); + } +} diff --git a/crates/cb-kernel/Cargo.toml b/crates/cb-kernel/Cargo.toml new file mode 100644 index 0000000..72b535d --- /dev/null +++ b/crates/cb-kernel/Cargo.toml @@ -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 diff --git a/crates/cb-kernel/src/ids.rs b/crates/cb-kernel/src/ids.rs new file mode 100644 index 0000000..6397123 --- /dev/null +++ b/crates/cb-kernel/src/ids.rs @@ -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) +); diff --git a/crates/cb-kernel/src/lib.rs b/crates/cb-kernel/src/lib.rs new file mode 100644 index 0000000..24f2400 --- /dev/null +++ b/crates/cb-kernel/src/lib.rs @@ -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}; diff --git a/crates/cb-kernel/src/pipeline.rs b/crates/cb-kernel/src/pipeline.rs new file mode 100644 index 0000000..57dbbe4 --- /dev/null +++ b/crates/cb-kernel/src/pipeline.rs @@ -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, Rejection>; + + /// Apply one event. Infallible: every logged event must apply (K1). + fn fold(&mut self, event: &Self::Event); +} diff --git a/crates/cb-kernel/src/rng.rs b/crates/cb-kernel/src/rng.rs new file mode 100644 index 0000000..21e83b7 --- /dev/null +++ b/crates/cb-kernel/src/rng.rs @@ -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(&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 = (0..64).map(|_| a.draw(1000)).collect(); + let draws_b: Vec = (0..64).map(|_| b.draw(1000)).collect(); + let draws_c: Vec = (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); + } +} diff --git a/games/ground/Cargo.toml b/games/ground/Cargo.toml new file mode 100644 index 0000000..c4297b0 --- /dev/null +++ b/games/ground/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "games-ground" +edition.workspace = true +version.workspace = true +license-file.workspace = true + +[dependencies] +cb-kernel.workspace = true +cb-events.workspace = true +cb-game-runtime.workspace = true +serde.workspace = true + +[dev-dependencies] +criterion.workspace = true + +[[bench]] +name = "synthetic" +harness = false + +[lints] +workspace = true diff --git a/games/ground/benches/synthetic.rs b/games/ground/benches/synthetic.rs new file mode 100644 index 0000000..b1700b6 --- /dev/null +++ b/games/ground/benches/synthetic.rs @@ -0,0 +1,49 @@ +//! Criterion skeleton for AM-6/AM-7 (GameKernel §4), wired to the +//! CB-RES-0001 baseline shape (3-player commit/reveal synthetic workload). +//! T08 replaces the placeholder body with the real aggregate loop; the +//! bench IDs and workload sizes are fixed here so results stay comparable +//! to benchmarks/baselines/ recordings. + +use cb_events::state_hash; +use cb_game_runtime::CommitWindow; +use cb_kernel::PlayerId; +use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; + +/// Placeholder workload: open/submit/reveal one 3-player commit window and +/// hash a small value. Exists so the bench harness, IDs, and baseline +/// wiring compile and run before the aggregate exists. +fn commit_reveal_round() -> usize { + let mut w: CommitWindow = CommitWindow::open([PlayerId(0), PlayerId(1), PlayerId(2)]); + for p in 0..3u8 { + w.submit(PlayerId(p), p).unwrap(); + } + let revealed = w.reveal().unwrap(); + state_hash(&revealed.len()).len() +} + +fn bench_synthetic(c: &mut Criterion) { + let mut group = c.benchmark_group("synthetic-ground-3p"); + // AM-6 headline workload sizes; AM-7 compares 5k vs 100k throughput. + for &rounds in &[5_000usize, 100_000] { + group.bench_function(format!("commit-reveal-{rounds}"), |b| { + b.iter_batched( + || rounds, + |n| { + let mut acc = 0usize; + for _ in 0..n / 1000 { + // Scaffold runs 1/1000th scale until T08 wires the + // real aggregate; the group/ID layout is what T07 + // delivers. + acc += commit_reveal_round(); + } + acc + }, + BatchSize::SmallInput, + ) + }); + } + group.finish(); +} + +criterion_group!(benches, bench_synthetic); +criterion_main!(benches); diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs new file mode 100644 index 0000000..00d211e --- /dev/null +++ b/games/ground/src/lib.rs @@ -0,0 +1,119 @@ +//! games-ground — the GROUND rules aggregate (specs/GroundRules.md, +//! GameKernel K15–K16). T07 scaffolds the state shell; validate/fold per +//! GR-rule land in T08 with rule IDs cross-referenced in doc comments. + +use cb_kernel::PlayerId; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// GR-O02: per-player state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlayerState { + /// GR-F01: clamped 0–5. + pub stress: u8, + /// GR-F03. + pub freedom_ready: bool, + /// GR-D01/D02: OFF or the pending/active stage. + pub darvo: DarvoStage, + pub hand: Vec, + pub protection: u8, + /// Blame tokens in front of this player (GR-T02), keyed by owner. + pub blame_from: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DarvoStage { + Off, + Deny, + Attack, + Reverse, +} + +/// GR-O04: one Problem card's live state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProblemState { + pub suit: Suit, + pub value: u8, + pub face_up: bool, + pub denied: bool, + pub claimed_by: Option, + /// GR-A11: protected from Deny this round by GROUND—OU. + pub protected_this_round: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Suit { + Clarify, + Repair, + Boundary, + Change, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SolutionCard { + pub suit: Suit, +} + +/// GR-O05: at most one relation per pair; endpoints ordered low→high. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Relation { + Bond, + Rivalry, +} + +/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered +/// collections only (GameKernel K6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroundState { + pub round: u8, + pub lead: PlayerId, + pub players: BTreeMap, + /// Keyed by (low, high) player pair. + pub relations: BTreeMap<(PlayerId, PlayerId), Relation>, + pub problems: BTreeMap, + pub solution_deck: Vec, + pub solution_discard: Vec, + /// Focus placements: sequence owner → target (GR-T03). + pub focus: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use cb_events::state_hash_hex; + + fn tiny_state() -> GroundState { + GroundState { + round: 1, + lead: PlayerId(0), + players: BTreeMap::from([( + PlayerId(0), + PlayerState { + stress: 2, + freedom_ready: true, + darvo: DarvoStage::Off, + hand: vec![SolutionCard { suit: Suit::Repair }], + protection: 0, + blame_from: vec![], + }, + )]), + relations: BTreeMap::new(), + problems: BTreeMap::new(), + solution_deck: vec![], + solution_discard: vec![], + focus: BTreeMap::new(), + } + } + + /// K7 on the real aggregate: hash stable across clones, sensitive to + /// semantic change. + #[test] + fn ground_state_hashes_canonically() { + let a = tiny_state(); + let b = a.clone(); + assert_eq!(state_hash_hex(&a), state_hash_hex(&b)); + let mut c = a.clone(); + c.players.get_mut(&PlayerId(0)).unwrap().stress = 5; + assert_ne!(state_hash_hex(&a), state_hash_hex(&c)); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8565ca6 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] diff --git a/scenarios/ground/smoke-setup.yaml b/scenarios/ground/smoke-setup.yaml new file mode 100644 index 0000000..c665ac3 --- /dev/null +++ b/scenarios/ground/smoke-setup.yaml @@ -0,0 +1,17 @@ +scenario: ground/smoke-setup +description: > + Smoke scenario for the T07 scaffold: exercises the documented file format + end to end. Assertions are the GR-S setup facts; execution lands in T08. +covers: [GR-S02, GR-S03, GR-O01] +provisional: false +seed: 42 +setup: + players: 3 + preset: standard-3p +commands: [] +expect: + state: + "round": 1 + "players.0.stress": 2 + "players.0.freedom_ready": true + rejects: [] diff --git a/tools/cb-sim/Cargo.toml b/tools/cb-sim/Cargo.toml new file mode 100644 index 0000000..96694dd --- /dev/null +++ b/tools/cb-sim/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cb-sim" +edition.workspace = true +version.workspace = true +license-file.workspace = true + +[dependencies] +cb-game-runtime.workspace = true +games-ground.workspace = true + +[lints] +workspace = true diff --git a/tools/cb-sim/src/main.rs b/tools/cb-sim/src/main.rs new file mode 100644 index 0000000..d881eca --- /dev/null +++ b/tools/cb-sim/src/main.rs @@ -0,0 +1,59 @@ +//! cb-sim — scenario runner binary (GameKernel K17; precursor of `cb sim`). +//! T07 scope: parse and validate scenario files, report coverage tags. +//! T08 wires execution. Exit codes: 0 all passed, 1 failures, 2 not yet +//! executable (parse-only), 64 usage error. + +use cb_game_runtime::{scenario, RunOutcome, ScenarioFile}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + eprintln!("usage: cb-sim ..."); + std::process::exit(64); + } + + let mut unimplemented = false; + let mut failed = false; + + for path in &args { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + eprintln!("{path}: read error: {e}"); + failed = true; + continue; + } + }; + match ScenarioFile::from_yaml(&text) { + Err(e) => { + eprintln!("{path}: parse error: {e}"); + failed = true; + } + Ok(sc) => match scenario::run(&sc) { + RunOutcome::Passed { covers } => { + println!("PASS {} covers={}", sc.scenario, covers.join(",")); + } + RunOutcome::Failed { reason } => { + println!("FAIL {} — {reason}", sc.scenario); + failed = true; + } + RunOutcome::Unimplemented => { + println!( + "PARSED {} covers={}{} (runner not yet implemented — T08)", + sc.scenario, + sc.covers.join(","), + if sc.provisional { " provisional" } else { "" } + ); + unimplemented = true; + } + }, + } + } + + if failed { + std::process::exit(1); + } + if unimplemented { + std::process::exit(2); + } +}