Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c44c274
M6 slice 1: difficulty levels, disclosed bot bonus, ai_noise and Matc…
claude Oct 28, 2026
e245576
M6 slice 1: play-bots scripts, seed-7 CI check and difficulty docs
claude Oct 29, 2026
536807f
M6 slice 2 groundwork: influence maps and counter-weighted mix modules
claude Oct 29, 2026
97326e6
M6 slice 1: keep the defend-vs-saving test's M5b timeline; record tim…
claude Oct 29, 2026
393a37e
M6 slice 2: bot behaviours and the rush, boom and tower personalities
claude Oct 29, 2026
72e63f4
M6 slice 2 tuning: more Muster Halls for Standard, Easy's Yeomen hand…
claude Oct 29, 2026
31507da
M6 slice 2: fun-gate CI job, bench gate and the tuning record
claude Oct 29, 2026
97acc48
Route the two remaining enemy-unit reads through the fog seam
claude Oct 29, 2026
282650a
Make the weak-target retreat test reach the Town-HP clause
claude Oct 29, 2026
ff84c73
Test Easy's disclosed Yeomen handicap
claude Oct 29, 2026
7fd587c
Pin what counts as a wiped wave
claude Oct 29, 2026
3cd4c00
Test seat_last's uncontested condition
claude Oct 29, 2026
7150e0e
Test match-log on a replay with human commands
claude Oct 29, 2026
2e2c9b8
Gate bench --bots on the influence path once any wave goes out
claude Oct 29, 2026
43bd3dd
Make the fun-gate steps report every check; require Easy's majority i…
claude Oct 29, 2026
2f31f5a
Correct the M6 docs the review found stale or wrong
claude Oct 29, 2026
01ab789
M6 retune groundwork: difficulty pressure knobs and per-seat border n…
claude Oct 29, 2026
62611ff
Retune M6 difficulty behaviour so the levels break stalemates
claude Oct 30, 2026
316e443
Document the M6 retune and keep bench --bots exercising the gated levels
claude Oct 30, 2026
ed33a36
Test the all-in's gate exemptions, the lure's first plan and Hold rei…
claude Oct 30, 2026
5ecc183
Record M6 owner decisions 12 to 14 in the ROADMAP notes
claude Oct 30, 2026
58773a4
Let Standard retreat again as its personality does
claude Oct 30, 2026
de5b0cd
Document the M6 retune review: why the flip flips, deviations for the…
claude Oct 30, 2026
9808600
Tick M6 acceptance lines 1 to 4 on PR #56
claude Oct 30, 2026
d75512e
Unquote prose in the M6 line 3 observation
claude Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,28 @@ jobs:
cargo run -p sim-cli --locked --release -- verify crates/sim/tests/fixtures/m5_first_match.eonreplay | grep '^OK' | tee hash-macos-m5.txt
test -s hash-macos-m5.txt

- name: M6 acceptance 3 (two play-bots --seeds 7..7 runs print identical final hashes)
# ROADMAP M6 line 3, the literal command twice on macOS (the headless
# job runs the same on Linux): the CSVs hold no wall time, so they
# must be byte-identical; the final hash goes to hash-macos-seed7.txt
# and hash-parity diffs it against Linux's. A second pair, Hard
# against Easy with the rush and tower personalities, puts the
# difficulty paths (the disclosed bonus of step 6d', the resolved
# knobs) and slice 2's behaviours (raids, the deadline raid, the
# Wain rule, reactive towers) under the same checks; it is not part
# of the literal line, so --allow-undecided keeps a draw from
# failing it.
run: |
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 > s7a.csv
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 > s7b.csv
diff s7a.csv s7b.csv
awk -F, 'NR==2{print "seed=7 standard/standard final_hash=" $5}' s7a.csv | tee hash-macos-seed7.txt
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 --a hard --b easy --personality-a rush --personality-b tower --allow-undecided > s7c.csv
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 --a hard --b easy --personality-a rush --personality-b tower --allow-undecided > s7d.csv
diff s7c.csv s7d.csv
awk -F, 'NR==2{print "seed=7 hard-rush/easy-tower final_hash=" $5}' s7c.csv | tee -a hash-macos-seed7.txt
test "$(grep -c 'final_hash=0x[0-9a-f]\{16\}$' hash-macos-seed7.txt)" = 2

- uses: actions/upload-artifact@v7
with:
name: hash-macos
Expand All @@ -138,6 +160,7 @@ jobs:
hash-macos-capture-seat.txt
hash-macos-stalemate.txt
hash-macos-m5.txt
hash-macos-seed7.txt
if-no-files-found: error

headless:
Expand Down Expand Up @@ -232,6 +255,20 @@ jobs:
cargo run -p sim-cli --locked --release -- play-bots --seeds 1..5 --a passive --b standard --max-ticks 36000 | tee m5b-wave-b.csv
scripts/check_first_wave.sh --side b --decisive m5b-wave-b.csv

- name: M6 acceptance 3 (two play-bots --seeds 7..7 runs print identical final hashes)
# ROADMAP M6 line 3 on Linux, the same commands as the macOS check
# job; hash-parity diffs the two hash-<os>-seed7.txt files.
run: |
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 > s7a.csv
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 > s7b.csv
diff s7a.csv s7b.csv
awk -F, 'NR==2{print "seed=7 standard/standard final_hash=" $5}' s7a.csv | tee hash-linux-seed7.txt
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 --a hard --b easy --personality-a rush --personality-b tower --allow-undecided > s7c.csv
cargo run -p sim-cli --locked --release -- play-bots --seeds 7..7 --a hard --b easy --personality-a rush --personality-b tower --allow-undecided > s7d.csv
diff s7c.csv s7d.csv
awk -F, 'NR==2{print "seed=7 hard-rush/easy-tower final_hash=" $5}' s7c.csv | tee -a hash-linux-seed7.txt
test "$(grep -c 'final_hash=0x[0-9a-f]\{16\}$' hash-linux-seed7.txt)" = 2

- name: ignored M5b bot tests (long games)
# The m5b_ filter leaves out golden_m5_first_match, which the
# release verify above covers. `ai` is opt-level 3 in dev.
Expand Down Expand Up @@ -291,8 +328,102 @@ jobs:
hash-linux-capture-seat.txt
hash-linux-stalemate.txt
hash-linux-m5.txt
hash-linux-seed7.txt
if-no-files-found: error

fun-gate:
name: fun-gate (ubuntu-24.04)
runs-on: ubuntu-24.04
# ROADMAP M6 acceptance 1, 2 and 4 with the literal commands (the M6
# plan's "fun-gate" job, docs/design/ai.md "The fun gate in CI"), in
# parallel with headless so that job keeps its time. Measured on the
# 4-vCPU VM: about 8-19 minutes once sim-cli is cached; if the job
# passes 25 minutes, the flip moves to release-check.yml. Every check
# step runs even when an earlier one failed (`!cancelled()`), and
# inside a step every command runs too (`|| rc=1`, then `exit "$rc"`:
# the default shell is `bash -eo pipefail`, which would otherwise stop
# at the first failing command and hide the rest), so one run reports
# every line; the job still fails if any step does.
timeout-minutes: 40
steps:
- uses: actions/checkout@v7

- name: Install Rust toolchain (rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
cache-shared-key: fun-gate
cache-on-failure: true
rustflags: ""
build-warnings: warn

- name: build sim-cli (release)
run: cargo build -p sim-cli --locked --release

- name: M6 acceptance 1 (20 personality games, a winner in all)
if: ${{ !cancelled() }}
# The literal command; scripts/fun_gate.sh checks every clause by
# the M6 owner decision 1 readings (a winner is a or b; decisive is
# seat_captured before tick 48000; past 24000 is tick > 24000).
# The line requires exit 0, so play-bots' exit fails the step, but
# only after the summary and fun_gate.sh's clauses are printed.
run: |
start=$(date +%s)
rc=0
target/release/sim-cli play-bots --seeds 1..20 --a standard --b standard --personality-a rush,boom,tower --personality-b rush,boom,tower --max-ticks 72000 --jobs 5 > l1.csv 2> l1.err || { echo "play-bots exit=$?"; rc=1; }
tail -n 1 l1.err
scripts/fun_gate.sh l1.csv l1.err || rc=1
echo "elapsed_s=$(( $(date +%s) - start ))"
exit "$rc"

- name: M6 acceptance 2 (Standard beats Easy, Hard beats Standard)
if: ${{ !cancelled() }}
# The literal commands at --jobs 4 and with --allow-undecided: the
# CSV is the same at any --jobs and with or without the flag, which
# only stops a draw from making play-bots exit 1 (the line counts
# wins, not draws; a panic or a usage error still exits non-zero
# and fails the step). Only `winner == b` counts; check_ordering.sh
# also prints the cap games that ended untouched. Both pairings run
# and report whatever the first one gave.
run: |
start=$(date +%s)
rc=0
target/release/sim-cli play-bots --a easy --b standard --seeds 1..20 --jobs 4 --allow-undecided > e_s.csv 2> e_s.err || { echo "play-bots exit=$?"; rc=1; }
tail -n 1 e_s.err
scripts/check_ordering.sh e_s.csv b 15 || rc=1
target/release/sim-cli play-bots --a standard --b hard --seeds 1..20 --jobs 4 --allow-undecided > s_h.csv 2> s_h.err || { echo "play-bots exit=$?"; rc=1; }
tail -n 1 s_h.err
scripts/check_ordering.sh s_h.csv b 15 || rc=1
echo "elapsed_s=$(( $(date +%s) - start ))"
exit "$rc"

- name: M6 acceptance 4, first clause (Hard attacks the borders before minute 12)
if: ${{ !cancelled() }}
# Hard against Passive in seeds 1..3, as the New Game seat (b), with
# every Random style, and from seat a; check_attack_before.sh also
# requires an armed unit on the enemy's land before tick 14400.
run: |
rc=0
target/release/sim-cli play-bots --seeds 1..3 --a passive --b hard --max-ticks 14400 --allow-undecided > h.csv || rc=1
scripts/check_attack_before.sh --side b --before 14400 h.csv || rc=1
target/release/sim-cli play-bots --seeds 1..3 --a passive --b hard --personality-b rush,boom,tower --max-ticks 14400 --allow-undecided > hp.csv || rc=1
scripts/check_attack_before.sh --side b --before 14400 hp.csv || rc=1
target/release/sim-cli play-bots --seeds 1..3 --a hard --b passive --max-ticks 14400 --allow-undecided > ha.csv || rc=1
scripts/check_attack_before.sh --side a --before 14400 ha.csv || rc=1
exit "$rc"

- name: M6 acceptance 4, second clause (hard.ron income_interval_ds 200 -> 300 flips Hard vs Easy)
if: ${{ !cancelled() }}
run: |
start=$(date +%s)
rc=0
scripts/check_difficulty_flip.sh || rc=1
echo "elapsed_s=$(( $(date +%s) - start ))"
exit "$rc"

- name: ignored M6 bot tests (long games)
if: ${{ !cancelled() }}
run: cargo test -p ai --locked -- --ignored m6_

game-linux:
name: game compiles on Linux
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -452,3 +583,14 @@ jobs:
test -s hashes/macos/hash-macos-m5.txt
test -s hashes/linux/hash-linux-m5.txt
diff hashes/macos/hash-macos-m5.txt hashes/linux/hash-linux-m5.txt

- name: compare M6 acceptance 3 (seed 7, Standard against Standard and Hard against Easy)
# ROADMAP M6 line 3: both jobs ran `play-bots --seeds 7..7` twice
# with identical CSVs; their final-hash lines must also agree across
# the two operating systems.
run: |
echo "macOS:"; cat hashes/macos/hash-macos-seed7.txt
echo "Linux:"; cat hashes/linux/hash-linux-seed7.txt
test -s hashes/macos/hash-macos-seed7.txt
test -s hashes/linux/hash-linux-seed7.txt
diff hashes/macos/hash-macos-seed7.txt hashes/linux/hash-linux-seed7.txt
38 changes: 36 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,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, and since the art pass it ends with the `visuals.ron` counts and the number of glTF files its Scenes name (`visuals=units:8 buildings:8 terrain:<n> models=<n>`; each file must exist under `assets/`, `--no-assets` skips that check), 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 visuals=units:8 buildings:8 terrain:3 models=36` (before the bump it prints `rules_version=25`; `models` counts the distinct glTF files, 36 since art slice 4). 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`, and from M6 `difficulties`), which is in `rules_hash` too, and since the art pass it ends with the `visuals.ron` counts and the number of glTF files its Scenes name (`visuals=units:8 buildings:8 terrain:<n> models=<n>`; each file must exist under `assets/`, `--no-assets` skips that check), so with the repository at `rules_version` 30 and the bump to 31 of step 2, step 3 now prints a line of the form `OK rules_version=31 rules_hash=0x<16 hex> resources=4 units=8 buildings=8 ages=3 techs=12 side_techs=2 factions=1 personalities=4 build_orders=4 difficulties=3 maps=plains_1v1 strings=en visuals=units:8 buildings:8 terrain:3 models=36` (before the bump it prints `rules_version=30`; `models` counts the distinct glTF files, 36 since art slice 4). An edit under `data/ai/` takes the same path: bump `rules_version`, `just rerecord`, run the tests.

### A build order or personality (`data/ai/`)

Expand Down Expand Up @@ -164,6 +164,40 @@ check_first_wave: 5/5 rows pass (side a)

`m5_first_match` moved (`0x3d2552033d51b4a2` at tick 26012 to `0x3de3fdc31c07b2ad` at tick 27636), as it must when the Standard bot's build order changes; `move_500` and `capture_seat` kept their hashes (`0x02e11c8e91350d52`, `0xbd9f9df3f98fcc23`), since no bot plays in them.

### A difficulty (`data/ai/difficulty/`)

A difficulty level (M6) is one file `data/ai/difficulty/<id>.ron` whose `id` is the file stem (`passive` is reserved, and `standard` must exist since every tool defaults to it). It holds the disclosed bonus the sim pays a bot seat at that level (the same bundle every `income_interval_ds` from `income_from_ds`; step 6d') the wave knobs `rules::ai::resolve` applies to the seat's personality, and (since the M6 retune) the siege `pressure` the bot reads and the `extra_research` appended to its build order; the field reference is [data/ai/README.md](data/ai/README.md). The id is what the replay header's `AiSeat.bot`, `play-bots --a/--b` and the game's `--difficulty` name. The path is the data-only one, with the difficulty checks of ROADMAP M6 lines 2 and 4 as the comparison:

```bash
# 1. Edit the level, for example Hard's interval in data/ai/difficulty/hard.ron
# (income_interval_ds: 200 -> 300), or copy a file to add a level.
# 2. Bump rules_version in data/rules/rules.ron with its one-line reason.
# 3. Validate (the bundle names resource ids, 0 to 500 each; the interval is 0
# or 50 to 72000 ds; a first_raid has min_armed <= max_armed and
# prep_ds < by_ds).
cargo run -p sim-cli -- data-check data
# 4. Re-record: a .hash changes only where a seated bot's level changed
# (today m5_first_match, two Standard seats at the standard level).
just rerecord
# 5. Run the bot's tests and the sim's.
cargo test -p ai && cargo test -p sim
# 6. Check the ordering (Standard beats Easy, Hard beats Standard) and the
# flip, and read each side's bonus_* and income_* columns: the bonus share
# is bonus_x / (bonus_x + income_x) (income_x excludes the bonus), pooled
# over the run, at most 200 per mille for Standard over the Standard
# mirror (the run the ceiling is defined on) and 300 for Hard against
# Standard; report the largest single seat too (M6 owner decision 14
# reads the ceiling pooled: docs/design/ai.md).
cargo run -p sim-cli --release -- play-bots --a standard --b standard --seeds 1..20 --jobs 4 > s_s.csv
cargo run -p sim-cli --release -- play-bots --a easy --b standard --seeds 1..20 --jobs 4 > e_s.csv
scripts/check_ordering.sh e_s.csv b 15
cargo run -p sim-cli --release -- play-bots --a standard --b hard --seeds 1..20 --jobs 4 > s_h.csv
scripts/check_ordering.sh s_h.csv b 15
scripts/check_difficulty_flip.sh
```

The bonus is disclosed, so a change to it also changes the New Game caption and the game-over line (`data/rules/strings/en.ron`, outside `rules_hash`). A change that needs a new field is a Rust change in `crates/rules/src/ai.rs`, and one that changes what the sim pays (step 6d') or how the bot reads a level is also a `SIM_VERSION` bump.

### Adding or swapping a model (`data/visuals.ron`, `assets/`)

A look is presentation, outside `rules_hash` ([ADR 0003](docs/adr/0003-visuals-outside-rules-hash.md)): changing one needs no `rules_version` bump, no re-record and no Rust. The steps:
Expand Down Expand Up @@ -222,7 +256,7 @@ The full rules and the reasoning are in [docs/DETERMINISM.md](docs/DETERMINISM.m
2. No `f32`, `f64`, `HashMap`, `HashSet`, `Instant` or `SystemTime`, and no `rand::random` or `rand::thread_rng`, anywhere in `crates/sim`, `crates/rules` or `crates/ai`. Each crate has a `clippy.toml` that bans them, and CI runs clippy with `-D warnings`.
3. No `bevy`, `glam`, `wgpu` or `winit` in `sim`, `rules`, `ai` or `sim-cli`. CI greps `cargo tree -p sim -e normal` for them.
4. Ids are monotonic and never reused. Every sort uses a total order with an id tiebreak.
5. All randomness comes from the one `Pcg32` inside `Sim`, which is part of the hash.
5. All randomness the sim draws comes from the one `Pcg32` inside `Sim`, which is part of the hash. The bot's noise (M6) is `SimView::ai_noise`, a pure hash of the seed, the player and a salt, never a generator and never the `Pcg32`.
6. No trigonometry. Units store direction vectors, and distances are compared squared through `dist_sq_i64`.
7. Every gameplay number comes from `data/`.

Expand Down
Loading
Loading