diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b7675b..d580c1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ Other recipes in the [justfile](justfile): Most contributions need no engine knowledge. Every tuning number lives in `data/rules/*.ron` as an integer: time in deciseconds (fields ending in `_ds`) and distance in tiles (fields ending in `_tiles`). The loader converts to ticks once. If you find yourself typing a gameplay constant in Rust, stop and move it to RON. The schema is explained in [docs/DATA_FORMAT.md](docs/DATA_FORMAT.md). -Any edit to a RON file changes `rules_hash`, and every golden replay fixture records the hash it was made with, so without a re-record `cargo test -p sim` fails with `RULES CHANGED since recording`. The data-only path is therefore always the same, and touches no Rust: +Any edit to a RON file changes `rules_hash` (except the two presentation files: the UI strings under `data/rules/strings/` and, since `rules_version` 25, `data/visuals.ron`, [ADR 0003](docs/adr/0003-visuals-outside-rules-hash.md); `data-check` validates both, but a change to either needs no bump and no re-record), and every golden replay fixture records the hash it was made with, so without a re-record `cargo test -p sim` fails with `RULES CHANGED since recording`. The data-only path is therefore always the same, and touches no Rust: ```bash # 1. Edit a value, for example the Croft gather rate in data/rules/buildings.ron @@ -117,7 +117,7 @@ $ git status --short Only `economy_second_town` changes its final hash: it is the only fixture with a worked Croft. A test that hard-codes a number the data controls fails this walkthrough; read the value from `Rules` instead. -Since M4a's rules data (`rules_version` 13) the `OK` line also counts the ages, techs and factions, since M3b it names the checked UI strings, since M5a's rules data (`rules_version` 18) `techs` counts the techs that count toward an age and `side_techs` the Watchtower's Harrying I and II, and since M5b (`rules_version` 21) it counts the bot data under `data/ai/` (`personalities`, `build_orders`), which is in `rules_hash` too, so with the repository at `rules_version` 24 and the bump to 25 of step 2, step 3 now prints a line of the form `OK rules_version=25 rules_hash=0x<16 hex> resources=4 units=8 buildings=8 ages=3 techs=12 side_techs=2 factions=1 personalities=1 build_orders=1 maps=plains_1v1 strings=en` (before the bump it prints `rules_version=24`). An edit under `data/ai/` takes the same path: bump `rules_version`, `just rerecord`, run the tests. +Since M4a's rules data (`rules_version` 13) the `OK` line also counts the ages, techs and factions, since M3b it names the checked UI strings, since M5a's rules data (`rules_version` 18) `techs` counts the techs that count toward an age and `side_techs` the Watchtower's Harrying I and II, and since M5b (`rules_version` 21) it counts the bot data under `data/ai/` (`personalities`, `build_orders`), which is in `rules_hash` too, so with the repository at `rules_version` 25 and the bump to 26 of step 2, step 3 now prints a line of the form `OK rules_version=26 rules_hash=0x<16 hex> resources=4 units=8 buildings=8 ages=3 techs=12 side_techs=2 factions=1 personalities=1 build_orders=1 maps=plains_1v1 strings=en` (before the bump it prints `rules_version=25`). An edit under `data/ai/` takes the same path: bump `rules_version`, `just rerecord`, run the tests. ### A build order or personality (`data/ai/`) diff --git a/crates/rules/src/lib.rs b/crates/rules/src/lib.rs index c7ffc32..4f3b543 100644 --- a/crates/rules/src/lib.rs +++ b/crates/rules/src/lib.rs @@ -17,7 +17,7 @@ //! | `rules/ages.ron` | [`Ages`] | //! | `rules/factions.ron` | [`Factions`] | //! | `maps/*.ron` | [`map::MapDef`] | -//! | `visuals.ron` | [`Visuals`] (how each unit kind is drawn; validated here, read by the game) | +//! | `visuals.ron` | [`Visuals`] (how each unit kind is drawn; validated here, read by the game, not in [`Rules::rules_hash`]: ADR 0003) | //! | `ai/personalities.ron`, `ai/build_orders/*.ron` | [`ai::AiRules`] (the bot's knobs and build orders, M5b; validated here, read by `crates/ai`) | //! //! Every error names the file and, when the data parsed, the field. @@ -316,13 +316,17 @@ pub struct Rules { pub maps: BTreeMap, /// Contents of `visuals.ron`: a [`Visual`] per unit kind id. Attached by /// [`Rules::load`] and validated against `units`; read by the game crate's - /// presenter, never by the sim. - #[serde(skip_deserializing)] + /// presenter, never by the sim. `#[serde(skip)]`, not only + /// `skip_deserializing`, so it is the one attached file left out of + /// [`Rules::rules_hash`] (ADR 0003, `docs/adr/0003-visuals-outside-rules-hash.md`): + /// a look edit needs no `rules_version` bump and no re-record, and a + /// bad or missing entry still fails `Rules::load` and `data-check`. + #[serde(skip)] pub visuals: Visuals, /// Everything under `ai/` (M5b): the bot personalities and build /// orders. Attached by [`Rules::load`] and validated against the rest /// of the rules; read by `crates/ai`, never by the sim, and in - /// `rules_hash` like every attached file. + /// `rules_hash` like every attached file but `visuals`. #[serde(skip_deserializing)] pub ai: AiRules, } @@ -1708,9 +1712,11 @@ impl Rules { } /// Content hash of the loaded rules (match rules, resources, units and - /// their counter table, buildings, techs, ages, factions, maps and - /// visuals); stored in replay headers so a changed RON file is reported - /// as `RULES CHANGED` instead of a sim divergence. + /// their counter table, buildings, techs, ages, factions, maps and the + /// bot data under `ai/`); stored in replay headers so a changed RON file + /// is reported as `RULES CHANGED` instead of a sim divergence. + /// `visuals.ron` is loaded and validated but not hashed (ADR 0003: the + /// sim never reads it), like the UI strings, which are not in `Rules`. pub fn rules_hash(&self) -> u64 { let bytes = postcard::to_allocvec(self).expect("Rules serialises"); xxhash_rust::xxh3::xxh3_64(&bytes) @@ -2046,22 +2052,75 @@ mod tests { d.rules_hash(), "units.ron is part of the hash" ); + let mut f = Rules::load(data_dir()).unwrap(); + f.buildings[1].hp += 1; + assert_ne!( + a.rules_hash(), + f.rules_hash(), + "buildings.ron is part of the hash" + ); + } + + /// ADR 0003: `visuals.ron` is attached and validated by `load` but left + /// out of `rules_hash` (`#[serde(skip)]`), so a look edit needs no + /// `rules_version` bump and no re-record. Checked in memory and through + /// an edited copy of the file, which must load with the edit in place. + #[test] + fn rules_hash_ignores_visuals_ron() { + let a = Rules::load(data_dir()).unwrap(); let mut e = Rules::load(data_dir()).unwrap(); e.visuals.units.insert( "yeoman".into(), Visual::Primitive(visuals::Primitive::Sphere), ); - assert_ne!( + assert_ne!(a.visuals, e.visuals); + assert_eq!( a.rules_hash(), e.rules_hash(), - "visuals.ron is part of the hash" + "visuals.ron is not part of the hash" ); - let mut f = Rules::load(data_dir()).unwrap(); - f.buildings[1].hp += 1; - assert_ne!( + let mut cleared = Rules::load(data_dir()).unwrap(); + cleared.visuals = Visuals::default(); + assert_eq!(a.rules_hash(), cleared.rules_hash()); + + let edited = loads_after_edits( + "visuals-edit", + &[( + "visuals.ron", + r#""skirmisher": Body(shape: Cone, height_tiles_x100: 150)"#, + r#""skirmisher": Body(shape: Cone, height_tiles_x100: 160)"#, + )], + ); + assert_eq!( + edited.visuals.units.get("skirmisher"), + Some(&Visual::Body { + shape: visuals::Primitive::Cone, + height_tiles_x100: 160, + lying: false, + }), + "the edited file is loaded" + ); + assert_eq!( a.rules_hash(), - f.rules_hash(), - "buildings.ron is part of the hash" + edited.rules_hash(), + "a visuals.ron edit keeps rules_hash" + ); + } + + /// The other half of ADR 0003: leaving the hash does not mean leaving + /// validation. A bad entry still fails `load`, naming the file and field. + #[test] + fn bad_visual_still_fails_to_load_naming_file_and_field() { + let msg = load_error_after_edit( + "visual-bad-height", + "visuals.ron", + r#""skirmisher": Body(shape: Cone, height_tiles_x100: 150)"#, + r#""skirmisher": Body(shape: Cone, height_tiles_x100: 500)"#, + ); + assert!(msg.contains("visuals.ron"), "{msg}"); + assert!( + msg.contains(r#"units["skirmisher"].height_tiles_x100"#), + "{msg}" ); } diff --git a/crates/rules/src/visuals.rs b/crates/rules/src/visuals.rs index a088dc8..1dd07c9 100644 --- a/crates/rules/src/visuals.rs +++ b/crates/rules/src/visuals.rs @@ -2,8 +2,12 @@ //! //! The simulation never reads this file; it is loaded by `Rules::load` so //! that it is validated with everything else (every unit kind must have a -//! visual) and so that it is part of `rules_hash`. The game crate maps a -//! [`Visual`] to a Bevy mesh and material in `present.rs`. +//! visual, so `sim-cli data-check` fails on a bad or missing entry). It is +//! not part of `rules_hash` (ADR 0003, +//! `docs/adr/0003-visuals-outside-rules-hash.md`; `Rules::visuals` is +//! `#[serde(skip)]`): changing a look needs no `rules_version` bump and no +//! re-record, and replays never record which looks were loaded. The game +//! crate maps a [`Visual`] to a Bevy mesh and material in `present.rs`. //! //! M2 shipped primitives only; M4b adds [`Visual::Body`], one primitive //! with its own height (or length, lying along the unit's facing), so each diff --git a/crates/sim-cli/tests/data_check.rs b/crates/sim-cli/tests/data_check.rs index 91bd350..01e7814 100644 --- a/crates/sim-cli/tests/data_check.rs +++ b/crates/sim-cli/tests/data_check.rs @@ -219,3 +219,77 @@ fn a_cadence_inside_the_command_delay_exits_1_naming_personalities_ron() { &["personalities[0].think_every_ds: ", "cmd_delay_ticks"], ); } + +/// ADR 0003: `visuals.ron` is out of `rules_hash` but still validated. A +/// unit kind without a visual fails data-check naming the file and kind. +#[test] +fn a_unit_kind_without_a_visual_exits_1_naming_visuals_ron() { + rejects_edit( + "visual-missing", + "visuals.ron", + " \"yeoman\": Primitive(Capsule),\n", + "", + &[r#"units["yeoman"]: "#, "needs a visual"], + ); +} + +/// ADR 0003: a look out of range still fails data-check, naming the field. +#[test] +fn a_visual_out_of_range_exits_1_naming_visuals_ron() { + rejects_edit( + "visual-height", + "visuals.ron", + r#""skirmisher": Body(shape: Cone, height_tiles_x100: 150)"#, + r#""skirmisher": Body(shape: Cone, height_tiles_x100: 500)"#, + &[r#"units["skirmisher"].height_tiles_x100: "#, "20..=400"], + ); +} + +/// ADR 0003: a missing `visuals.ron` still fails data-check, naming it. +#[test] +fn a_missing_visuals_ron_exits_1_naming_it() { + let dir = edited_copy("visuals-gone", "visuals.ron", |text| text); + std::fs::remove_file(dir.join("visuals.ron")).unwrap(); + let out = data_check(&dir); + let stderr = text(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{}{stderr}", text(&out.stdout)); + assert!( + stderr.starts_with("error: ") + && stderr.lines().count() == 1 + && stderr.contains("visuals.ron"), + "{stderr}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// ADR 0003's point: a valid edit of `visuals.ron` passes data-check with +/// the same `OK` line as the repository data, `rules_hash` included, so it +/// needs no `rules_version` bump and no re-record. +#[test] +fn a_valid_visuals_edit_keeps_the_rules_hash() { + let repo = data_check(&repo_data()); + let from = r#""skirmisher": Body(shape: Cone, height_tiles_x100: 150)"#; + let dir = edited_copy("visuals-edit", "visuals.ron", |text| { + assert!( + text.contains(from), + "visuals.ron no longer contains {from:?}" + ); + text.replacen( + from, + r#""skirmisher": Body(shape: Cone, height_tiles_x100: 160)"#, + 1, + ) + }); + let edited = data_check(&dir); + let (repo_out, edited_out) = (text(&repo.stdout), text(&edited.stdout)); + assert_eq!(repo.status.code(), Some(0), "{repo_out}"); + assert_eq!( + edited.status.code(), + Some(0), + "{edited_out}{}", + text(&edited.stderr) + ); + assert!(edited_out.contains(" rules_hash=0x"), "{edited_out}"); + assert_eq!(repo_out, edited_out); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/sim/tests/fixtures/README.md b/crates/sim/tests/fixtures/README.md index 5fe0fa1..f04eaf0 100644 --- a/crates/sim/tests/fixtures/README.md +++ b/crates/sim/tests/fixtures/README.md @@ -29,6 +29,18 @@ The `snapshot_restore` recording snapshots the sim before tick 300, restores the bytes into a fresh sim and continues recording on that sim; `verify` re-simulates without the swap, so an `OK` proves the restore was exact. +Recorded 2026-10-26 at `rules_version` 25, `SIM_VERSION` 17 with +`just rerecord` (ADR 0003, `docs/adr/0003-visuals-outside-rules-hash.md`: +`data/visuals.ron` left `rules_hash`; no value and no sim code changed). +Each of the eleven files differs from its `rules_version` 24 recording in +the header's `rules_version` and `rules_hash` only (10 bytes, offsets 8 to +17; every other byte is equal), and no `.hash` file changed. A per-tick +`sim-cli hash-dump --every 1` of `move_500`, `combat_skirmish`, +`capture_seat` and `m5_first_match` as recorded at 24, run by the +pre-bump binary with its own `data/`, equals that of the re-recorded +fixture run by the new binary on every tick. From here an edit of +`data/visuals.ron` alone needs no bump and no re-record. + Recorded 2026-10-23 at `rules_version` 24, `SIM_VERSION` 17 with `just rerecord` (the M5b waves slice's review fixes: `data/ai/` gained the `defend_guard_pop_ratio` knob, and the Standard bot's defence, build-order diff --git a/crates/sim/tests/fixtures/capture_seat.eonreplay b/crates/sim/tests/fixtures/capture_seat.eonreplay index 9c3dd71..fb9387c 100644 Binary files a/crates/sim/tests/fixtures/capture_seat.eonreplay and b/crates/sim/tests/fixtures/capture_seat.eonreplay differ diff --git a/crates/sim/tests/fixtures/combat_skirmish.eonreplay b/crates/sim/tests/fixtures/combat_skirmish.eonreplay index 2168bf6..3bf9445 100644 Binary files a/crates/sim/tests/fixtures/combat_skirmish.eonreplay and b/crates/sim/tests/fixtures/combat_skirmish.eonreplay differ diff --git a/crates/sim/tests/fixtures/economy_second_town.eonreplay b/crates/sim/tests/fixtures/economy_second_town.eonreplay index 9385489..d7c781b 100644 Binary files a/crates/sim/tests/fixtures/economy_second_town.eonreplay and b/crates/sim/tests/fixtures/economy_second_town.eonreplay differ diff --git a/crates/sim/tests/fixtures/economy_walkthrough.eonreplay b/crates/sim/tests/fixtures/economy_walkthrough.eonreplay index cdb37d2..28211d2 100644 Binary files a/crates/sim/tests/fixtures/economy_walkthrough.eonreplay and b/crates/sim/tests/fixtures/economy_walkthrough.eonreplay differ diff --git a/crates/sim/tests/fixtures/enemy_outpost_walkthrough.eonreplay b/crates/sim/tests/fixtures/enemy_outpost_walkthrough.eonreplay index 0f38be3..25824c4 100644 Binary files a/crates/sim/tests/fixtures/enemy_outpost_walkthrough.eonreplay and b/crates/sim/tests/fixtures/enemy_outpost_walkthrough.eonreplay differ diff --git a/crates/sim/tests/fixtures/group_spiral.eonreplay b/crates/sim/tests/fixtures/group_spiral.eonreplay index 3f17b37..32c03ac 100644 Binary files a/crates/sim/tests/fixtures/group_spiral.eonreplay and b/crates/sim/tests/fixtures/group_spiral.eonreplay differ diff --git a/crates/sim/tests/fixtures/m5_first_match.eonreplay b/crates/sim/tests/fixtures/m5_first_match.eonreplay index ea6b714..befab68 100644 Binary files a/crates/sim/tests/fixtures/m5_first_match.eonreplay and b/crates/sim/tests/fixtures/m5_first_match.eonreplay differ diff --git a/crates/sim/tests/fixtures/move_500.eonreplay b/crates/sim/tests/fixtures/move_500.eonreplay index a00c356..93aa435 100644 Binary files a/crates/sim/tests/fixtures/move_500.eonreplay and b/crates/sim/tests/fixtures/move_500.eonreplay differ diff --git a/crates/sim/tests/fixtures/move_500_short.eonreplay b/crates/sim/tests/fixtures/move_500_short.eonreplay index 7f6de09..78a382c 100644 Binary files a/crates/sim/tests/fixtures/move_500_short.eonreplay and b/crates/sim/tests/fixtures/move_500_short.eonreplay differ diff --git a/crates/sim/tests/fixtures/snapshot_restore.eonreplay b/crates/sim/tests/fixtures/snapshot_restore.eonreplay index 33b45a1..5b65198 100644 Binary files a/crates/sim/tests/fixtures/snapshot_restore.eonreplay and b/crates/sim/tests/fixtures/snapshot_restore.eonreplay differ diff --git a/crates/sim/tests/fixtures/stalemate_45min.eonreplay b/crates/sim/tests/fixtures/stalemate_45min.eonreplay index 955e292..287cb3a 100644 Binary files a/crates/sim/tests/fixtures/stalemate_45min.eonreplay and b/crates/sim/tests/fixtures/stalemate_45min.eonreplay differ diff --git a/data/README.md b/data/README.md index b39cf48..d695018 100644 --- a/data/README.md +++ b/data/README.md @@ -4,7 +4,9 @@ This directory holds every tuning number, map and (from M5b) AI script that the Eonmark simulation reads at start-up. Nothing in here is compiled into the binary; `rules::Rules::load("data")` parses and validates it, and the resulting `rules_hash` is written into every replay header so a changed file -is reported as `RULES CHANGED` rather than as a simulation bug. The code +is reported as `RULES CHANGED` rather than as a simulation bug. The two +presentation files, `visuals.ron` and `rules/strings/`, are validated too +but are not in `rules_hash`, since the simulation never reads them. The code reads these files; it never defines gameplay constants of its own. ## Layout @@ -20,7 +22,7 @@ reads these files; it never defines gameplay constants of its own. | `rules/factions.ron` | The factions and their Modifier rows (Freeholders, none) | `crates/rules/src/factions.rs` (`Factions`) | M4a | | `maps/plains_1v1.ron` | The one 128x128 mirrored two-player map | `crates/rules/src/map.rs` | M0 | | `rules/strings/en.ron` | UI strings: the message line's text for every command rejection, HUD labels and tooltips. Presentation only, outside `rules_hash` (see `crates/rules/src/strings.rs`) | written | M3b | -| `visuals.ron` | How each unit kind is drawn: `Primitive(Capsule \| Cuboid \| Cylinder \| Sphere \| Cone)` and, from M4b, `Body(shape, height_tiles_x100, lying)` now, `Scene(path, scale_x100, y_offset_tiles_x100, yaw_deg)` from M7; keyed by unit kind id, every kind in `units.ron` must appear | `crates/rules/src/visuals.rs` (`Visuals`) | M2, M4b | +| `visuals.ron` | How each unit kind is drawn: `Primitive(Capsule \| Cuboid \| Cylinder \| Sphere \| Cone)` and, from M4b, `Body(shape, height_tiles_x100, lying)` now, `Scene(path, scale_x100, y_offset_tiles_x100, yaw_deg)` from M7; keyed by unit kind id, every kind in `units.ron` must appear. Presentation only: validated by `Rules::load`, outside `rules_hash` since `rules_version` 25 ([ADR 0003](../docs/adr/0003-visuals-outside-rules-hash.md)) | `crates/rules/src/visuals.rs` (`Visuals`) | M2, M4b | | `ai/personalities.ron` | One row per bot personality: its build order and every knob of the bot (cadence, caps, site policies, army mix, waves, defence and recall); M5b ships `standard`. Loaded last by `Rules::load`, attached as `Rules::ai` and in `rules_hash`, so an edit needs a `rules_version` bump | `crates/rules/src/ai.rs` (`AiRules`) | M5b | | `ai/build_orders/*.ron` | One build order per file, its `id` equal to the file stem; M5b ships `standard.ron`. In `rules_hash` like the personalities | `crates/rules/src/ai.rs` (`AiRules`) | M5b | | `ai/difficulty.ron` | Easy, Standard, Hard: income interval and aggression | not yet written | M6 | @@ -33,8 +35,11 @@ reference for contributors is [../docs/DATA_FORMAT.md](../docs/DATA_FORMAT.md). ## visuals.ron Read by the game's presenter, never by the simulation, but loaded and -validated by `Rules::load` so a missing visual is caught before a window -opens and so the file is part of `rules_hash`. Schema `crates/rules/src/visuals.rs`. +validated by `Rules::load`, so a missing or bad visual fails `sim-cli +data-check` and is caught before a window opens. Since `rules_version` 25 +the file is not part of `rules_hash` +([ADR 0003](../docs/adr/0003-visuals-outside-rules-hash.md); before that it +was, from M2). Schema `crates/rules/src/visuals.rs`. | Field | Meaning | |-------|---------| @@ -43,10 +48,12 @@ opens and so the file is part of `rules_hash`. Schema `crates/rules/src/visuals. | `Body(shape, height_tiles_x100, lying)` | The same primitive two radii wide with its own height in hundredths of a tile (M4b): `height_tiles_x100` in 20..=400, a `Capsule` at least two radii. `lying: true` (default `false`, `Capsule` or `Cylinder` only) lays it along the unit's facing, `height_tiles_x100` long and two radii tall. A `Body` is never a `Sphere`. Errors name `units[""].shape`, `.lying` or `.height_tiles_x100`. | | `Scene(path, scale_x100, y_offset_tiles_x100, yaw_deg)` | glTF under `assets/` (`.glb` or `.gltf`), uniform scale in hundredths (1..=10000), vertical offset in hundredths of a tile, extra yaw in whole degrees (-360..=360). Parsed and validated now, used from M7. | -The game draws every look low-poly and flat-shaded. A changed look changes -`rules_hash`, so it needs a `rules_version` bump and `just rerecord` like -any other data edit; the sim never reads it, so every `.hash` file stays the -same and only the replay headers change. +The game draws every look low-poly and flat-shaded. A changed look leaves +`rules_hash` alone, so it needs no `rules_version` bump and no `just +rerecord`: every replay still verifies (`OK`, not `RULES CHANGED`), and +`sim-cli data-check` still rejects an entry that breaks the rules above. +Anything the simulation or a bot reads does not belong in this file; it +goes in a hashed file under `rules/` or `ai/`. ```ron (units: { @@ -82,10 +89,13 @@ exits 1 and prints the file path, the span and the field, for example: error: data/rules/rules.ron: 8:3-8:18: Unexpected field named `tick_rate_hertz` in `Rules`, expected one of `rules_version`, `tick_rate_hz`, ... instead ``` -Every edit here changes `rules_hash`, so a change you keep also bumps +Every edit here except `visuals.ron` and `rules/strings/` changes +`rules_hash`, so a change you keep to any other file also bumps `rules_version` in `rules/rules.ron` and re-records the golden fixtures with `just rerecord` before `cargo test -p sim`; the full data-only path is -the worked example in [rules/README.md](rules/README.md). +the worked example in [rules/README.md](rules/README.md). A look or UI +string edit needs only `data-check`: neither file is hashed, so it takes no +bump and no re-record. ## License diff --git a/data/rules/README.md b/data/rules/README.md index 2bf7976..3a7ffd6 100644 --- a/data/rules/README.md +++ b/data/rules/README.md @@ -14,6 +14,10 @@ M5b, the bot data in `../ai/`, see [`../ai/README.md`](../ai/README.md)) and validated before the simulation starts; their content is part of `rules_hash`, so editing them without bumping `rules_version` makes `sim-cli verify` report `RULES CHANGED since recording` on older replays. +`../visuals.ron` is the exception: it is validated with the rest but, since +`rules_version` 25, not hashed +([ADR 0003](../../docs/adr/0003-visuals-outside-rules-hash.md)), because +the simulation never reads it. Changing a number here needs no Rust; see the worked example at the end for the full data-only path. @@ -47,7 +51,7 @@ when you can; the validator does not require it. | Field | Meaning | Value | Used from | |-------|---------|-------|-----------| -| `rules_version` | Bump whenever golden replays are regenerated, and for any edit to a RON file under `data/rules/` or `data/ai/` except the UI strings in `data/rules/strings/` (those files are in `rules_hash`; the strings are not, see `docs/DATA_FORMAT.md`); the comment above it gives the reason for the current number | 22 | M0 | +| `rules_version` | Bump whenever golden replays are regenerated, and for any edit to a RON file under `data/rules/` or `data/ai/` except the UI strings in `data/rules/strings/` (those files are in `rules_hash`; the strings are not, see `docs/DATA_FORMAT.md`); the comment above it gives the reason for the current number | 25 | M0 | | `tick_rate_hz` | Simulation ticks per second | 20 | M0 | | `cmd_delay_ticks` | A command issued at tick N applies at N + this | 2 | M0 | | `default_map` | Map for `MatchSetup::skirmish`; must exist under `data/maps/` | `"plains_1v1"` | M0 | @@ -182,7 +186,10 @@ order), with the same header-only re-record, and added the `m5_first_match` golden; its review fixes (`rules_version` 24) added the `defend_guard_pop_ratio` knob to `../ai/personalities.ron`, again with a header-only re-record (only `m5_first_match`, new in that pull request, -moved with the bot's code). +moved with the bot's code). The art pass's ADR 0003 (`rules_version` 25) +changed no value either: `../visuals.ron` left `rules_hash`, so every +fixture was re-recorded for its header alone and no `.hash` moved; a look +edit needs no bump from then on. ## resources.ron @@ -553,13 +560,14 @@ To make attrition tick every 4 seconds instead of 3.2, edit `rules.ron`: attrition_interval_ds: 40, ``` -Any RON edit changes `rules_hash`, so without the next steps every golden +Any edit to a hashed RON file (every one but `../visuals.ron` and +`strings/`) changes `rules_hash`, so without the next steps every golden fixture reports `RULES CHANGED since recording` and `cargo test -p sim` -fails. The data-only path is the same for every RON file and touches no -Rust: +fails. The data-only path is the same for every hashed RON file and +touches no Rust: 1. Edit the value, as above. -2. Bump `rules_version` in `rules.ron` (24 to 25), update the one-line +2. Bump `rules_version` in `rules.ron` (25 to 26), update the one-line reason in the comment above it and put the same reason in the commit message. 3. Check the data, re-record the golden fixtures and run the sim tests: @@ -571,8 +579,8 @@ cargo test -p sim ``` `data-check` prints -`OK rules_version=25 rules_hash=0x<16 hex> resources=4 units=8 buildings=8 ages=3 techs=12 side_techs=2 factions=1 personalities=1 build_orders=1 maps=plains_1v1 strings=en` -(the repository is at 24, so before the edit it prints `rules_version=24`; +`OK rules_version=26 rules_hash=0x<16 hex> resources=4 units=8 buildings=8 ages=3 techs=12 side_techs=2 factions=1 personalities=1 build_orders=1 maps=plains_1v1 strings=en` +(the repository is at 25, so before the edit it prints `rules_version=25`; the hash is new because a file changed). `just rerecord` runs `scripts/rerecord_fixtures.sh`: it re-records every fixture under `crates/sim/tests/fixtures/` in release, writes each `.hash` diff --git a/data/rules/rules.ron b/data/rules/rules.ron index d474851..7beffee 100644 --- a/data/rules/rules.ron +++ b/data/rules/rules.ron @@ -3,11 +3,10 @@ // at load by rules::Rules::ticks_from_ds. Schema: crates/rules/src/lib.rs // (deny_unknown_fields). Authoring guide: data/rules/README.md ( - // 24: M5b waves slice review: data/ai only (personalities.ron gains - // defend_guard_pop_ratio, the size of the home guard once a defence is - // past defend_max_block_ds); every fixture is re-recorded for the new - // rules_hash in its header and no existing .hash moves. - rules_version: 24, + // 25: data/visuals.ron leaves rules_hash (ADR 0003); no sim change, no + // value changed: every fixture is re-recorded for the new rules_version + // and rules_hash in its header and no .hash moves. + rules_version: 25, tick_rate_hz: 20, cmd_delay_ticks: 2, default_map: "plains_1v1", diff --git a/data/visuals.ron b/data/visuals.ron index 7f5ed95..8b84d7b 100644 --- a/data/visuals.ron +++ b/data/visuals.ron @@ -1,9 +1,12 @@ // Eonmark visuals: how each unit kind is drawn. Schema: // crates/rules/src/visuals.rs (deny_unknown_fields). Keys are unit kind ids // from rules/units.ron; every kind listed there must appear here (the loader -// rejects a missing or unknown key and names it). Loaded by rules::Rules::load, -// so this file is part of rules_hash; the sim never reads it. Building -// kinds are drawn by crates/game/src/buildings.rs; M7 moves them here. +// rejects a missing or unknown key and names it). Loaded and validated by +// rules::Rules::load, so `sim-cli data-check` fails on a bad entry, but not +// part of rules_hash (ADR 0003, docs/adr/0003-visuals-outside-rules-hash.md): +// the sim never reads it, so a look edit needs no rules_version bump and no +// re-record. Building kinds are drawn by crates/game/src/buildings.rs; M7 +// moves them here. // // Primitive(Capsule | Cuboid | Cylinder | Sphere | Cone): one shared mesh // sized from the kind's radius_tiles_x100 (a Capsule 3.4 radii tall, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 159079b..60f7530 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -123,7 +123,7 @@ M5b's bot plumbing slice (`SIM_VERSION` 17, `rules_version` 21) puts the bots in Schema, loader and validator for everything under `data/`. `Rules::load(dir)` reads the RON files, checks every cross-reference, converts authored units to ticks and integers exactly once, and computes `rules_hash`. Errors name the file path and field. The clippy ban list applies here too, so a float can never enter through data. -At M0: `lib.rs` (the `Rules` struct with the full v0.1 field set from `rules.ron`, a validator, `ticks_from_ds` and `rules_hash`), `resources.rs` (the four resources) and `map.rs` (`MapDef`, the tile-row map loader and symmetry validator). `Rules::load` attaches resources and every map under `data/maps/` to the struct, so `rules_hash` covers all three. M1 adds `units.rs` (`UnitKind` with the `_x100` movement fields, `Units::validate`, `Rules::unit_kind(u16)` and `unit_kind_by_id(&str)`; `units.ron` is attached by `load` and is part of `rules_hash`). M2 adds `visuals.rs` (`Primitive`, `Visual::{Primitive, Scene}`, `Visuals::validate` against the unit kinds, `Rules::visual(kind)`; `data/visuals.ron` is attached by `load` and is part of `rules_hash` although only the game reads it). M3a adds `buildings.rs` (`BuildingKind`, `GatherSpec`, `Slots::{Fixed, Adjacent}`, `Buildings::validate`; `buildings.ron` is attached by `load` and is part of `rules_hash`), the training fields of `UnitKind` (`UnitClass`, `UnitRamp::{Flat, PerUnit, Military}`), the ramp, queue, idle-seek and `standard_start` fields of `Rules`, the checks that span files (trainers, `gathered_from` both ways, the standard start, map starts against the Town footprint) and `costs.rs`, the pure cost functions (`building_cost`, `unit_cost`, `building_mult_permille`, `military_mult_permille`) that the sim, the HUD and the bot share. M4a's rules data adds `modifiers.rs` (`Modifier`, `StatPath`, `UnitSel`, `ModOp` and the per-row floored `resolve`), `attack.rs` (`Attack`, `Prefer`), `techs.rs`, `ages.rs` and `factions.rs` (each file attached by `load` and part of `rules_hash`), `hp`, `attack` and `requires_tech` on `UnitKind`, the counter table, building attacks, `Rules::modifier_rows` and the tech, age and combat accessors, and the checks that span the new files (the `ages.ron` references resolved first, unlocks exactly once and in order, capped costs, at least one tick per cooldown and research, at least 1 damage per hit, worst-case bounds per stat). M5a's rules data (`rules_version` 18) adds `StatPath::HarryingLevel`, `counts_toward_age` on `Tech` (the Watchtower's Harrying I and II are side techs), `immune_to_attrition` and `supplies` on `UnitKind`, `stays_on_annexation` on `BuildingKind`, the attrition, annexation and match-end fields of `Rules` with their accessors (`attrition_pulse_hp`, `harrying_bonus_age_index`, `match_cap_ticks`, `annexation_defender_radius`, `yield_cap_decay_ticks`, `counting_techs`) and their checks (age gates counting only age techs, the annexation selectors, the `HarryingLevel` worst case against `MAX_HARRYING_LEVEL` and the halved attrition interval, timers that fit `u32`). M5b adds `ai.rs` (`AiRules` with the `Personality` rows of `data/ai/personalities.ron` and the `BuildOrder`s of `data/ai/build_orders/*.ron`, `Step`, `Placement`, `RecallWave`, `worker_kind`), attached by `load` last as `Rules::ai` and part of `rules_hash`, with its checks (ids, ranges, placement kinds and the static reachability of every build order; the personalities' caps, radii, cadence against `cmd_delay_ticks`, army mix, supply kind and wave table). +At M0: `lib.rs` (the `Rules` struct with the full v0.1 field set from `rules.ron`, a validator, `ticks_from_ds` and `rules_hash`), `resources.rs` (the four resources) and `map.rs` (`MapDef`, the tile-row map loader and symmetry validator). `Rules::load` attaches resources and every map under `data/maps/` to the struct, so `rules_hash` covers all three. M1 adds `units.rs` (`UnitKind` with the `_x100` movement fields, `Units::validate`, `Rules::unit_kind(u16)` and `unit_kind_by_id(&str)`; `units.ron` is attached by `load` and is part of `rules_hash`). M2 adds `visuals.rs` (`Primitive`, `Visual::{Primitive, Scene}`, `Visuals::validate` against the unit kinds, `Rules::visual(kind)`; `data/visuals.ron` is attached by `load` and was part of `rules_hash` although only the game reads it, until ADR 0003 made `Rules::visuals` `#[serde(skip)]` at `rules_version` 25: it is still loaded and validated, so `data-check` catches a bad entry, but not hashed). M3a adds `buildings.rs` (`BuildingKind`, `GatherSpec`, `Slots::{Fixed, Adjacent}`, `Buildings::validate`; `buildings.ron` is attached by `load` and is part of `rules_hash`), the training fields of `UnitKind` (`UnitClass`, `UnitRamp::{Flat, PerUnit, Military}`), the ramp, queue, idle-seek and `standard_start` fields of `Rules`, the checks that span files (trainers, `gathered_from` both ways, the standard start, map starts against the Town footprint) and `costs.rs`, the pure cost functions (`building_cost`, `unit_cost`, `building_mult_permille`, `military_mult_permille`) that the sim, the HUD and the bot share. M4a's rules data adds `modifiers.rs` (`Modifier`, `StatPath`, `UnitSel`, `ModOp` and the per-row floored `resolve`), `attack.rs` (`Attack`, `Prefer`), `techs.rs`, `ages.rs` and `factions.rs` (each file attached by `load` and part of `rules_hash`), `hp`, `attack` and `requires_tech` on `UnitKind`, the counter table, building attacks, `Rules::modifier_rows` and the tech, age and combat accessors, and the checks that span the new files (the `ages.ron` references resolved first, unlocks exactly once and in order, capped costs, at least one tick per cooldown and research, at least 1 damage per hit, worst-case bounds per stat). M5a's rules data (`rules_version` 18) adds `StatPath::HarryingLevel`, `counts_toward_age` on `Tech` (the Watchtower's Harrying I and II are side techs), `immune_to_attrition` and `supplies` on `UnitKind`, `stays_on_annexation` on `BuildingKind`, the attrition, annexation and match-end fields of `Rules` with their accessors (`attrition_pulse_hp`, `harrying_bonus_age_index`, `match_cap_ticks`, `annexation_defender_radius`, `yield_cap_decay_ticks`, `counting_techs`) and their checks (age gates counting only age techs, the annexation selectors, the `HarryingLevel` worst case against `MAX_HARRYING_LEVEL` and the halved attrition interval, timers that fit `u32`). M5b adds `ai.rs` (`AiRules` with the `Personality` rows of `data/ai/personalities.ron` and the `BuildOrder`s of `data/ai/build_orders/*.ron`, `Step`, `Placement`, `RecallWave`, `worker_kind`), attached by `load` last as `Rules::ai` and part of `rules_hash`, with its checks (ids, ranges, placement kinds and the static reachability of every build order; the personalities' caps, radii, cadence against `cmd_delay_ticks`, army mix, supply kind and wave table). ### `crates/ai/` diff --git a/docs/ART_STYLE.md b/docs/ART_STYLE.md index 4f735f0..9956b2d 100644 --- a/docs/ART_STYLE.md +++ b/docs/ART_STYLE.md @@ -98,7 +98,7 @@ Unit looks (M4b, `data/visuals.ron`), one silhouette per kind. Width is the kind Two kinds share a shape only when they differ by at least 1.4 times on some axis (the Scribe's drum and the Bowman's post, the Shieldbearer's block and the Wain's low box, the Yeoman's upright pill and the Outrider's lying one); `present::tests::every_unit_kind_has_its_own_silhouette` checks it. The 40 m view is `--scenario unit_looks`: one unit of each kind per team in two rows 15 m apart, framed at 40 m unless `--camera` is given (`target/ci/eonmark --background --scenario unit_looks --exit-after-seconds 40 --screenshot-at 100:looks.png`; CI's render smoke test renders it on every PR). The owner chose single-shape silhouettes over multi-part looks on 2026-10-22 (ROADMAP M4b, Notes, decision 11); multi-part looks wait for the art pass. The looks themselves are signed off on the 40 m screenshot, and that sign-off is still pending: the owner's run on the Mac, with the P3 and sRGB check and the checklist below. -The sign-off also looks at a crowd, because the lineup stands every unit alone 4 m from the next and cannot show how the looks pack in a fight: `target/ci/eonmark --background --scenario enemy_outpost --camera 100,32,40 --exit-after-seconds 60 --screenshot-at 250:crowd.png` (`just render-smoke out enemy_outpost 100,32,40 250` under Xvfb) frames the 200 vs 200 prologue at 40 m mid-fight. Every look is exactly its kind's collision diameter across except the Outrider's, whose lying pill is 1.8 m long against a 0.9 m diameter. Melee range is measured edge to edge (0.25 m for the Outrider) and the sim keeps units apart only by a soft push, so two Outriders holding on each other stand at most 1.15 m apart, centre to centre, while each pill reaches 0.9 m ahead of its centre: head-on they overlap by at least 0.65 m, and in a packed melee the pills visibly pass through each other. Shortening the pill is a data-only change, `height_tiles_x100` of `"outrider"` in `data/visuals.ron` (120 keeps a lying pill, cuts the head-on overlap to 0.05 m and still differs from the Yeoman's upright pill by 1.7 times along its length, and `present::tests::every_unit_kind_has_its_own_silhouette` passes with it, but reads closer to the Mangonel's 1.0 m ball); because `visuals.ron` is in `rules_hash`, it needs a `rules_version` bump and `just rerecord` in the same commit. The owner decides that trade at the sign-off. +The sign-off also looks at a crowd, because the lineup stands every unit alone 4 m from the next and cannot show how the looks pack in a fight: `target/ci/eonmark --background --scenario enemy_outpost --camera 100,32,40 --exit-after-seconds 60 --screenshot-at 250:crowd.png` (`just render-smoke out enemy_outpost 100,32,40 250` under Xvfb) frames the 200 vs 200 prologue at 40 m mid-fight. Every look is exactly its kind's collision diameter across except the Outrider's, whose lying pill is 1.8 m long against a 0.9 m diameter. Melee range is measured edge to edge (0.25 m for the Outrider) and the sim keeps units apart only by a soft push, so two Outriders holding on each other stand at most 1.15 m apart, centre to centre, while each pill reaches 0.9 m ahead of its centre: head-on they overlap by at least 0.65 m, and in a packed melee the pills visibly pass through each other. Shortening the pill is a data-only change, `height_tiles_x100` of `"outrider"` in `data/visuals.ron` (120 keeps a lying pill, cuts the head-on overlap to 0.05 m and still differs from the Yeoman's upright pill by 1.7 times along its length, and `present::tests::every_unit_kind_has_its_own_silhouette` passes with it, but reads closer to the Mangonel's 1.0 m ball); since `rules_version` 25 `visuals.ron` is not in `rules_hash` ([ADR 0003](adr/0003-visuals-outside-rules-hash.md)), so it needs no `rules_version` bump and no `just rerecord` (at M4b it needed both). The owner decides that trade at the sign-off. Motion is procedural (bob, swing, scale pulse), not animation clips, in v0.1. @@ -134,7 +134,7 @@ Every unit kind is drawn through one `Visual` value from `data/visuals.ron`, key "mangonel": Scene(path: "models/mangonel.glb", scale_x100: 100, y_offset_tiles_x100: 0, yaw_deg: 0), ``` -`Visual::Primitive(shape)` draws a team-coloured primitive sized from the kind's radius (`Capsule`, `Cuboid`, `Cylinder`, `Sphere` or `Cone`). `Visual::Body(shape, height_tiles_x100, lying)` (M4b) draws the same primitive two radii wide with its own height in hundredths of a metre (20 to 400; never a `Sphere`); `lying: true` lays a `Capsule` or `Cylinder` along the unit's facing, that long and two radii tall. `Visual::Scene(path, scale_x100, y_offset_tiles_x100, yaw_deg)` loads a glTF scene; the scale and offset are in hundredths and the yaw in whole degrees, so the file stays float-free. Swapping a cube for a model is a RON edit, never a Rust change. M0 to M6 ship primitives only; M7 maps every kind to CC0 models and keeps the primitive look as the fallback. Anything drawn above a unit (the M4b health bar) takes its height from `present::look_top_y`, computed from the `Visual`, so the art pass changes that one function. Until M7 the presenter draws a `Scene` as the fallback `Primitive(Capsule)` (`present::FALLBACK_VISUAL`). +`Visual::Primitive(shape)` draws a team-coloured primitive sized from the kind's radius (`Capsule`, `Cuboid`, `Cylinder`, `Sphere` or `Cone`). `Visual::Body(shape, height_tiles_x100, lying)` (M4b) draws the same primitive two radii wide with its own height in hundredths of a metre (20 to 400; never a `Sphere`); `lying: true` lays a `Capsule` or `Cylinder` along the unit's facing, that long and two radii tall. `Visual::Scene(path, scale_x100, y_offset_tiles_x100, yaw_deg)` loads a glTF scene; the scale and offset are in hundredths and the yaw in whole degrees, so the file stays float-free. Swapping a cube for a model is a RON edit, never a Rust change, and since `rules_version` 25 it needs no `rules_version` bump or re-record either: `data-check` validates `visuals.ron`, but `rules_hash` leaves it out ([ADR 0003](adr/0003-visuals-outside-rules-hash.md)). M0 to M6 ship primitives only; M7 maps every kind to CC0 models and keeps the primitive look as the fallback. Anything drawn above a unit (the M4b health bar) takes its height from `present::look_top_y`, computed from the `Visual`, so the art pass changes that one function. Until M7 the presenter draws a `Scene` as the fallback `Primitive(Capsule)` (`present::FALLBACK_VISUAL`). ## glTF import rules diff --git a/docs/DATA_FORMAT.md b/docs/DATA_FORMAT.md index 652a3bc..0215c31 100644 --- a/docs/DATA_FORMAT.md +++ b/docs/DATA_FORMAT.md @@ -11,7 +11,7 @@ This document explains how Eonmark's game data is authored: where the files live - Durations are authored in deciseconds, in fields ending in `_ds`. The one conversion is `Rules::ticks_from_ds`: `ticks = round(ds * tick_rate_hz / 10)`, rounding to nearest with halves up. At 20 Hz one decisecond is exactly two ticks: `32` ds is 64 ticks, `600` ds is 1200 ticks. The validator does not require a whole tick count; pick values that give one when you can. The exceptions are `tick_rate_hz` and `cmd_delay_ticks` in `rules.ron`, which define the clock itself. - Conversion happens in one place. Authored values stay in `Rules` as integers; the sim reads them only through tick and `Fx` accessors such as `Rules::attrition_interval_ticks()` and `Rules::annexation_ticks()`, which call `Rules::ticks_from_ds`. No other code converts. - Rates are per 30 seconds. "10 Grain per 30 s" is written as `10` in a field documented as per-30-s. -- `rules_version` is a human label bumped when golden replays regenerate; `rules_hash` is computed over everything `Rules::load` reads (match rules, resources and every map at M0; `units.ron` from M1; `visuals.ron` from M2; `buildings.ron` from M3a; `techs.ron`, `ages.ron` and `factions.ron` from M4a). Both go into every replay header. See [DETERMINISM.md](DETERMINISM.md). +- `rules_version` is a human label bumped when golden replays regenerate; `rules_hash` is computed over everything `Rules::load` reads (match rules, resources and every map at M0; `units.ron` from M1; `buildings.ron` from M3a; `techs.ron`, `ages.ron` and `factions.ron` from M4a; `data/ai/` from M5b) except `visuals.ron`, which `Rules::load` reads and validates but, since `rules_version` 25, does not hash (from M2 to `rules_version` 24 it did; [ADR 0003](adr/0003-visuals-outside-rules-hash.md)). Both go into every replay header. See [DETERMINISM.md](DETERMINISM.md). ## The `Modifier` type @@ -69,7 +69,7 @@ The Yield Cap is applied to the rate before accumulation, so production above th ## Schema strictness -Every struct in `crates/rules` is `#[serde(deny_unknown_fields)]`. A misspelled or obsolete field fails to load with the file path and field name. New optional fields use `#[serde(default)]` so existing data keeps loading. Files other than `rules.ron` (`resources.ron`, `units.ron`, `buildings.ron`, `techs.ron`, `ages.ron`, `factions.ron`, `visuals.ron`, every map under `maps/`, and from M5b `ai/personalities.ron` and every `ai/build_orders/*.ron`) are parsed separately and attached to the `Rules` struct by `Rules::load`, so one struct carries everything and `rules_hash` covers it all. +Every struct in `crates/rules` is `#[serde(deny_unknown_fields)]`. A misspelled or obsolete field fails to load with the file path and field name. New optional fields use `#[serde(default)]` so existing data keeps loading. Files other than `rules.ron` (`resources.ron`, `units.ron`, `buildings.ron`, `techs.ron`, `ages.ron`, `factions.ron`, `visuals.ron`, every map under `maps/`, and from M5b `ai/personalities.ron` and every `ai/build_orders/*.ron`) are parsed separately and attached to the `Rules` struct by `Rules::load`, so one struct carries everything and `rules_hash` covers it all but `visuals.ron`: `Rules::visuals` is `#[serde(skip)]`, so the look data is validated with the rest and left out of the hash ([ADR 0003](adr/0003-visuals-outside-rules-hash.md)). Cross-reference checks run after parsing. Implemented as of M3a: every cost, yield and gather entry names a resource id; every unit's `trainer` names a building kind and every unit has a visual; every building's gather `worker` names a unit kind; each resource's `gathered_from` equals the building kinds that yield or gather it (checked both ways); exactly one building kind is the Town; `standard_start` names known units and lists every resource; every map start fits the Town footprint, in bounds and all grass. Added in M4a, with the `ages.ron` references resolved first so a removed tech is reported where `ages.ron` names it: every id an age unlocks exists and every unit kind, building kind and tech is unlocked by exactly one age, never before its trainer, host, predecessor level or `requires_tech`; age gates are monotonic and reachable; each tech line's levels come in order; a tech's host and a unit's `requires_tech` exist; every `Modifier` target is a valid `StatPath` with integer values and `Mul` in permille; tech and age costs fit the capped stockpiles; every attack lasts at least one tick and every hit does at least 1 damage; and every stat's worst case fits its type. Added in M5a: the age gates count only the techs that count toward an age (`counts_toward_age`, so not the Harrying side techs); `harrying_bonus_age` names an age; every `Kind` in `annexation_attackers` and `annexation_defenders` names a unit kind, each list reaches at least one kind and no attacker selector reaches a `Civilian` kind; the `HarryingLevel` rows reach at most level 8 and the attrition interval halved per level above 1 stays at least one tick; and the Town kind never has `stays_on_annexation`. Added in M5b, for the bot data under `data/ai/` (`crates/rules/src/ai.rs`, loaded last, after the maps): a build order's `id` equals its file stem and it has 1 to 64 steps; every kind, tech, age and resource id resolves; counts are 1 to 200; a `Workers` split is non-empty, names resources the worker kind gathers and sums to at most the count; `Expansion` places only the Town kind, `Terrain` only kinds with `Adjacent` slots and `BorderEdge` only kinds with `border_radius_tiles`; and static reachability: a `Build`, `Train` or `Research` step that is not unlocked by the start age or an earlier `Advance`, whose trainer or host no earlier non-optional step builds, whose prerequisite (a line's lower level, a unit's `requires_tech`) no earlier non-optional step researches, or whose count exceeds `max_per_town` times the Towns the earlier steps build, must be `optional: true`; an `Advance` is never optional, its ages go up, and the earlier non-optional steps must research its `techs_required` counting techs. Each personality has a unique id and an existing build order, a cadence in ticks above `cmd_delay_ticks`, caps of 1 to 64, radii of 1 to 64 tiles, times up to 72000 ds, an army mix of armed non-civilian kinds with weights of 1 to 1000, a `supply_kind` that `supplies`, wave sizes from 0 ds with strictly increasing times and non-decreasing sizes up to `max_wave_units` (at most 64, which bounds the bot's hashed memory), and a `wave_requires_age` that is `None` or an age. The field-by-field list is in `data/rules/README.md` and `data/ai/README.md`. @@ -79,7 +79,7 @@ Cross-reference checks run after parsing. Implemented as of M3a: every cost, yie cargo run -p sim-cli -- data-check data/ ``` -Exit code 0 prints one line such as `OK rules_version=24 rules_hash=0x<16 hex> resources=4 units=8 buildings=8 ages=3 techs=12 side_techs=2 factions=1 personalities=1 build_orders=1 maps=plains_1v1 strings=en` (`techs` counts the techs that count toward an age and `side_techs` the rest, from M5a the Watchtower's Harrying I and II; `personalities` and `build_orders` count the bot data under `data/ai/`, from M5b; the hash changes whenever any loaded file changes; M1 added `units.ron` to it, M2 `visuals.ron`, M3a `buildings.ron`, M4a `techs.ron`, `ages.ron` and `factions.ron`, M5b `data/ai/`; the M3b UI strings are checked but not hashed). Exit code 1 prints one line starting with `error:` in one of three shapes, from `rules::Error`: +Exit code 0 prints one line such as `OK rules_version=25 rules_hash=0x<16 hex> resources=4 units=8 buildings=8 ages=3 techs=12 side_techs=2 factions=1 personalities=1 build_orders=1 maps=plains_1v1 strings=en` (`techs` counts the techs that count toward an age and `side_techs` the rest, from M5a the Watchtower's Harrying I and II; `personalities` and `build_orders` count the bot data under `data/ai/`, from M5b; the hash changes whenever any hashed file changes; M1 added `units.ron` to it, M2 `visuals.ron`, M3a `buildings.ron`, M4a `techs.ron`, `ages.ron` and `factions.ron`, M5b `data/ai/`, and `rules_version` 25 took `visuals.ron` out again (ADR 0003); the M3b UI strings and `visuals.ron` are checked but not hashed). Exit code 1 prints one line starting with `error:` in one of three shapes, from `rules::Error`: | Variant | Format | Example cause | |---|---|---| @@ -105,7 +105,7 @@ The same loader runs at game start, so a broken file is caught before a window o | `data/README.md` | Layout, conventions and the license statement for `data/` (MIT OR Apache-2.0) | M0 | | `data/maps/README.md`, `data/ai/README.md` | Per-directory schema in plain English | M0 | | `data/maps/plains_1v1.ron` | The one 128x128 map: `name`, `width`, `height`, `symmetry` (`MirrorX`), two `starts`, and `rows` of `.` grass, `f` forest, `m` mountain, `~` water | M0 | -| `data/visuals.ron` | `(units: { "": , ... })`, keyed by `units.ron` `id`; every kind needs one. `Visual` is `Primitive(Capsule \| Cuboid \| Cylinder \| Sphere \| Cone)` (one shared mesh sized from the kind's `radius_tiles_x100`: a Capsule 3.4 radii tall, the others 2 radii; tinted with the owner's team colour), the struct variant `Body(shape: , height_tiles_x100: , lying: )` (M4b: the same primitive two radii wide and `height_tiles_x100` tall, or, with `lying: true`, that long along the unit's facing and two radii tall) or the struct variant `Scene(path: "models/x.glb", scale_x100: 100, y_offset_tiles_x100: 0, yaw_deg: 0)` (glTF under `assets/`; parsed and validated now, used from M7). Validation (`Visuals::validate`): a kind without a visual or a key naming no kind is an error naming `units[""]`; a `Body` shape must not be `Sphere` (`units[""].shape`), only a `Capsule` or `Cylinder` may lie (`.lying`), `height_tiles_x100` must be in `20..=400` and a `Capsule`'s at least twice the kind's `radius_tiles_x100` (`.height_tiles_x100`); a `Scene` path must end in `.glb` or `.gltf`, `scale_x100` must be in `1..=10000`, `yaw_deg` in `-360..=360`, errors naming `units[""].`; an unknown shape is a parse error with line and column; `deny_unknown_fields`. Schema `crates/rules/src/visuals.rs`, loaded by `Rules::load`, part of `rules_hash` (so a visual change still marks older replays `RULES CHANGED` and needs a `rules_version` bump with `just rerecord`; the `.hash` files stay the same); the sim never reads it, the game's `present.rs` does and draws every look low-poly and flat-shaded. M4b entries (`rules_version` 17): `yeoman` Primitive(Capsule), `scribe` Primitive(Cylinder), `skirmisher` Body Cone 150, `shieldbearer` Body Cuboid 130, `bowman` Body Cylinder 190, `outrider` Body Capsule 180 lying, `mangonel` Primitive(Sphere), `supply_wain` Body Cuboid 60. Building kinds are drawn by the game's `buildings.rs` | M2 (primitives), M4b (`Body`, `Cone`), M7 (glTF) | +| `data/visuals.ron` | `(units: { "": , ... })`, keyed by `units.ron` `id`; every kind needs one. `Visual` is `Primitive(Capsule \| Cuboid \| Cylinder \| Sphere \| Cone)` (one shared mesh sized from the kind's `radius_tiles_x100`: a Capsule 3.4 radii tall, the others 2 radii; tinted with the owner's team colour), the struct variant `Body(shape: , height_tiles_x100: , lying: )` (M4b: the same primitive two radii wide and `height_tiles_x100` tall, or, with `lying: true`, that long along the unit's facing and two radii tall) or the struct variant `Scene(path: "models/x.glb", scale_x100: 100, y_offset_tiles_x100: 0, yaw_deg: 0)` (glTF under `assets/`; parsed and validated now, used from M7). Validation (`Visuals::validate`): a kind without a visual or a key naming no kind is an error naming `units[""]`; a `Body` shape must not be `Sphere` (`units[""].shape`), only a `Capsule` or `Cylinder` may lie (`.lying`), `height_tiles_x100` must be in `20..=400` and a `Capsule`'s at least twice the kind's `radius_tiles_x100` (`.height_tiles_x100`); a `Scene` path must end in `.glb` or `.gltf`, `scale_x100` must be in `1..=10000`, `yaw_deg` in `-360..=360`, errors naming `units[""].`; an unknown shape is a parse error with line and column; `deny_unknown_fields`. Schema `crates/rules/src/visuals.rs`, loaded and validated by `Rules::load` (so `data-check` fails on a bad or missing entry) but not part of `rules_hash` since `rules_version` 25 ([ADR 0003](adr/0003-visuals-outside-rules-hash.md); from M2 to 24 it was): a visual change needs no `rules_version` bump and no `just rerecord`, and every replay still verifies; the sim never reads it, the game's `present.rs` does and draws every look low-poly and flat-shaded. M4b entries (`rules_version` 17): `yeoman` Primitive(Capsule), `scribe` Primitive(Cylinder), `skirmisher` Body Cone 150, `shieldbearer` Body Cuboid 130, `bowman` Body Cylinder 190, `outrider` Body Capsule 180 lying, `mangonel` Primitive(Sphere), `supply_wain` Body Cuboid 60. Building kinds are drawn by the game's `buildings.rs` | M2 (primitives), M4b (`Body`, `Cone`), M7 (glTF) | | `data/ai/build_orders/*.ron` | Build orders: `id` (the file stem) and `steps`, each `Workers(count, split)`, `Build(kind, count, at: NearTown \| Terrain \| Expansion \| BorderEdge, optional)`, `Train(kind, count, optional)`, `Research(tech, optional)`, `Advance(age)`, `Military` or `Wait(until_ds)`; a step is done when the view shows it. M5b ships `standard.ron`. Schema `crates/rules/src/ai.rs`, attached as `Rules::ai`, in `rules_hash` | M5b | | `data/ai/difficulty.ron` | Easy, Standard, Hard: income interval and aggression flag | M6 | | `data/ai/personalities.ron` | One row per personality: its build order and every knob of the bot's rules (cadence, per-plan caps, site policies, army mix and supply kind, rally, waves, staging, the Supply Wain, ring hold, all-in, defend and recall); M5b ships `standard`, M6 adds rush, boom and tower. Schema `crates/rules/src/ai.rs` | M5b (one row), M6 | @@ -114,7 +114,7 @@ Every subdirectory carries a README with its schema in plain English and one wor ## Changing the bot's data (M5b) -`data/ai/` is in `rules_hash` like every other loaded file, so an edit to a build order or a personality takes the data-only path of [../CONTRIBUTING.md](../CONTRIBUTING.md) ("The data-first path"): edit, bump `rules_version`, `data-check`, `just rerecord`, run the tests, and compare the bot with `sim-cli play-bots` (plus `scripts/check_first_wave.sh` for the first wave). `just rerecord` rewrites every fixture's header. A fixture's `.hash` changes only when the edit changes a decision of a bot that the fixture seats: today that is `m5_first_match` alone (the Standard bot in both seats), so an edit to `standard.ron` or the `standard` row usually moves its hash, while a new build order or personality that no fixture seats moves none. `data-check` rejects a build order the bot could never finish (the static reachability checks above) before any game runs. A new kind of step, placement or knob is a Rust change in `crates/rules/src/ai.rs` (schema and validator) and in `crates/ai`; when it can alter a decision of the shipped bot it also bumps `SIM_VERSION` (M5b owner decision 2). +`data/ai/` is in `rules_hash` like every other loaded file but `visuals.ron`, so an edit to a build order or a personality takes the data-only path of [../CONTRIBUTING.md](../CONTRIBUTING.md) ("The data-first path"): edit, bump `rules_version`, `data-check`, `just rerecord`, run the tests, and compare the bot with `sim-cli play-bots` (plus `scripts/check_first_wave.sh` for the first wave). `just rerecord` rewrites every fixture's header. A fixture's `.hash` changes only when the edit changes a decision of a bot that the fixture seats: today that is `m5_first_match` alone (the Standard bot in both seats), so an edit to `standard.ron` or the `standard` row usually moves its hash, while a new build order or personality that no fixture seats moves none. `data-check` rejects a build order the bot could never finish (the static reachability checks above) before any game runs. A new kind of step, placement or knob is a Rust change in `crates/rules/src/ai.rs` (schema and validator) and in `crates/ai`; when it can alter a decision of the shipped bot it also bumps `SIM_VERSION` (M5b owner decision 2). ## Adding a field diff --git a/docs/DETERMINISM.md b/docs/DETERMINISM.md index a59aee3..820863a 100644 --- a/docs/DETERMINISM.md +++ b/docs/DETERMINISM.md @@ -131,7 +131,7 @@ The reader tolerates truncation. It decodes records with `postcard::take_from_by | `sim_version` | `u32` | Yes, fails fast | Hand-bumped whenever sim behaviour changes for the same inputs. Constant `sim::SIM_VERSION`. | | `git_sha` | `String` | Never | Informational. Comparing it would invalidate every fixture on every commit. | | `rules_version` | `u32` | Reported | Human label from `rules.ron`, bumped when goldens regenerate. | -| `rules_hash` | `u64` | Yes, reports `RULES CHANGED` | xxh3 over postcard of the loaded `Rules`, covering every file `Rules::load` reads: match rules, resources and every map at M0, `units.ron` from M1, `visuals.ron` from M2 (the sim never reads it, but a replay still records which visuals were loaded), `buildings.ron` from M3a, `techs.ron`, `ages.ron` and `factions.ron` from M4a (the counter table lives in `units.ron`), and from M5b `data/ai` (`personalities.ron` and `build_orders/*.ron`, attached as `Rules::ai`: the sim never reads them, the bots inside its step do). | +| `rules_hash` | `u64` | Yes, reports `RULES CHANGED` | xxh3 over postcard of the loaded `Rules`, covering every file `Rules::load` reads but `visuals.ron`: match rules, resources and every map at M0, `units.ron` from M1, `buildings.ron` from M3a, `techs.ron`, `ages.ron` and `factions.ron` from M4a (the counter table lives in `units.ron`), and from M5b `data/ai` (`personalities.ron` and `build_orders/*.ron`, attached as `Rules::ai`: the sim never reads them, the bots inside its step do). `visuals.ron` was hashed from M2 to `rules_version` 24; since 25 `Rules::visuals` is `#[serde(skip)]` ([ADR 0003](adr/0003-visuals-outside-rules-hash.md)): the file is still loaded and validated, but the sim never reads it, so a look edit needs no bump and a replay no longer records which looks were loaded, which `verify` never needed. The UI strings are not in `Rules` at all. | | `map` | `String` | Loaded | Map id under `data/maps/`. | | `seed` | `u64` | Loaded | Seed for the `Pcg32`. | | `tick_rate_hz` | `u32` | Loaded | 20. | @@ -179,7 +179,7 @@ cargo run -p sim-cli --release -- verify path/to/match.eonreplay | `OK final_hash=0x<16 hex> ticks=` | 0 | Re-simulation reproduced every recorded hash. `final_hash` and `ticks` come from the last hash record in the file (a replay without any hash record reports the re-simulated hash at the last tick seen). | | `DIVERGED at tick (subsystem: )` | 1 | The first hash record that did not match. `` is the first differing field of `SubHashes` in field order (`units`, `buildings`, `economy`, `territory`, `tech`, `pathing`, `rng`, `map`, `meta`), or `state` if the whole hash differs while every sub-hash matches. | | `SIM VERSION MISMATCH (replay , binary )` | 1 | `header.sim_version != sim::SIM_VERSION`. The replay predates a behaviour change; nothing is simulated and the rules are not loaded. The version is read from the header's first field before the rest is decoded, so a header whose layout has changed still reports this outcome. | -| `RULES CHANGED since recording (replay 0x<16 hex>, loaded 0x<16 hex>)` | 1 | `header.rules_hash != Rules::load(data_dir).rules_hash()`. Someone edited RON without bumping `rules_version` and regenerating; nothing is simulated. | +| `RULES CHANGED since recording (replay 0x<16 hex>, loaded 0x<16 hex>)` | 1 | `header.rules_hash != Rules::load(data_dir).rules_hash()`; nothing is simulated. Either someone edited a hashed RON file without bumping `rules_version` and regenerating, or the replay was recorded before a later `rules_version` bump (a player's match, not a fixture): verify that one with the build whose `sim-cli data-check data` prints the `replay` hash. The header's `rules_version` is not printed. | The checks run in that order: version, then rules, then re-simulation. From M5b the controller for the header's AI seats is built between the rules check and the re-simulation (`replay::verify_with` and `hash_dump_with` take a factory, `ai::controller_for` in sim-cli and the game; `verify` and `hash_dump` remain as wrappers that take a ready controller), so the four lines above stay the only outcomes and an unknown bot is a read error. Read errors (`ReplayError`: missing file, bad magic, unsupported format version, undecodable header, a corrupt record stream before the clean-exit trailer, out-of-order records) are not outcomes; the CLI reports them as `error: ` with exit 2 and never prints one of the four lines. The one exception is `ReplayError::SimVersion`: `verify` and the replay form of `hash-dump` turn it into the `SIM VERSION MISMATCH` outcome (exit 1), and the game's `--headless-run` prints the same line on stderr and exits 1. A truncated recording (no trailer) is not an error: the CLI prints a `warning:` on stderr and then the outcome for the records it has. @@ -272,7 +272,7 @@ Regeneration policy: - Fixtures regenerate only in a commit that bumps `rules_version` in `data/rules/rules.ron` and states the reason in one line of the commit message. CI enforces it: `scripts/check_fixture_policy.sh ` fails a pull request whose diff touches `crates/sim/tests/fixtures/*.eonreplay` or `*.hash` unless `rules_version` increased in the same diff (`just fixture-policy` runs it locally against the merge base with main). - A changed hash without that bump is a bug, not a fixture update. Find it with the bisect procedure above. -- `RULES CHANGED since recording` means someone edited RON without bumping. Either revert the edit or bump and regenerate in the same commit. +- `RULES CHANGED since recording` on a fixture means someone edited a hashed RON file without bumping. Either revert the edit or bump and regenerate in the same commit. Editing `data/visuals.ron` or the UI strings never causes it: neither is hashed. A replay recorded before a bump (a player's match, not a fixture) reports it on every later build, with no edit at all. Verify it with the build that recorded it; no command prints a header's `rules_version`, but the `replay 0x…` hash in its `RULES CHANGED` line names that build, because `sim-cli data-check data` at the recording commit prints the same `rules_hash` (`0xb231ab753cb4197e` is `rules_version` 24, `e84af88`). - `just rerecord [NAMES]` (`scripts/rerecord_fixtures.sh`) is the regeneration command: it re-records every fixture, or the ones named, in release, writes each `.hash` from an independent `sim-cli verify` OK line rather than from the recorder, and then fails if a fixture differs from the merge base with main while the working-tree `rules_version` is not larger than the base's (`scripts/check_fixture_policy.sh` checks the same on commits in CI). It is also the re-record step of the data-only contributor path in [../CONTRIBUTING.md](../CONTRIBUTING.md). - A behaviour change in Rust that is intended (a new movement rule, a fixed bug) bumps `SIM_VERSION` as well, and the commit regenerates every fixture. @@ -326,7 +326,7 @@ At the M4a close the sim is at `SIM_VERSION` 14 and the rules at `rules_version` ## What M4b adds -M4b adds no hashed state and keeps `SIM_VERSION` 14. Its unit looks bumped `rules_version` 16 -> 17 (`data/visuals.ron` is in `rules_hash`, but the sim never reads it), so every fixture was re-recorded with only its header changed and every `.hash` file byte-identical. `SimView::building_hp` is a read-only accessor, and `tech::research_quote_after` quotes research for a given set of researched techs with the same resolution `research_quote` uses (which now calls it), so a scripted scenario can price its research ahead of time. `sim::scenarios` gains `enemy_outpost` and `enemy_outpost_walkthrough`; their beat ticks and P0's grants are derived from the rules (`enemy_outpost_beats`, `outpost_grants`), so a data change moves them, and only the battle's outcome (`ENEMY_OUTPOST_CLEAR_TICK`) and the siege's length (`ENEMY_OUTPOST_SIEGE_TICKS`) are measured constants that the ignored tests assert. Their only RNG draws are the prologue's `DebugSpawnJittered` (two per unit) and the battle's acquisition and shooter ties; the walkthrough's own commands draw nothing and stay out of the battle, so both scenarios fight the same prologue. `enemy_outpost_walkthrough` is a first recording of a new scenario (`just rerecord enemy_outpost_walkthrough`), which needs no bump by itself; no other fixture was re-recorded. +M4b adds no hashed state and keeps `SIM_VERSION` 14. Its unit looks bumped `rules_version` 16 -> 17 (`data/visuals.ron` was in `rules_hash` then, although the sim never reads it; ADR 0003 took it out at `rules_version` 25, see [After M5b: ADR 0003](#after-m5b-adr-0003-visuals-out-of-rules_hash)), so every fixture was re-recorded with only its header changed and every `.hash` file byte-identical. `SimView::building_hp` is a read-only accessor, and `tech::research_quote_after` quotes research for a given set of researched techs with the same resolution `research_quote` uses (which now calls it), so a scripted scenario can price its research ahead of time. `sim::scenarios` gains `enemy_outpost` and `enemy_outpost_walkthrough`; their beat ticks and P0's grants are derived from the rules (`enemy_outpost_beats`, `outpost_grants`), so a data change moves them, and only the battle's outcome (`ENEMY_OUTPOST_CLEAR_TICK`) and the siege's length (`ENEMY_OUTPOST_SIEGE_TICKS`) are measured constants that the ignored tests assert. Their only RNG draws are the prologue's `DebugSpawnJittered` (two per unit) and the battle's acquisition and shooter ties; the walkthrough's own commands draw nothing and stay out of the battle, so both scenarios fight the same prologue. `enemy_outpost_walkthrough` is a first recording of a new scenario (`just rerecord enemy_outpost_walkthrough`), which needs no bump by itself; no other fixture was re-recorded. ## What M5a adds @@ -400,3 +400,7 @@ The waves slice (`rules_version` 22 -> 23, `SIM_VERSION` stays 17) adds the Stan The game slice changes no sim code, data or fixture. A plain launch builds `MatchSetup::vs_bot(.., "standard", "standard")` on a fresh wall-clock seed (or `--seed`), and `--spectate` builds `MatchSetup::bots` with the Standard bot in both seats; both name their bots in the header, so the game's replay verifies with the bots rebuilt from it. The sim does not step while the main menu is open (`match_running`), and the recorder starts right before the first stepped tick, so a menu quit writes nothing. `--spectate --fast-forward-to N` steps the sim to tick N before the window opens and the recorder starts after it; the replay still verifies from tick 0, because a spectated match has no human commands for the replay to miss and the bots are regenerated (M5b owner decision 8; `a_replay_recorded_after_a_fast_forward_verifies_from_tick_0`, and `just m5b-checks`, whose fast-forwarded overlay run's replay verifies to `m5_first_match.hash`). Local commands for a bot's slot are dropped before they reach the sim, so a click while spectating never enters the replay. At the M5b close the sim is at `SIM_VERSION` 17 and the rules at `rules_version` 24. M5b adds to the hashed state each player's `ai_memory` (`meta`) and to the header the `ai_seats`, and nothing else; the bots' plan facts, the build-order window, the worker targets, the threats and the Wain point are recomputed on every plan, while the chosen site (`BotMemory.site`) and a wave's stage point (`Wave.stage`, set at launch and on a retarget) live in that hashed memory; the only other bot state, the Standard bot's `MapAnalysis`, is a pure function of the map, the rules and the personality, so a restore or a rebuilt controller continues exactly. The bots draw nothing: their ties go to the lowest id or tile index, so the sim still draws only for exact acquisition and shooter ties, damage variance above 0 and `DebugSpawnJittered`, and a seed changes a bot match only through combat's tie draws (every seed of ROADMAP M5b line 1 ends in P0's Seat capture at tick 26012; the five final hashes differ, the seed being hashed in `meta`). `Command::DebugAdvanceAge` is appended. `data/ai/` is in `rules_hash`, so an edit there is a `rules_version` bump and `just rerecord`; a `crates/ai` change that can alter a decision is also a `SIM_VERSION` bump (owner decision 2), which `m5_first_match` enforces through the fixture policy. There are eleven golden fixtures: the ten of M5a, re-recorded at the bot plumbing slice with only `meta` changing their hashes and then three times (`rules_version` 22, 23 and 24) for their headers alone, and `m5_first_match`, the first with bot seats, which CI verifies in release on both operating systems, re-simulates through the game's `--headless-run` and compares in the hash-parity job. + +## After M5b: ADR 0003, visuals out of `rules_hash` + +The pre-stream art pass begins with [ADR 0003](adr/0003-visuals-outside-rules-hash.md) (`rules_version` 24 -> 25, `SIM_VERSION` stays 17): `Rules::visuals` is `#[serde(skip)]`, so `rules_hash` no longer covers `data/visuals.ron`, while `Rules::load` still parses and validates it, and `sim-cli data-check` still fails on a bad, missing or unknown entry (`rules::tests::rules_hash_ignores_visuals_ron` and `bad_visual_still_fails_to_load_naming_file_and_field`, and the visuals cases of `crates/sim-cli/tests/data_check.rs`). No value and no sim code changed: the eleven fixtures were re-recorded for their headers alone (10 bytes at offsets 8 to 17: the `rules_version` byte and 9 of the 10 bytes of the `rules_hash` varint at offsets 9 to 18, whose last byte, `0x01`, is the same in both; every other byte equal), no `.hash` file moved, and a per-tick `sim-cli hash-dump --every 1` of `move_500`, `combat_skirmish`, `capture_seat` and `m5_first_match` as recorded at 24, run by the pre-bump binary with its own `data/`, equals that of the re-recorded fixture run by the new binary, line for line. From here a look edit needs no bump: with a number in `data/visuals.ron` changed, `sim-cli verify` of a fixture still prints `OK`. The price is paid once: a replay recorded at `rules_version` 24 or earlier reports `RULES CHANGED` on this build and every later one, so a match recorded before the bump (the M5b owner match among them) is verified with the build that recorded it; no command prints a header's `rules_version`, but the `replay 0x…` hash in its `RULES CHANGED` line names that build, because `sim-cli data-check data` at the recording commit prints the same `rules_hash` (`0xb231ab753cb4197e` is `rules_version` 24, `e84af88`). Anything the sim or a bot reads must stay out of `visuals.ron`. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index cd5b591..560c8bd 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -29,13 +29,13 @@ freezes a decided match and draws the outcome overlay). Its good first issues are #44, #45 and #46. M5b, the first complete skirmish opponent and the first playable match, is -on PR #47 at `SIM_VERSION` 17 and `rules_version` 24, with eleven golden -fixtures. Its lines 1, 2 and 3 are ticked there with pasted output, -observed on the 4-vCPU Linux VM and, for line 1's last clause, in PR #47's -Actions run 40258854764 (`m5_first_match` verified on macOS and Linux to -`OK final_hash=0x3d2552033d51b4a2 ticks=26012`, empty parity diff). Line -1's "under 120 s on the dev Mac" is re-run by the owner on the Mac (owner -decision 8). M5b is done once the PR merges. +merged as PR #47 (`e84af88`) at `SIM_VERSION` 17 and `rules_version` 24, +with eleven golden fixtures. Its lines 1, 2 and 3 are ticked there with +pasted output, observed on the 4-vCPU Linux VM and, for line 1's last +clause, in PR #47's Actions run 40258854764 (`m5_first_match` verified on +macOS and Linux to `OK final_hash=0x3d2552033d51b4a2 ticks=26012`, empty +parity diff). Line 1's "under 120 s on the dev Mac" is re-run by the owner +on the Mac (owner decision 8). Its [owner] line 4 waits for the owner's own match; its automated proxy, `just m5b-checks`, passes and is pasted in `docs/PLAYTEST.md`, and goes into the PR description with the CI results. The owner answered the nine @@ -81,13 +81,22 @@ Next is the pre-stream art pass, before the first gameplay stream and before M6 (the owner's decision of 2026-10-21 under "Order of work" in the ROADMAP): CC0 models through `data/visuals.ron`, dressed terrain, a minimap and a dev-panel draw-call counter. The owner took every default of -its implementation plan on 2026-10-26. It starts with ADR 0003 as its own -small PR right after PR #47 merges: `docs/adr/0003-visuals-outside-rules-hash.md` -takes `data/visuals.ron` out of `rules_hash` (the sim never reads it; UI -strings are the precedent, and `data-check` still validates every entry), -with one `rules_version` bump (24 to 25) and a header-only re-record of -the eleven fixtures, no `.hash` change. The art slices follow in one PR -rebased on it, with no further bump: the packs (KayKit Medieval Hexagon +its implementation plan on 2026-10-26. It starts with ADR 0003, its own +small PR on top of the M5b merge (`e84af88`): +`docs/adr/0003-visuals-outside-rules-hash.md` (Accepted) takes +`data/visuals.ron` out of `rules_hash` (`Rules::visuals` is +`#[serde(skip)]`; the sim never reads it; UI strings are the precedent, and +`data-check` still validates every entry), with one `rules_version` bump +(24 to 25) and a header-only re-record of the eleven fixtures, no `.hash` +change. From 25 a look edit needs no bump. The timing trap: a replay +recorded on a build before the bump (the owner's M5b match, if it is played +on `e84af88`) prints `RULES CHANGED` on later builds and is verified with +the build that recorded it. No command prints a header's +`rules_version`; the `replay 0x…` hash in the `RULES CHANGED` line names +the build (`0xb231ab753cb4197e` is `rules_version` 24, `e84af88`; `sim-cli +data-check data` prints each commit's hash; `docs/PLAYTEST.md`, "Owner's +Mac checklist (M5b)"). The art slices follow +in one PR rebased on it, with no further bump: the packs (KayKit Medieval Hexagon and KayKit Adventurers, and the Inter font) vendored with their licence proof, buildings, sites and terrain, the eight unit kinds, the minimap, the draw-call counter and the close. Its first slice records the pass, its @@ -139,8 +148,8 @@ ticked with pasted command output in a merged PR. | Architecture and invariants | `docs/ARCHITECTURE.md`, `docs/DETERMINISM.md`, `docs/DATA_FORMAT.md` | Complete | | Per-system design | `docs/design/*.md` | Complete, with target numbers | | Dependency pins and policy | `docs/DEPENDENCIES.md`, `docs/adr/` | Complete | -| Simulation crates | `crates/sim`, `crates/rules`, `crates/ai`, `crates/sim-cli` | M3a merged (PR #26: economy, Towns, territory, construction, gathering, training); M4a merged (PR #35: tech, ages, combat, the counter triangle, `bench --combat`, the `combat_skirmish` golden); M4b merged (PR #38: the `enemy_outpost` scenarios, their golden and `SimView::building_hp`); M5a merged (PR #43: attrition, supply, annexation, the outcome, `bench --combat --harrying`, the `capture_seat` and `stalemate_45min` goldens); M5b on PR #47, done when line 1's CI clause is pasted and it merges (`SIM_VERSION` 17, `rules_version` 24: the bot seats and hashed bot memory, `data/ai/`, the Standard bot in `crates/ai`, `play-bots`, `bench --bots`, the `m5_first_match` golden) | -| Presentation crate | `crates/game` | M2 merged: camera, selection and orders, replay recorder, headless run, macOS menu; M3b merged (PR #31): buildings, terrain, the FieldExt territory shader, resource bar and messages, command card, placement ghost, context orders; M4b merged (PR #38; line 2 waits for the Mac): unit looks, health bars, attack orders, research panel, age strip, notices, the walkthrough director and the fight window; M5a merged (PR #43): the freeze at the outcome, the outcome overlay with the final hash, the attrition indicator, the supply ring, the annexation timer and readout; M5b's game half on PR #47 (line 4 waits for the owner's match): the main menu and New Game against the Standard bot, `--skip-menu`, `--spectate` and `--fast-forward-to`, the lazy recorder | +| Simulation crates | `crates/sim`, `crates/rules`, `crates/ai`, `crates/sim-cli` | M3a merged (PR #26: economy, Towns, territory, construction, gathering, training); M4a merged (PR #35: tech, ages, combat, the counter triangle, `bench --combat`, the `combat_skirmish` golden); M4b merged (PR #38: the `enemy_outpost` scenarios, their golden and `SimView::building_hp`); M5a merged (PR #43: attrition, supply, annexation, the outcome, `bench --combat --harrying`, the `capture_seat` and `stalemate_45min` goldens); M5b merged (PR #47, `SIM_VERSION` 17, `rules_version` 24: the bot seats and hashed bot memory, `data/ai/`, the Standard bot in `crates/ai`, `play-bots`, `bench --bots`, the `m5_first_match` golden) | +| Presentation crate | `crates/game` | M2 merged: camera, selection and orders, replay recorder, headless run, macOS menu; M3b merged (PR #31): buildings, terrain, the FieldExt territory shader, resource bar and messages, command card, placement ghost, context orders; M4b merged (PR #38; line 2 waits for the Mac): unit looks, health bars, attack orders, research panel, age strip, notices, the walkthrough director and the fight window; M5a merged (PR #43): the freeze at the outcome, the outcome overlay with the final hash, the attrition indicator, the supply ring, the annexation timer and readout; M5b's game half merged (PR #47; line 4 waits for the owner's match): the main menu and New Game against the Standard bot, `--skip-menu`, `--spectate` and `--fast-forward-to`, the lazy recorder | | CI and release automation | `.github/workflows/`, `scripts/`, `justfile` | In use since M0: every PR runs check, headless, game-linux, deny and hash-parity | ## Read these first, in this order diff --git a/docs/PLAYTEST.md b/docs/PLAYTEST.md index c82f612..896920c 100644 --- a/docs/PLAYTEST.md +++ b/docs/PLAYTEST.md @@ -136,6 +136,7 @@ cargo run --release -p game # the time, the final hash and the replay's name on the overlay; stdout has the # `replay:` and `outcome:` lines). # 3. Verify that replay: it must print OK final_hash= ticks=. +# Verify it from the same checkout that recorded it (timing trap below). replay=$(ls -t ~/Library/Application\ Support/com.tonianev.Eonmark/replays/*.eonreplay | head -n 1) cargo run -p sim-cli --release -- verify "$replay" # 4. ROADMAP M5b line 1 on the Mac (owner decision 8): paste the stderr summary and wall_s. @@ -144,7 +145,7 @@ cargo run -p sim-cli --release -- play-bots --seeds 1..5 --a standard --b standa just m5b-checks ``` -What to look for: 1. the menu over the dimmed map, New Game and Quit; Quit exits 0 and writes no replay. 2. The bot's first attack wave reaches you at about minute 11 (tick 13400 against an idle opponent); the overlay reads "Victory" or "Defeat" with the reason, the time, "Final hash ..." and "Replay ". 3. One `OK` line with the overlay's hash and tick. 4. `play-bots: games=5 ... a=... b=... draw=0 none=0 panic=0` and `wall_s` under 120 s. 5. `m5b_checks: ok`. Then note in the PR whether the bot felt winnable and losable (owner decision 4), and do the open M3b, M4a, M4b and M5a Mac rows below (M4b owner decision 3). +What to look for: 1. the menu over the dimmed map, New Game and Quit; Quit exits 0 and writes no replay. 2. The bot's first attack wave reaches you at about minute 11 (tick 13400 against an idle opponent); the overlay reads "Victory" or "Defeat" with the reason, the time, "Final hash ..." and "Replay ". 3. One `OK` line with the overlay's hash and tick. Timing trap ([ADR 0003](adr/0003-visuals-outside-rules-hash.md)): `rules_version` 25 took `data/visuals.ron` out of `rules_hash`, so a match recorded on a build before that bump (`rules_version` 24, `main` at the M5b merge `e84af88`) prints `RULES CHANGED since recording (replay 0xb231ab753cb4197e, loaded ...)` on any later build. That is not a failure of line 4: verify the replay with the build that recorded it (for example `git worktree add ../eonmark-m5b e84af88` and run the same `verify` there). No command prints the header's `rules_version`; the `replay 0x...` value in that line is the recording build's `rules_hash`, which `cargo run -p sim-cli -- data-check data` prints at each commit (`0xb231ab753cb4197e` at 24). 4. `play-bots: games=5 ... a=... b=... draw=0 none=0 panic=0` and `wall_s` under 120 s. 5. `m5b_checks: ok`. Then note in the PR whether the bot felt winnable and losable (owner decision 4), and do the open M3b, M4a, M4b and M5a Mac rows below (M4b owner decision 3). ## Owner's Mac checklist (M4b) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad6f9a3..b28d862 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -266,6 +266,7 @@ The player can research, advance ages, and fight with visible health and distinc ### Notes - Owner decisions, 2026-10-22: the owner answered the eleven open questions of the M4b implementation plan. (1) Battle design: an automatic prologue in `enemy_outpost`, where P1 with Arms I-III wipes the 200-unit P0 vanguard on P1's side of the river, which frees pop; then the player builds up and destroys the Watchtower, so one scenario covers both acceptance lines. (2) Line 2 gate: fight fps >= 60 and fight p95 <= 16.7 ms with the window focused, plus a `--max-fps 1000` headroom report. (3) Gate before M4b: M4b starts once M4a is done; the owner's Mac checks for M3b (60 fps, the border screenshot review), M4a and M4b stay open on this roadmap for one later Mac session (accepted risk: Mac-only problems surface late). (4) A and S keys: no change in M4b (both pan and issue orders while units are selected); revisited at the owner's controls playtest. (5) `visuals.ron` stays in `rules_hash` for M4b (one `rules_version` bump); an ADR on taking it out may come before the art pass. (6) Health bars show only when the target is damaged or selected, with a team-colour fill on a `#3A3A3F` track and no green-to-red. (7) The Advance button is always visible, greyed with "n / m techs researched" (ART_STYLE "greyed, not hidden"). (8) Research panel: the next level of each line on the Scriptorium card, the blocker on the button, prerequisites in the tooltip. (9) The infinite-queue toggle is not in M4b and becomes a help-wanted issue (it needs a sim Command and a hash change; filed as #42, also labelled needs-design); per-slot queue Cancel is a help-wanted issue too (filed as #41). (10) Outpost content: a lone P1 Watchtower at (58,64). (11) Looks: single-shape Body silhouettes (cone, block, post, lying pill, ball, low box), signed off on the 40 m screenshot. +- Owner decision, 2026-10-26 (question 1 of the pre-stream art pass plan; it settles what decision 5 above left open): `data/visuals.ron` leaves `rules_hash` ([ADR 0003](adr/0003-visuals-outside-rules-hash.md)) as its own small PR right after M5b, with one `rules_version` bump (24 to 25) and a header-only re-record of the eleven fixtures, no `.hash` change; `Rules::load` and `data-check` still validate every entry. From `rules_version` 25 a look edit needs no bump and no re-record. A replay recorded before the bump, such as the M5b owner match, is verified with the build that recorded it; no command prints a header's `rules_version`, but the `replay 0x…` hash in its `RULES CHANGED` line names that build, because `sim-cli data-check data` at the recording commit prints the same `rules_hash` (`0xb231ab753cb4197e` is `rules_version` 24, `e84af88`). ### Acceptance diff --git a/docs/adr/0003-visuals-outside-rules-hash.md b/docs/adr/0003-visuals-outside-rules-hash.md new file mode 100644 index 0000000..d854228 --- /dev/null +++ b/docs/adr/0003-visuals-outside-rules-hash.md @@ -0,0 +1,37 @@ +# 0003: `data/visuals.ron` outside `rules_hash` + +Status: Accepted +Date: 2026-10-26 + +This record takes the look data, `data/visuals.ron`, out of the rules hash that every replay header carries, while keeping it loaded and validated with the rest of the rules. It is the owner's decision of 2026-10-26 (question 1 of the pre-stream art pass plan, the plan's default) and lands as its own small pull request after M5b, before the art pass itself. + +## Context + +- **The hash and the file.** `rules_hash` is xxh3 over postcard of the whole loaded `Rules` struct (`Rules::rules_hash`, `crates/rules/src/lib.rs`). Every replay header stores it, and `verify` reports `RULES CHANGED since recording` when the loaded rules hash differently. `Rules::load` attaches `data/visuals.ron` as `Rules::visuals` (`#[serde(skip_deserializing)]` until now), so the file has been in the hash since M2 (`rules_version` 4). +- **M4b owner decision 5** (ROADMAP, M4b Notes, 2026-10-22): "`visuals.ron` stays in `rules_hash` for M4b (one `rules_version` bump); an ADR on taking it out may come before the art pass." M4b's looks cost one bump (16 to 17) and a header-only re-record of every fixture. +- **The simulation never reads it.** `crates/rules/src/visuals.rs` says so in its module doc; only the game's presenter (`crates/game/src/present.rs`) maps a `Visual` to a mesh. No sim, bot or replay code reads `Rules::visuals`, so a look edit can change no state hash: M4b's bump re-recorded every fixture with every `.hash` file byte-identical. +- **Precedent.** The UI strings (`data/rules/strings/en.ron`, M3b) are presentation data outside `Rules` and outside the hash; `sim-cli data-check` validates them anyway (`crates/sim-cli/src/main.rs`, `data_check`), and `docs/DATA_FORMAT.md` documents that editing them needs no bump. +- **The art pass's churn.** The art pass replaces the primitive looks with CC0 glTF models through `visuals.ron`. Its plan edits the file in three slices and again at every 40 m readability review: about four to six bumps, each re-recording the eleven fixture headers for no state change, and every later model swap (the M7 good first issue that replaces the Croft with a KayKit model "via `visuals.ron`") would cost one more. ROADMAP M7's model-swap walkthrough would read "one RON line plus a bump plus `just rerecord`" instead of one RON line. + +## Decision + +1. `Rules::visuals` becomes `#[serde(skip)]` instead of `#[serde(skip_deserializing)]`. `Visuals` already derives `Default`, and `Rules` is serialised only by `rules_hash`, so the hash stops covering `data/visuals.ron` and nothing else changes. +2. `Rules::load` still parses `data/visuals.ron` and runs `Visuals::validate` against the unit kinds, so `sim-cli data-check` still fails on a bad, missing or unknown entry and on a missing file. Tests hold both halves: `rules::tests::rules_hash_ignores_visuals_ron` (an in-memory change and an edited copy of the file keep the hash, and the edit is loaded) and `bad_visual_still_fails_to_load_naming_file_and_field`, and in `crates/sim-cli/tests/data_check.rs` a kind without a visual, an out-of-range look and a missing `visuals.ron` each exit 1 naming the file, while a valid look edit prints the repository's own `OK` line, `rules_hash` included. +3. `replay::verify`, the replay format and `SIM_VERSION` are unchanged. One `rules_version` bump (24 to 25) re-records the eleven fixtures for their headers; after it, a `visuals.ron` edit never needs a bump or a re-record. +4. Nothing the simulation or a bot reads may live in `visuals.ron`. If a look ever needs a number the sim uses (a footprint, a range), that number goes in a hashed file under `data/rules/` or `data/ai/`. + +## Alternatives considered + +| Alternative | Why not | +|---|---| +| Keep `visuals.ron` in the hash and bump on every look edit | Four to six bumps in the art pass alone and one per later model swap, each re-recording every fixture header with no state change; old replays report `RULES CHANGED` after every one of them. The bumps would buy nothing, since the sim cannot diverge on a look. | +| A separate `visuals_hash` field in the replay header | A header layout change means a `SIM_VERSION` bump and a re-record of every fixture, and nothing would ever compare the field: `verify` does not need the looks to re-simulate. | +| Move the look data out of `Rules` into a game-side loader, as the UI strings are | Larger change for the same effect: the game reads looks through `Rules::visual`, the validator checks them against `Rules::units`, and `data-check` would need a second load path. `#[serde(skip)]` keeps one load and one validation. | + +## Consequences + +- One `rules_version` bump now (24 to 25, "data/visuals.ron leaves rules_hash (ADR 0003); no sim change") with `just rerecord` in the same commit. Each of the eleven fixtures changes in its header's `rules_version` and `rules_hash` only (10 bytes); every `.hash` file stays byte-identical, and a per-tick `hash-dump` of `move_500`, `combat_skirmish`, `capture_seat` and `m5_first_match` is identical before and after. +- Replays recorded before the bump report `RULES CHANGED` on this build and every later one. A match recorded on an earlier build, the M5b owner match included, is verified with the build that recorded it. No command prints a header's `rules_version` (24 or lower for such a replay); the `replay 0x…` hash in the `RULES CHANGED` line names the build, since `sim-cli data-check data` at the recording commit prints the same `rules_hash` (`0xb231ab753cb4197e` is `rules_version` 24, `e84af88`). +- A replay no longer records which looks were loaded. `verify` never needed that, and the game's looks do not affect any hash. +- From `rules_version` 25 a look edit is one RON line: `sim-cli verify` of every fixture still prints `OK` and `data-check` still validates the entry. ROADMAP M7's swap walkthrough and the art pass need no further bump for looks. +- Docs that said the file is hashed are updated in the same pull request: `docs/DETERMINISM.md`, `docs/DATA_FORMAT.md`, `data/README.md`, `data/rules/README.md`, `docs/ART_STYLE.md`, `docs/ARCHITECTURE.md`, `CONTRIBUTING.md`, the module doc of `crates/rules/src/visuals.rs`, the comment at the top of `data/visuals.ron` and the `rules_hash` doc in `crates/rules/src/lib.rs`. diff --git a/docs/adr/README.md b/docs/adr/README.md index adfb504..0bbdfb5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,6 +8,7 @@ This directory holds Eonmark's architecture decision records. An ADR is a short, |---|---|---|---| | [0001](0001-license.md) | Licensing of code, data and assets | Accepted | 2026-10-05 | | [0002](0002-bevy-0-20-migration.md) | Migration to Bevy 0.20 | Proposed | | +| [0003](0003-visuals-outside-rules-hash.md) | `data/visuals.ron` outside `rules_hash` | Accepted | 2026-10-26 | ## Statuses diff --git a/docs/design/README.md b/docs/design/README.md index 4e5bdb0..713e5ef 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -32,7 +32,7 @@ This directory holds one document per game system. Each states Eonmark's own tar ## Proposing a balance change -Balance changes are data changes. Open an issue with the `kind:balance` form: which RON file, what number, and the `sim-cli play-bots` result. A data PR bumps `rules_version`, re-records the golden fixtures with `just rerecord` (any RON edit changes `rules_hash`), needs `sim-cli data-check` and `cargo test -p sim` to pass and, when the change touches pacing, a play-bots table. See `../../CONTRIBUTING.md`. +Balance changes are data changes. Open an issue with the `kind:balance` form: which RON file, what number, and the `sim-cli play-bots` result. A data PR bumps `rules_version`, re-records the golden fixtures with `just rerecord` (any edit to a hashed RON file changes `rules_hash`; `data/visuals.ron` and the UI strings are not hashed), needs `sim-cli data-check` and `cargo test -p sim` to pass and, when the change touches pacing, a play-bots table. See `../../CONTRIBUTING.md`. ```bash cargo run -p sim-cli -- data-check data/ diff --git a/docs/design/ai.md b/docs/design/ai.md index 0f75580..a712848 100644 --- a/docs/design/ai.md +++ b/docs/design/ai.md @@ -169,7 +169,7 @@ M5b ships rules 1 to 6 above as the Standard bot, `ai::standard::StandardBot` (` | Defend scope and recall | Owner decision 7, which replaces rule 6's "the Seat only, every unit recalled". A threat is an enemy non-civilian unit on own land within `defend_radius_tiles` (16) of the centre of any own complete Town (around a contested Town, whose land went neutral, any enemy military in the radius); the engaged Town is the one with the most threatening pop, the Seat first. The home guard attack-moves to the threatening unit nearest each guard, so a parked raider is killed rather than watched, and enemy siege gets an explicit `Attack` from up to 4 guards. The latch is released after `defend_release_ds` (15 s) without a threat, and the guard attack-moves back to the rally. A defence blocks launches for at most `defend_max_block_ds` (60 s); past that, while a launch is due, the guard shrinks to the units nearest the threat until their pop reaches `defend_guard_pop_ratio` (2) times the threat's, and the wave launches with the rest. A staging wave is recalled only while the defence still blocks launches, any wave when a threatened Town is contested or at or below `recall_wave_town_hp_permille` (500) of its hit points, and none during the all-in. | | CSV additions and `first_attack_*` | The seven columns above come first and unchanged; `winner` adds `draw` and `panic`, and the diagnostics follow (the table under "As built (M5b)"). `first_attack_tick` is the tick, `Sim::tick()` after the step, at which one of A's units first has the `AttackMove` intent with its goal on B's land; for the Standard bot that is a wave's assault order, and a wave that ends before its assault does not count. `first_attack_units` counts A's non-civilian units committed to B's land at that tick (order goal, post goal, attack target or position on it), Supply Wains included, and `first_attack_wains` those of them that supply. | | Open question 1 | Answered by owner decision 1: the timers and latches are hashed sim state (`Player::ai_memory`) and the build-order cursor is re-derived every plan, so the bot needs no `snapshot()`/`restore()` of its own; see "Where the bot runs" and [../DETERMINISM.md](../DETERMINISM.md), "What M5b adds". | -| Versioning | Owner decision 2: from `SIM_VERSION` 17 a `crates/ai` change that can alter a decision bumps `SIM_VERSION`, and because `m5_first_match` (the Standard bot in both seats) then changes its hash, the fixture policy forces the `rules_version` bump and a re-record in the same commit. A `data/ai/` edit is a `rules_version` bump and `just rerecord`, as for any RON file (CONTRIBUTING.md, "The data-first path"). | +| Versioning | Owner decision 2: from `SIM_VERSION` 17 a `crates/ai` change that can alter a decision bumps `SIM_VERSION`, and because `m5_first_match` (the Standard bot in both seats) then changes its hash, the fixture policy forces the `rules_version` bump and a re-record in the same commit. A `data/ai/` edit is a `rules_version` bump and `just rerecord`, as for any hashed RON file (CONTRIBUTING.md, "The data-first path"). | | Randomness | None. The bot never draws: ties go to the lowest `UnitId`, `BuildingId`, `UnitKindId` or tile index. Standard against Standard is therefore close to one trajectory whatever the seed: every seed of acceptance 1 ends in P0's Seat capture at tick 26012; a seed changes only combat's tie draws, and the final hashes differ because the seed itself is hashed in `meta`. | M6 notes. Each M6 item has a seam in this code: