diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34e5095..dcc833e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: @@ -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--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. @@ -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 @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71344a1..3275471 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: models=`; 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: models=`; 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/`) @@ -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/.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: @@ -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/`. diff --git a/crates/ai/src/harness.rs b/crates/ai/src/harness.rs index 710a791..dcae79f 100644 --- a/crates/ai/src/harness.rs +++ b/crates/ai/src/harness.rs @@ -7,18 +7,23 @@ //! Player A is `PlayerId(0)` and player B `PlayerId(1)`. use crate::standard::{Facts, build_order}; -use crate::{BOT_STANDARD, BotError, controller_for}; +use crate::{BotError, bot_stats, controller_for, is_standard_bot}; use rules::{BuildOrder, Rules, UnitClass}; use sim::{ - AiController, BuildingKindId, Construction, FxVec2, Intent, MatchResult, MatchSetup, Outcome, - OutcomeReason, PlayerId, Sim, SimEvent, SimView, TargetId, Unit, + AiController, BuildingKindId, Construction, FxVec2, Intent, MatchResult, MatchSetup, + MatchStats, Outcome, OutcomeReason, PlayerId, Sim, SimEvent, SimView, TargetId, Unit, }; /// The CSV header: the seven columns of docs/design/ai.md first, in their /// order, then the M5b diagnostics (B's `second_town_tick`, `tower_tick` -/// and `bo_done_tick` last, so the side swap reports what A's columns -/// report). -pub const CSV_HEADER: &str = "seed,winner,outcome,tick,final_hash,age_reached_tick,first_attack_tick,reason,first_attack_units,first_attack_wains,second_town_tick,tower_tick,bo_done_tick_a,age_reached_tick_b,first_attack_tick_b,first_attack_units_b,first_attack_wains_b,territory_a,territory_b,units_lost_a,units_lost_b,min_enemy_town_hp_permille_a,min_enemy_town_hp_permille_b,rejected_a,rejected_b,second_town_tick_b,tower_tick_b,bo_done_tick_b"; +/// and `bo_done_tick` last among them, so the side swap reports what A's +/// columns report), then the M6 columns ([`M6_COLUMNS`]). +pub const CSV_HEADER: &str = "seed,winner,outcome,tick,final_hash,age_reached_tick,first_attack_tick,reason,first_attack_units,first_attack_wains,second_town_tick,tower_tick,bo_done_tick_a,age_reached_tick_b,first_attack_tick_b,first_attack_units_b,first_attack_wains_b,territory_a,territory_b,units_lost_a,units_lost_b,min_enemy_town_hp_permille_a,min_enemy_town_hp_permille_b,rejected_a,rejected_b,second_town_tick_b,tower_tick_b,bo_done_tick_b,bot_a,personality_a,bot_b,personality_b,waves_a,waves_b,raids_a,raids_b,retreats_a,retreats_b,wiped_a,wiped_b,towns_taken_a,towns_taken_b,bonus_a,bonus_b,income_a,income_b,cap_s_a,cap_s_b,first_contact_tick,max_quiet_ticks,first_on_land_tick_a,first_on_land_tick_b,min_own_town_hp_permille_a,min_own_town_hp_permille_b"; + +/// The columns M5b printed, which stay first and in order. +pub const M5B_COLUMNS: usize = 28; +/// The columns M6 appends (docs/design/ai.md, the CSV table). +pub const M6_COLUMNS: usize = 26; /// The age whose arrival `age_reached_tick` records (by id). pub const AGE_REACHED: &str = "masonry"; @@ -92,30 +97,70 @@ pub struct Side { pub min_enemy_town_hp_permille: u32, /// The side's rejected commands (`CommandRejected`). pub rejected: u32, + /// M6, from the side's bot memory ([`crate::bot_stats`]; `None` for a + /// Passive seat): waves launched. + pub waves: Option, + /// Raids launched. + pub raids: Option, + /// Waves called home by the retreat rule. + pub retreats: Option, + /// Waves that ended below `recall_below_units`. + pub wiped: Option, + /// M6, from [`MatchStats`]: Towns the side annexed. + pub towns_taken: u32, + /// Bonus resources the side received (`BonusIncome`), every resource. + pub bonus: i64, + /// Credited income in whole resources, every resource, from the + /// once-a-second samples. + pub income: i64, + /// Seconds with any resource at its Yield Cap. + pub cap_s: u32, + /// The first tick an armed unit of the side stood on the other's land. + pub first_on_land_tick: Option, + /// The lowest hit points, per mille, of a Town the side started with or + /// founded, while it owned it (0 once one was annexed from it). + pub min_own_town_hp_permille: u32, +} + +/// What the observer records for the match as a whole (M6). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MatchFacts { + /// The first tick a hit landed between the two players' entities. + pub first_contact_tick: Option, + /// The longest span with no cross-player hit after first contact, up + /// to the outcome. + pub max_quiet_ticks: Option, } /// Collects the play-bots facts after every step from the view and the /// step's events. Everything it reads is hashed state or an event, so two -/// runs of one seed observe the same row. +/// runs of one seed observe the same row. Since M6 it also feeds a +/// [`MatchStats`], the source of the M6 columns. #[derive(Debug, Clone)] pub struct Observer { sides: [Side; 2], orders: [Option; 2], age_reached: Option, + stats: MatchStats, } impl Observer { - /// An observer for `setup` under `rules`: each `standard` seat's build - /// order is read from the rules for its `bo_done_tick`. + /// An observer for `setup` under `rules`: each Standard seat's build + /// order (any difficulty, M6, resolved for the level's + /// `worker_target_permille`) is read from the rules for its + /// `bo_done_tick`. pub fn new(setup: &MatchSetup, rules: &Rules) -> Observer { let order = |p: PlayerId| { - setup + let seat = setup .ai_seats .iter() - .find(|s| s.player == p && s.bot == BOT_STANDARD) - .and_then(|s| rules.ai.personality(&s.personality)) - .and_then(|k| rules.ai.build_order(&k.build_order)) - .cloned() + .find(|s| s.player == p && is_standard_bot(rules, &s.bot))?; + let level = rules.ai.difficulty(&seat.bot)?; + let k = rules.ai.personality(&seat.personality)?; + rules + .ai + .build_order(&k.build_order) + .map(|o| rules::ai::resolve_order(o, level)) }; Observer { sides: [ @@ -130,6 +175,7 @@ impl Observer { ], orders: [order(A), order(B)], age_reached: rules.age_index(AGE_REACHED), + stats: MatchStats::new(setup, rules), } } @@ -138,9 +184,15 @@ impl Observer { &self.sides } + /// The match statistics so far (M6). + pub fn stats(&self) -> &MatchStats { + &self.stats + } + /// Record one step: `view` is the sim after it, `events` what it /// emitted. pub fn observe(&mut self, view: &SimView<'_>, events: &[SimEvent]) { + self.stats.observe(view, events); let tick = view.tick; let rules = view.rules(); for e in events { @@ -319,24 +371,56 @@ impl BotMatch { } /// The row for `seed` at the current state, with the territory counted - /// now. + /// now, the bot statistics read from the memories now (M6), and the + /// [`MatchStats`] columns. pub fn row(&self, seed: u64) -> GameRow { let view = self.sim.view(); + let stats = self.observer.stats(); let mut sides = *self.observer.sides(); for (i, side) in sides.iter_mut().enumerate() { let p = PlayerId(u8::try_from(i).expect("two sides")); side.territory = view.territory().owned_tile_count(p); + let bot = bot_stats(&view, p); + side.waves = bot.map(|b| b.waves_launched); + side.raids = bot.map(|b| b.raids); + side.retreats = bot.map(|b| b.retreats); + side.wiped = bot.map(|b| b.wiped); + let s = stats.player(p); + side.towns_taken = s.towns_taken; + side.bonus = s.bonus_total(); + side.income = stats.income_total(p); + side.cap_s = s.cap_seconds; + side.first_on_land_tick = s.first_on_land_tick; + side.min_own_town_hp_permille = s.min_own_town_hp_permille; } - GameRow::finished( + let mut row = GameRow::finished( seed, self.sim.result(), self.sim.tick(), self.sim.hash(), sides, - ) + ); + row.seats = seats_of(self.sim.setup()); + row.facts = Some(MatchFacts { + first_contact_tick: stats.first_contact_tick(), + max_quiet_ticks: stats.max_quiet_ticks(), + }); + row } } +/// The header's `(bot, personality)` of A and B (empty for a human slot). +pub fn seats_of(setup: &MatchSetup) -> [(String, String); 2] { + [A, B].map(|p| { + setup + .ai_seats + .iter() + .find(|s| s.player == p) + .map(|s| (s.bot.clone(), s.personality.clone())) + .unwrap_or_default() + }) +} + /// How a decided match ended, as the `outcome` column prints it. fn outcome_str(r: &MatchResult) -> &'static str { match r.outcome { @@ -373,6 +457,11 @@ pub struct GameRow { pub final_hash: Option, /// The observed facts, `[A, B]`; `None` after a panic. pub sides: Option<[Side; 2]>, + /// The header's `(bot, personality)` of A and B (M6; empty when not + /// known). + pub seats: [(String, String); 2], + /// The whole-match facts (M6); `None` after a panic. + pub facts: Option, } impl GameRow { @@ -398,6 +487,8 @@ impl GameRow { tick: Some(result.map_or(tick, |r| r.tick)), final_hash: Some(final_hash), sides: Some(sides), + seats: Default::default(), + facts: Some(MatchFacts::default()), } } @@ -410,13 +501,15 @@ impl GameRow { tick: None, final_hash: None, sides: None, + seats: Default::default(), + facts: None, } } /// The row in [`CSV_HEADER`]'s column order; an unknown value is /// empty. pub fn csv(&self) -> String { - fn opt(v: Option) -> String { + fn opt(v: Option) -> String { v.map(|v| v.to_string()).unwrap_or_default() } let r = self.result; @@ -426,6 +519,9 @@ impl GameRow { None => (&blank, &blank, false), }; let num = |v: u32| if known { v.to_string() } else { String::new() }; + let num64 = |v: i64| if known { v.to_string() } else { String::new() }; + let facts = self.facts.unwrap_or_default(); + let [(bot_a, personality_a), (bot_b, personality_b)] = &self.seats; let cols = [ self.seed.to_string(), self.winner.as_str().to_string(), @@ -459,6 +555,33 @@ impl GameRow { opt(b.second_town_tick), opt(b.tower_tick), opt(b.bo_done_tick), + // M6. + bot_a.clone(), + personality_a.clone(), + bot_b.clone(), + personality_b.clone(), + opt(a.waves), + opt(b.waves), + opt(a.raids), + opt(b.raids), + opt(a.retreats), + opt(b.retreats), + opt(a.wiped), + opt(b.wiped), + num(a.towns_taken), + num(b.towns_taken), + num64(a.bonus), + num64(b.bonus), + num64(a.income), + num64(b.income), + num(a.cap_s), + num(b.cap_s), + opt(facts.first_contact_tick), + opt(facts.max_quiet_ticks), + opt(a.first_on_land_tick), + opt(b.first_on_land_tick), + num(a.min_own_town_hp_permille), + num(b.min_own_town_hp_permille), ]; cols.join(",") } @@ -486,6 +609,72 @@ mod tests { Rules::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")).unwrap() } + /// M6: the 26 columns appended after M5b's 28, in order, and a real + /// row of a Hard bot against Passive fills them. + #[test] + fn csv_appends_the_m6_columns_after_the_m5b_ones() { + let cols: Vec<&str> = CSV_HEADER.split(',').collect(); + assert_eq!(cols.len(), M5B_COLUMNS + M6_COLUMNS); + assert_eq!( + cols[M5B_COLUMNS..], + [ + "bot_a", + "personality_a", + "bot_b", + "personality_b", + "waves_a", + "waves_b", + "raids_a", + "raids_b", + "retreats_a", + "retreats_b", + "wiped_a", + "wiped_b", + "towns_taken_a", + "towns_taken_b", + "bonus_a", + "bonus_b", + "income_a", + "income_b", + "cap_s_a", + "cap_s_b", + "first_contact_tick", + "max_quiet_ticks", + "first_on_land_tick_a", + "first_on_land_tick_b", + "min_own_town_hp_permille_a", + "min_own_town_hp_permille_b" + ] + ); + let r = rules(); + let setup = MatchSetup::bots(&r, 1, ("hard", "standard"), ("passive", "")); + let mut m = BotMatch::with_bots(setup, r.clone()).unwrap(); + m.run(4400); + let row = m.row(1).csv(); + let got: Vec<&str> = row.split(',').collect(); + assert_eq!(got.len(), cols.len(), "{row}"); + let col = |name: &str| got[cols.iter().position(|c| *c == name).unwrap()]; + assert_eq!( + [ + col("bot_a"), + col("personality_a"), + col("bot_b"), + col("personality_b") + ], + ["hard", "standard", "passive", ""] + ); + // Hard pays from 3:00 every 20 s: ticks 4000 and 4400. + let bundle: i64 = r.ai.difficulty("hard").unwrap().bundle(&r).iter().sum(); + assert_eq!(col("bonus_a"), (2 * bundle).to_string()); + assert_eq!(col("bonus_b"), "0"); + assert_eq!(col("waves_a"), "0"); + assert_eq!(col("waves_b"), "", "Passive has no bot memory"); + assert!(col("income_a").parse::().unwrap() > 0); + assert_eq!(col("min_own_town_hp_permille_b"), "1000"); + assert_eq!(col("first_contact_tick"), "", "no fight by 4400"); + assert_eq!(col("max_quiet_ticks"), ""); + } + #[test] fn csv_header_starts_with_the_seven_design_columns() { let cols: Vec<&str> = CSV_HEADER.split(',').collect(); @@ -501,14 +690,14 @@ mod tests { "first_attack_tick" ] ); - assert_eq!(cols.len(), 28); + assert_eq!(cols.len(), M5B_COLUMNS + M6_COLUMNS); assert_eq!( - cols[25..], + cols[25..M5B_COLUMNS], ["second_town_tick_b", "tower_tick_b", "bo_done_tick_b"] ); let row = GameRow::panicked(4).csv(); assert_eq!(row.split(',').count(), cols.len()); - assert_eq!(row, format!("4,panic{}", ",".repeat(26))); + assert_eq!(row, format!("4,panic{}", ",".repeat(cols.len() - 2))); } #[test] diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs index 9c43b32..a44514a 100644 --- a/crates/ai/src/lib.rs +++ b/crates/ai/src/lib.rs @@ -7,7 +7,11 @@ //! data-driven [`standard::StandardBot`] (knobs and build orders from //! `data/ai/`, attached to `Rules::ai`), [`controller_for`], which builds //! the controller for a match header's AI seats, and the engine-free -//! [`harness`] behind `sim-cli play-bots`. +//! [`harness`] behind `sim-cli play-bots`. M6 makes a seat's `bot` a +//! difficulty id (`easy`, `standard`, `hard`: [`bot_ids`], +//! [`is_standard_bot`]); every difficulty is the Standard bot playing the +//! knobs `rules::ai::resolve` derives, while the sim pays the level's +//! disclosed bonus. //! //! Module map: //! @@ -38,11 +42,74 @@ use sim::{ use std::collections::BTreeMap; /// The bot id of [`Passive`] in `MatchSetup.ai_seats`. -pub const BOT_PASSIVE: &str = "passive"; -/// The bot id of [`standard::StandardBot`] in `MatchSetup.ai_seats`. -pub const BOT_STANDARD: &str = "standard"; -/// Every bot id [`controller_for`] accepts. -pub const BOTS: [&str; 2] = [BOT_PASSIVE, BOT_STANDARD]; +pub const BOT_PASSIVE: &str = rules::ai::PASSIVE_ID; +/// The default bot id: the `standard` difficulty, played by +/// [`standard::StandardBot`] (M6: any difficulty id of +/// `data/ai/difficulty/` is a Standard bot, [`is_standard_bot`]). +pub const BOT_STANDARD: &str = rules::ai::STANDARD_DIFFICULTY; +/// The personality pseudo-id that `play-bots` and the game resolve per +/// seed with [`resolve_personality`] (M6). +pub const RANDOM_PERSONALITY: &str = "random"; + +/// Every bot id [`controller_for`] accepts under `rules` (M6): `passive`, +/// then the difficulty ids of `data/ai/difficulty/` in id order. +pub fn bot_ids(rules: &Rules) -> Vec { + std::iter::once(BOT_PASSIVE.to_string()) + .chain(rules.ai.difficulties.keys().cloned()) + .collect() +} + +/// Whether `bot` is played by [`standard::StandardBot`]: it names a +/// difficulty level of `rules` (M6). +pub fn is_standard_bot(rules: &Rules, bot: &str) -> bool { + rules.ai.difficulty(bot).is_some() +} + +/// The salt of [`resolve_personality`]'s hash. +const RANDOM_PERSONALITY_SALT: u64 = 0x5241_4e44_4f4d; + +/// The personality the `random` choice gives `player` at `seed` (M6): one +/// of the personalities with `random: true` in +/// `data/ai/personalities.ron`, in file order, picked by a hash of the +/// seed and the player (`sim::ai_noise`, never the sim's `Pcg32`), so a +/// seed always resolves to the same style and the two seats of a +/// bot-versus-bot match draw independently. The result is written to the +/// header, so a replay never resolves it again. Adding a personality to +/// the pool remaps the seeds. +/// +/// # Errors +/// +/// When no personality is in the pool. +pub fn resolve_personality(rules: &Rules, seed: u64, player: PlayerId) -> Result { + let pool: Vec<&str> = rules + .ai + .personalities + .iter() + .filter(|p| p.random) + .map(|p| p.id.as_str()) + .collect(); + if pool.is_empty() { + return Err( + "\"random\" needs a personality with `random: true` in data/ai/personalities.ron; none has it" + .to_string(), + ); + } + let n = u64::try_from(pool.len()).expect("few personalities"); + let pick = sim::ai_noise(seed, player, RANDOM_PERSONALITY_SALT) % n; + Ok(pool[usize::try_from(pick).expect("below the pool length")].to_string()) +} + +/// The bot statistics of `player`'s seat (M6), read from its memory +/// outside `think`: waves, raids, retreats and wiped waves, for the +/// play-bots CSV and the game-over screen. `None` for a human slot or a +/// `passive` seat. +pub fn bot_stats(view: &SimView<'_>, player: PlayerId) -> Option { + let seat = view.setup().ai_seats.iter().find(|s| s.player == player)?; + if !is_standard_bot(view.rules(), &seat.bot) { + return None; + } + Some(standard::stats(view.ai_memory(player))) +} /// Why [`controller_for`] could not build a controller for a header. #[derive(Debug, Clone, PartialEq, Eq)] @@ -55,12 +122,14 @@ pub enum BotError { /// The `is_ai` slots, in order. ai_slots: Vec, }, - /// A seat names a bot this build does not know. + /// A seat names a bot the rules do not know. UnknownBot { /// The seat. player: PlayerId, /// The bot id. bot: String, + /// The bot ids the rules know ([`bot_ids`]). + known: Vec, }, /// A seat names a personality its bot cannot take: one that is not in /// `data/ai/personalities.ron`, or any for `passive`. @@ -83,11 +152,11 @@ impl core::fmt::Display for BotError { seats.iter().map(|p| p.0).collect::>(), ai_slots.iter().map(|p| p.0).collect::>() ), - BotError::UnknownBot { player, bot } => write!( + BotError::UnknownBot { player, bot, known } => write!( f, "player {}: unknown bot {bot:?} (known: {})", player.0, - BOTS.join(", ") + known.join(", ") ), BotError::UnknownPersonality { player, @@ -118,7 +187,9 @@ impl std::error::Error for BotError {} /// `hash-dump`, `record`, the game and its `--headless-run`) builds bots /// through this, so a header always gets the bots it was recorded with. /// The slot check comes first, so a header `Sim::new` would reject is an -/// error here instead of a panic. +/// error here instead of a panic. A seat's `bot` is `passive` or a +/// difficulty id (M6), whose Standard bot plays its personality's knobs +/// resolved for that level. pub fn controller_for( setup: &MatchSetup, rules: &Rules, @@ -143,7 +214,14 @@ pub fn controller_for( let bot: Box = match seat.bot.as_str() { BOT_PASSIVE if seat.personality.is_empty() => Box::new(Passive), BOT_PASSIVE => return Err(unknown_personality()), - BOT_STANDARD => { + id => { + let Some(difficulty) = rules.ai.difficulty(id) else { + return Err(BotError::UnknownBot { + player: seat.player, + bot: seat.bot.clone(), + known: bot_ids(rules), + }); + }; let personality = rules .ai .personality(&seat.personality) @@ -153,14 +231,9 @@ pub fn controller_for( setup, seat.player, personality, + difficulty, )) } - _ => { - return Err(BotError::UnknownBot { - player: seat.player, - bot: seat.bot.clone(), - }); - } }; by_player.insert(seat.player, bot); } @@ -168,26 +241,39 @@ pub fn controller_for( } /// One line about `player`'s bot for the dev panel and debugging (M5b): -/// for a Standard seat, the build-order window (step index and label), -/// the `Military` latch, the wave (phase and size, the next launch tick, -/// waves launched) and the defend latch (Town and age, defences -/// released), read from the view and the player's memory; `passive` for a -/// Passive seat, `human` for a slot with no seat. Call it outside -/// `think`, where `view.ai_memory` shows the memory. +/// for a Standard seat, its difficulty and personality, the build-order +/// window (step index and label), the difficulty's first raid (M6: its +/// deadline tick, or `none`) with the raids and retreats so far, the +/// `Military` latch, the wave (phase and size, the next launch tick, waves +/// launched) and the defend latch (Town and age, defences released), read +/// from the view and the player's memory; `passive` for a Passive seat, +/// `human` for a slot with no seat. Call it outside `think`, where +/// `view.ai_memory` shows the memory. pub fn describe(view: &SimView<'_>, player: PlayerId) -> String { let Some(seat) = view.setup().ai_seats.iter().find(|s| s.player == player) else { return "human".to_string(); }; - if seat.bot != BOT_STANDARD { - return seat.bot.clone(); - } let rules = view.rules(); - let Some(knobs) = rules.ai.personality(&seat.personality) else { - return format!("standard {}: unknown personality", seat.personality); + let Some(level) = rules.ai.difficulty(&seat.bot) else { + return seat.bot.clone(); + }; + let Some(personality) = rules.ai.personality(&seat.personality) else { + return format!("{} {}: unknown personality", seat.bot, seat.personality); }; - let Some(order) = rules.ai.build_order(&knobs.build_order) else { - return format!("standard {}: unknown build order", seat.personality); + let knobs = rules::ai::resolve(personality, level); + let Some(order) = rules + .ai + .build_order(&knobs.build_order) + .map(|o| rules::ai::resolve_order(o, level)) + else { + return format!("{} {}: unknown build order", seat.bot, seat.personality); }; + let order = ℴ + let stats = standard::stats(view.ai_memory(player)); + let raid = level.first_raid.map_or_else( + || "none".to_string(), + |r| format!("by {}", rules.ticks_from_ds(r.by_ds)), + ); let mem = standard::BotMemory::decode(view.ai_memory(player)); let facts = standard::Facts::gather(view, player); let lookahead = usize::try_from(knobs.lookahead).unwrap_or(1); @@ -211,9 +297,12 @@ pub fn describe(view: &SimView<'_>, player: PlayerId) -> String { }, ); format!( - "standard {} window=[{}] military={} wave={} next_wave={} waves={} defend={} defends={}", + "{} {} window=[{}] raid={raid} raids={} retreats={} military={} wave={} next_wave={} waves={} defend={} defends={}", + seat.bot, seat.personality, window.join(", "), + stats.raids, + stats.retreats, if mem.military { "yes" } else { "no" }, wave, mem.next_wave_at, @@ -444,8 +533,167 @@ mod tests { } } + /// M6: the bot ids are `passive` and the difficulty ids; only the + /// difficulties are Standard bots. + #[test] + fn controller_for_builds_every_difficulty_and_lists_ids_on_error() { + let r = rules(); + assert_eq!(bot_ids(&r), ["passive", "easy", "hard", "standard"]); + for id in ["easy", "standard", "hard"] { + assert!(is_standard_bot(&r, id), "{id}"); + let setup = MatchSetup::bots(&r, 3, (id, "standard"), (id, "standard")); + let mut sim = Sim::new( + setup.clone(), + r.clone(), + controller_for(&setup, &r).unwrap(), + ); + for _ in 0..30 { + sim.step(&[]); + } + assert!(sim.view().commands_applied() > 0, "{id} plays"); + } + assert!(!is_standard_bot(&r, BOT_PASSIVE)); + assert!(!is_standard_bot(&r, "nightmare")); + } + + /// M6: a Standard bot's knobs are `resolve(personality, difficulty)`, + /// a pure function of the rules and the header: two bots built for + /// the same seat agree, and `standard` plays the personality with its + /// level's all-in time and (M6 owner decision 12) no retreat. + #[test] + fn knobs_are_pure_functions_of_rules_and_header() { + let r = rules(); + let p = r.ai.personality("standard").unwrap(); + let setup = MatchSetup::bots(&r, 1, ("hard", "standard"), ("easy", "standard")); + for (seat, id) in [(PlayerId(0), "hard"), (PlayerId(1), "easy")] { + let level = r.ai.difficulty(id).unwrap(); + let a = standard::StandardBot::new(&r, &setup, seat, p, level); + let b = standard::StandardBot::new(&r, &setup, seat, p, level); + assert_eq!(a.knobs(), b.knobs()); + assert_eq!(a.knobs(), &rules::ai::resolve(p, level)); + assert_eq!(a.first_raid(), level.first_raid); + assert_eq!(a.difficulty(), id); + // The build order it plays is the level's resolved order (M6 + // owner decision 12: Easy's Statecraft II appended). + let order = r.ai.build_order(&p.build_order).unwrap(); + assert_eq!(a.order(), &rules::ai::resolve_order(order, level)); + } + let first_workers = |o: &rules::BuildOrder| { + o.steps.iter().find_map(|s| match s { + rules::Step::Workers { count, .. } => Some(*count), + _ => None, + }) + }; + let easy = r.ai.difficulty("easy").unwrap(); + let easy_bot = standard::StandardBot::new(&r, &setup, PlayerId(1), p, easy); + assert_eq!(first_workers(easy_bot.order()), Some(7)); + let shipped = r.ai.build_order(&p.build_order).unwrap(); + assert_eq!(easy_bot.order().steps.len(), shipped.steps.len() + 1); + assert_eq!( + easy_bot.order().steps.last(), + Some(&rules::Step::Research { + tech: "statecraft_2".into(), + optional: true + }) + ); + let standard = r.ai.difficulty("standard").unwrap(); + let bot = standard::StandardBot::new(&r, &setup, PlayerId(0), p, standard); + let mut want = p.clone(); + // Standard retreats as its personality does (the review of the M6 + // retune put it back); only its all-in time moves. + want.all_in_after_ds = 21600; + assert_eq!(bot.knobs(), &want); + assert_eq!(bot.order(), shipped); + assert_eq!(first_workers(bot.order()), Some(7)); + // `describe` reads the same resolved window for the Easy seat (at + // tick 0, before the first plan, the window starts at step 0). + let sim = Sim::new( + setup.clone(), + r.clone(), + controller_for(&setup, &r).unwrap(), + ); + let line = describe(&sim.view(), PlayerId(1)); + assert!( + line.starts_with("easy standard window=[0 workers 7, "), + "{line}" + ); + let line = describe(&sim.view(), PlayerId(0)); + assert!( + line.starts_with("hard standard window=[0 workers 7, "), + "{line}" + ); + } + + /// M6: `random` resolves per seed and seat from the pool of + /// personalities with `random: true`, the same way every time; with + /// no such personality it is an error. + #[test] + fn resolve_personality_draws_from_the_random_pool() { + let mut r = rules(); + // The shipped pool (slice 2): rush, boom and tower, never the + // Balanced `standard`, an explicit choice. + let shipped: Vec = (0..60) + .map(|seed| resolve_personality(&r, seed, PlayerId(1)).unwrap()) + .collect(); + for id in ["rush", "boom", "tower"] { + assert!(shipped.iter().any(|p| p == id), "{id} is drawn"); + } + assert!(!shipped.iter().any(|p| p == "standard")); + for p in &mut r.ai.personalities { + p.random = false; + } + let err = resolve_personality(&r, 1, PlayerId(1)).unwrap_err(); + assert!(err.contains("random: true"), "{err}"); + let mut extra = |id: &str| { + let mut p = r.ai.personality("standard").unwrap().clone(); + p.id = id.to_string(); + p.random = true; + r.ai.personalities.push(p); + }; + extra("a"); + extra("b"); + extra("c"); + let picks: Vec = (0..60) + .map(|seed| resolve_personality(&r, seed, PlayerId(1)).unwrap()) + .collect(); + for id in ["a", "b", "c"] { + assert!(picks.iter().any(|p| p == id), "{id} is drawn"); + } + assert!(!picks.iter().any(|p| p == "standard"), "not in the pool"); + let again: Vec = (0..60) + .map(|seed| resolve_personality(&r, seed, PlayerId(1)).unwrap()) + .collect(); + assert_eq!(picks, again); + let other_seat: Vec = (0..60) + .map(|seed| resolve_personality(&r, seed, PlayerId(0)).unwrap()) + .collect(); + assert_ne!(picks, other_seat, "the seats draw independently"); + } + + /// M6: `bot_stats` reads a Standard seat's memory and is `None` for a + /// human slot or a Passive seat. + #[test] + fn bot_stats_reads_standard_seats_only() { + let r = rules(); + let setup = MatchSetup::bots(&r, 1, ("hard", "standard"), ("passive", "")); + let sim = Sim::new( + setup.clone(), + r.clone(), + controller_for(&setup, &r).unwrap(), + ); + let view = sim.view(); + assert_eq!( + bot_stats(&view, PlayerId(0)), + Some(standard::BotStats::default()) + ); + assert_eq!(bot_stats(&view, PlayerId(1)), None); + let human = Sim::new(MatchSetup::skirmish(&r, 1), r, Box::new(Passive)); + assert_eq!(bot_stats(&human.view(), PlayerId(0)), None); + } + /// M5b: `controller_for` reads the header's seats: the slot check - /// first, then each seat's bot and personality. + /// first, then each seat's bot and personality. M6: every difficulty + /// id is a bot, and the unknown ones are ids no data file defines. #[test] fn controller_for_checks_slots_bots_and_personalities() { let r = rules(); @@ -453,6 +701,8 @@ mod tests { MatchSetup::skirmish(&r, 1), MatchSetup::vs_bot(&r, 1, BOT_STANDARD, "standard"), MatchSetup::bots(&r, 1, ("standard", "standard"), ("passive", "")), + MatchSetup::bots(&r, 1, ("easy", "standard"), ("hard", "standard")), + MatchSetup::vs_bot(&r, 1, "hard", "standard"), MatchSetup::scenario(&r, 1), ] { assert!(controller_for(&setup, &r).is_ok(), "{:?}", setup.ai_seats); @@ -476,16 +726,22 @@ mod tests { 2 ]; assert!(matches!(err(&s), BotError::SlotMismatch { .. })); - // An unknown bot is reported even after a valid seat. - let s = MatchSetup::bots(&r, 1, ("passive", ""), ("hard", "standard")); + // An unknown bot is reported even after a valid seat, with the ids + // the rules know. + let s = MatchSetup::bots(&r, 1, ("passive", ""), ("nightmare", "standard")); + assert_eq!( + err(&s).to_string(), + "player 1: unknown bot \"nightmare\" (known: passive, easy, hard, standard)" + ); + let s = MatchSetup::vs_bot(&r, 1, "standard", "zerg"); assert_eq!( err(&s).to_string(), - "player 1: unknown bot \"hard\" (known: passive, standard)" + "player 1: bot \"standard\" has no personality \"zerg\" in data/ai/personalities.ron" ); - let s = MatchSetup::vs_bot(&r, 1, "standard", "rush"); + let s = MatchSetup::vs_bot(&r, 1, "hard", "zerg"); assert_eq!( err(&s).to_string(), - "player 1: bot \"standard\" has no personality \"rush\" in data/ai/personalities.ron" + "player 1: bot \"hard\" has no personality \"zerg\" in data/ai/personalities.ron" ); let s = MatchSetup::vs_bot(&r, 1, "passive", "standard"); assert_eq!( diff --git a/crates/ai/src/standard/army.rs b/crates/ai/src/standard/army.rs index 2f6aa1a..60df44d 100644 --- a/crates/ai/src/standard/army.rs +++ b/crates/ai/src/standard/army.rs @@ -29,8 +29,10 @@ //! analysis' rally seed ([`Ctx::rally`]); every trainer of a non-civilian //! kind whose rally differs gets `SetRally` (`check_rally` first). -use super::ctx::Ctx; +use super::compose::{Counters, shares, weighted_mix}; +use super::ctx::{Ctx, jitter, salt}; use super::memory::BotMemory; +use super::waves::wain_needed; use rules::UnitClass; use sim::{BuildingId, BuildingKindId, Command, UnitId, UnitKindId}; use std::collections::BTreeSet; @@ -130,13 +132,79 @@ pub fn producing(mem: &BotMemory) -> bool { mem.military || mem.defend.is_some() } -/// Train the plan's army: Wains first, then the mix by deficit. +/// Whether kind `k` is armed siege. +fn is_siege(ctx: &Ctx<'_>, k: UnitKindId) -> bool { + ctx.view + .rules() + .unit_kind(k.0) + .is_some_and(|d| d.class == UnitClass::Siege) +} + +/// Emergency production before a difficulty's deadline raid (M6): from +/// `by_ds - prep_ds` until the raid launches (and while no wave has), +/// the bot trains non-siege armed kinds of its mix toward the raid's +/// `min_armed`, before the `Military` latch too. `true` while that many +/// are not yet alive or queued. +pub fn emergency(ctx: &Ctx<'_>, mem: &BotMemory) -> bool { + let Some(raid) = ctx.first_raid else { + return false; + }; + if mem.raid_used + || mem.waves_launched > 0 + || ctx.tick() < ctx.ticks(raid.by_ds.saturating_sub(raid.prep_ds)) + { + return false; + } + raiders_alive_and_queued(ctx) < raid.min_armed +} + +/// Non-siege armed units alive or queued. +fn raiders_alive_and_queued(ctx: &Ctx<'_>) -> u32 { + let rules = ctx.view.rules(); + rules + .units + .iter() + .enumerate() + .filter(|(_, u)| u.class != UnitClass::Civilian && u.class != UnitClass::Siege) + .filter(|(_, u)| u.attack.is_some()) + .map(|(i, _)| { + ctx.facts + .alive_and_queued(UnitKindId(u16::try_from(i).expect("few"))) + }) + .sum() +} + +/// The personality's counter knobs as [`Counters`]. +fn counters(ctx: &Ctx<'_>) -> Counters { + Counters { + weight_permille: ctx.knobs.counter_weight_permille, + floor_permille: ctx.knobs.counter_floor_permille, + cap_permille: ctx.knobs.counter_cap_permille, + join_permille: ctx.knobs.counter_join_permille, + kinds: ctx + .knobs + .counter_kinds + .iter() + .filter_map(|m| Some((unit_kind_id(ctx, &m.kind)?, m.weight))) + .collect(), + } +} + +/// Train the plan's army: Wains first, then the mix by deficit. M6: the +/// mix is weighted by counters against the visible enemy army +/// ([`super::compose`]) and jittered by `noise_mix_permille` per wave; +/// Wains are trained only while the Wain rule needs them; and before the +/// latch, emergency production ([`emergency`]) trains the non-siege kinds +/// of the mix toward the deadline raid's `min_armed`. pub fn produce(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { - if !producing(mem) { + let urgent = !producing(mem) && emergency(ctx, mem); + if !producing(mem) && !urgent { return; } let mut used: BTreeSet = BTreeSet::new(); - if let Some(wain) = unit_kind_id(ctx, &ctx.knobs.supply_kind) + if !urgent + && wain_needed(ctx) + && let Some(wain) = unit_kind_id(ctx, &ctx.knobs.supply_kind) && ctx.facts.alive_and_queued(wain) < ctx.knobs.wains_per_wave && let Some(b) = free_trainers(ctx, wain).first().copied() { @@ -157,12 +225,26 @@ pub fn produce(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { Err(_) => {} } } - let mix: Vec<(UnitKindId, u32)> = ctx + let personal: Vec<(UnitKindId, u32)> = ctx .knobs .army_mix .iter() .filter_map(|m| Some((unit_kind_id(ctx, &m.kind)?, m.weight))) + .collect(); + let enemy = shares(&ctx.facts.enemy.armed); + let knobs = counters(ctx); + let weighted = weighted_mix(ctx.view.rules(), &personal, &enemy, &knobs, |k| { + in_share_base(ctx, k) + }); + let waves = u64::from(mem.waves_launched); + let mix: Vec<(UnitKindId, u32)> = weighted + .into_iter() + .filter(|(k, _)| !urgent || !is_siege(ctx, *k)) .filter(|(k, _)| in_share_base(ctx, *k)) + .map(|(k, w)| { + let noise = ctx.noise(salt::MIX + (u64::from(k.0) << 16) + waves); + (k, jitter(w, noise, ctx.knobs.noise_mix_permille).max(1)) + }) .collect(); let base: Vec = (0..mix.len()).collect(); let now: Vec = base @@ -181,8 +263,10 @@ pub fn produce(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { for i in &now { trainers.extend(free_trainers(ctx, mix[*i].0)); } + let mut raiders = raiders_alive_and_queued(ctx); + let raid_min = ctx.first_raid.map_or(0, |r| r.min_armed); for b in trainers { - if used.contains(&b) || ctx.commands_left == 0 { + if used.contains(&b) || ctx.commands_left == 0 || (urgent && raiders >= raid_min) { continue; } let mut order: Vec<(i64, UnitKindId, usize)> = now @@ -204,6 +288,7 @@ pub fn produce(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { kind: k, }) { have[i] += 1; + raiders = raiders.saturating_add(1); } break; } @@ -422,4 +507,71 @@ mod tests { rally_upkeep(&mut ctx); assert!(ctx.into_commands().is_empty(), "set once"); } + + /// M6: from `by_ds - prep_ds` of a deadline raid, before the + /// `Military` latch, the bot trains non-siege armed kinds of its mix + /// toward `min_armed`; not without a deadline raid, not once + /// `min_armed` stand, and never a Mangonel or a Wain. + #[test] + fn emergency_production_trains_raiders_before_the_military_latch() { + let r = crate::testkit::rules(); + let raid = rules::FirstRaid { + by_ds: 600, + prep_ds: 600, + min_armed: 6, + max_armed: 10, + }; + let setup = |extra: u16| { + let mut v = vec![ + Command::DebugAdvanceAge { owner: P0 }, + place(&r, P0, "muster_hall", 29, 66), + place(&r, P0, "engine_yard", 29, 58), + ]; + v.extend(grant_all(&r, P0, 1000)); + if extra > 0 { + v.push(crate::testkit::spawn(&r, P0, "skirmisher", 36, 64, extra)); + } + Arena::frozen(standard_vs_passive(&r), v) + }; + let a = setup(0); + let mh = a.buildings_of(P0, "muster_hall")[0]; + let mut ctx = a.ctx(P0); + ctx.first_raid = Some(raid); + let mut mem = BotMemory::default(); + assert!(emergency(&ctx, &mem)); + produce(&mut ctx, &mut mem); + let cmds = ctx.into_commands(); + assert!(!cmds.is_empty(), "trains before the latch"); + for c in &cmds { + let Command::Train { building, kind } = c else { + panic!("{c:?}"); + }; + assert_eq!(*building, mh, "at the Muster Hall, not the Engine Yard"); + let def = r.unit_kind(kind.0).unwrap(); + assert!( + def.class != UnitClass::Siege && def.attack.is_some(), + "{}", + def.id + ); + } + // No deadline raid: no production before the latch. + let mut ctx = a.ctx(P0); + ctx.first_raid = None; + produce(&mut ctx, &mut BotMemory::default()); + assert!(ctx.into_commands().is_empty(), "no raid, no latch"); + // Six armed already: nothing more. + let six = setup(6); + let mut ctx = six.ctx(P0); + ctx.first_raid = Some(raid); + let mem = BotMemory::default(); + assert!(!emergency(&ctx, &mem)); + // The raid launched: back to the latch. + let mut ctx = a.ctx(P0); + ctx.first_raid = Some(raid); + let mem = BotMemory { + raid_used: true, + ..BotMemory::default() + }; + assert!(!emergency(&ctx, &mem)); + } } diff --git a/crates/ai/src/standard/build_order.rs b/crates/ai/src/standard/build_order.rs index bdfb6af..be1d097 100644 --- a/crates/ai/src/standard/build_order.rs +++ b/crates/ai/src/standard/build_order.rs @@ -45,9 +45,9 @@ use super::ctx::Ctx; use super::economy; -use super::facts::{Facts, kind_centre, tiles_sq}; +use super::facts::{Facts, kind_centre, tiles_sq, visible_enemies}; use super::memory::BotMemory; -use super::sites::{SiteSearch, site_for}; +use super::sites::{SiteSearch, near_town_candidates, search, site_for}; use rules::{BuildOrder, Placement, Rules, Step, UnitClass}; use sim::{BuildingId, BuildingKindId, Command, FxVec2, RejectReason, TechId, UnitId, UnitKindId}; use std::collections::BTreeSet; @@ -446,9 +446,85 @@ pub fn step(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { } } } + reactive_tower(ctx, mem); site_keeper(ctx, mem); } +/// The step index the reactive Watchtower's site search remembers its site +/// under (never a build-order step, so the next plan forgets it). +pub const REACTIVE_STEP: u16 = u16::MAX; + +/// The reactive Watchtower (M6, `reactive_towers`): after a released +/// defence ([`super::defend`] sets `BotMemory::last_defended`), the bot +/// places one Watchtower `NearTown` around the defended Town with what +/// the plan's ledger can pay (it never saves up for it), up to +/// `reactive_towers` in the match. A Town lost or contested meanwhile, or +/// no site, drops the request. +pub fn reactive_tower(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { + let Some(town) = mem.last_defended else { + return; + }; + if u32::from(mem.extra_towers) >= ctx.knobs.reactive_towers + || !ctx.facts.towns.contains(&town) + || ctx.facts.contested.contains(&town) + { + mem.last_defended = None; + return; + } + if ctx.commands_left == 0 || ctx.probes_left == 0 { + return; + } + let rules = ctx.view.rules(); + let Some(k) = rules + .buildings + .iter() + .position(|b| !b.town && b.border_radius_tiles.is_some() && b.attack.is_some()) + else { + mem.last_defended = None; + return; + }; + let k = BuildingKindId(u16::try_from(k).expect("validated len")); + let Ok(cost) = ctx.view.check_build_kind(ctx.me, k) else { + return; + }; + if !ctx.ledger.affords(&cost) { + return; + } + let pool = builder_pool(ctx, mem); + let candidates = near_town_candidates(ctx, k, town); + match search( + ctx, + mem, + REACTIVE_STEP, + k, + Placement::NearTown, + &pool, + candidates, + ) { + SiteSearch::Found { builder, tile } => { + if ctx.ledger.try_spend(&cost) { + ctx.claim(builder); + if ctx.push(Command::Build { + unit: builder, + kind: k, + tile, + }) { + ctx.planned.push(( + i32::from(tile.x), + i32::from(tile.y), + i32::from(tile.x), + i32::from(tile.y), + )); + mem.extra_towers = mem.extra_towers.saturating_add(1); + mem.last_defended = None; + } + } + } + SiteSearch::NoSite => mem.last_defended = None, + SiteSearch::NoBuilder | SiteSearch::OutOfProbes => {} + } +} + /// What an `Advance` saving up for `cost` holds back: each resource at the /// share of its cost that the scarcest resource has reached in `ledger`, /// `cost_r × min_r(available_r / cost_r)`, at most `cost_r` (integer @@ -493,13 +569,13 @@ pub fn site_danger_tiles(ctx: &Ctx<'_>) -> u32 { } /// Whether another player's armed unit (non-civilian, with an attack) -/// stands within [`site_danger_tiles`] of `centre`. +/// stands within [`site_danger_tiles`] of `centre`. It reads the enemy +/// through [`visible_enemies`], the fog seam. pub fn enemy_military_near(ctx: &Ctx<'_>, centre: FxVec2) -> bool { let rules = ctx.view.rules(); let r2 = tiles_sq(site_danger_tiles(ctx)); - ctx.view.units().any(|u| { - u.owner != ctx.me - && u.pos.dist_sq_i64(centre) <= r2 + visible_enemies(&ctx.view, ctx.me).any(|u| { + u.pos.dist_sq_i64(centre) <= r2 && rules .unit_kind(u.kind.0) .is_some_and(|k| k.class != UnitClass::Civilian && k.attack.is_some()) diff --git a/crates/ai/src/standard/compose.rs b/crates/ai/src/standard/compose.rs new file mode 100644 index 0000000..e56e668 --- /dev/null +++ b/crates/ai/src/standard/compose.rs @@ -0,0 +1,255 @@ +//! Counter-weighted composition (M6): `army::produce` weights the +//! personality's mix with it on every plan that trains. +//! +//! The bot weights its personality's `army_mix` by what the enemy shows. +//! With `s_e` the visible enemy armed share of kind `e` in per mille and +//! `c(k, e)` the counter table of `units.ron` (1000 when a pair is not +//! listed): +//! +//! ```text +//! adv(k) = sum_e s_e x (c(k, e) - c(e, k)) / 1000 +//! factor(k) = clamp(1000 + weight x adv(k) / 1000, floor, cap) +//! weight'(k) = weight(k) x factor(k) / 1000 +//! ``` +//! +//! [`weighted_mix`] returns `weight(k) x factor(k)` itself, the same +//! proportions in thousandths of a weight, so a weight of 1 at the floor +//! (250) stays a quarter of a weight instead of rounding down to none. +//! +//! A kind of [`Counters::kinds`] (the Outrider, the only counter to +//! Mangonels, has no weight in the M5b mix) joins the mix with its listed +//! weight only while its factor reaches [`Counters::join_permille`] and it +//! is trainable now. With no visible enemy army every factor is 1000, so +//! the mix is the personality's own; a weight of 0 turns the rule off. +//! Integer arithmetic throughout, kinds in `UnitKindId` order, so ties go +//! to the lowest id. + +use rules::{Defender, Rules}; +use sim::UnitKindId; +use std::collections::BTreeMap; + +/// The counter knobs (slice 2 reads them from the personality). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Counters { + /// How strongly counters bend the mix, per mille (0 turns it off). + pub weight_permille: u32, + /// The smallest factor, per mille. + pub floor_permille: u32, + /// The largest factor, per mille. + pub cap_permille: u32, + /// The factor at which a [`Counters::kinds`] entry joins the mix. + pub join_permille: u32, + /// Kinds outside the mix that join it, with their weight, while they + /// counter what the enemy shows. + pub kinds: Vec<(UnitKindId, u32)>, +} + +/// The enemy's visible armed composition as shares in per mille of its +/// armed units, by kind (rounded down; empty with no enemy army). +pub fn shares(counts: &BTreeMap) -> BTreeMap { + let total: u64 = counts.values().map(|n| u64::from(*n)).sum(); + if total == 0 { + return BTreeMap::new(); + } + counts + .iter() + .filter(|(_, n)| **n > 0) + .map(|(k, n)| { + let s = u64::from(*n) * 1000 / total; + (*k, u32::try_from(s).expect("at most 1000")) + }) + .collect() +} + +/// `c(a, d)`: the counter table's per mille for hits by kind `a` on kind +/// `d` (1000 when not listed or either kind is unknown). +fn counter(rules: &Rules, a: UnitKindId, d: UnitKindId) -> i64 { + let (Some(ak), Some(dk)) = (rules.unit_kind(a.0), rules.unit_kind(d.0)) else { + return 1000; + }; + i64::from(rules.counter_permille(&ak.id, &Defender::Kind(dk.id.clone()))) +} + +/// `adv(k)`: how far kind `k` out-trades the visible enemy army, per mille. +pub fn advantage(rules: &Rules, k: UnitKindId, enemy: &BTreeMap) -> i64 { + enemy + .iter() + .map(|(e, s)| i64::from(*s) * (counter(rules, k, *e) - counter(rules, *e, k)) / 1000) + .sum() +} + +/// `factor(k)`, per mille, clamped to the floor and the cap. +pub fn factor( + rules: &Rules, + k: UnitKindId, + enemy: &BTreeMap, + knobs: &Counters, +) -> u32 { + let raw = 1000 + i64::from(knobs.weight_permille) * advantage(rules, k, enemy) / 1000; + let lo = i64::from(knobs.floor_permille); + let hi = i64::from(knobs.cap_permille).max(lo); + u32::try_from(raw.clamp(lo, hi)).expect("clamped to u32 bounds") +} + +/// The mix to train toward, in thousandths of a weight: every `mix` +/// entry's weight times its factor, then each [`Counters::kinds`] entry +/// not already in the mix whose factor reaches the join threshold and that +/// `trainable` accepts, with its listed weight times its factor. In +/// `UnitKindId` order; with every factor at 1000 it is the personality's +/// mix times 1000. +pub fn weighted_mix( + rules: &Rules, + mix: &[(UnitKindId, u32)], + enemy: &BTreeMap, + knobs: &Counters, + trainable: impl Fn(UnitKindId) -> bool, +) -> Vec<(UnitKindId, u32)> { + let scale = |w: u32, f: u32| -> u32 { w.saturating_mul(f) }; + let mut out: BTreeMap = BTreeMap::new(); + for (k, w) in mix { + out.insert(*k, scale(*w, factor(rules, *k, enemy, knobs))); + } + for (k, w) in &knobs.kinds { + if out.contains_key(k) { + continue; + } + let f = factor(rules, *k, enemy, knobs); + if f >= knobs.join_permille && trainable(*k) { + out.insert(*k, scale(*w, f)); + } + } + out.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::unit_kind; + + fn rules() -> Rules { + crate::testkit::rules() + } + + /// The M6 plan's shared starting values. + fn knobs(r: &Rules) -> Counters { + Counters { + weight_permille: 750, + floor_permille: 250, + cap_permille: 4000, + join_permille: 1500, + kinds: vec![(unit_kind(r, "outrider"), 2)], + } + } + + /// The `standard` personality's mix, as kind ids. + fn standard_mix(r: &Rules) -> Vec<(UnitKindId, u32)> { + let p = r.ai.personality("standard").unwrap(); + p.army_mix + .iter() + .map(|m| (unit_kind(r, &m.kind), m.weight)) + .collect() + } + + fn weight_of(mix: &[(UnitKindId, u32)], k: UnitKindId) -> u32 { + mix.iter().find(|(x, _)| *x == k).map_or(0, |(_, w)| *w) + } + + #[test] + fn shares_are_per_mille_of_the_armed_units() { + let r = rules(); + let (bow, skirm) = (unit_kind(&r, "bowman"), unit_kind(&r, "skirmisher")); + assert!(shares(&BTreeMap::new()).is_empty()); + let s = shares(&BTreeMap::from([ + (bow, 3), + (skirm, 1), + (unit_kind(&r, "outrider"), 0), + ])); + assert_eq!(s, BTreeMap::from([(bow, 750), (skirm, 250)])); + } + + /// M6: against an all-Bowman army the Skirmisher (2500 per mille on + /// Bowmen) gains, the Shieldbearer (which Bowmen hit at 3000) drops to + /// the floor, and the Skirmisher becomes the heaviest entry of the + /// Standard mix. + #[test] + fn counter_weights_turn_bowmen_into_skirmishers() { + let r = rules(); + let k = knobs(&r); + let (bow, skirm, shield) = ( + unit_kind(&r, "bowman"), + unit_kind(&r, "skirmisher"), + unit_kind(&r, "shieldbearer"), + ); + let enemy = shares(&BTreeMap::from([(bow, 10)])); + assert_eq!(advantage(&r, skirm, &enemy), 1500); + assert_eq!(factor(&r, skirm, &enemy, &k), 1000 + 750 * 1500 / 1000); + assert_eq!(advantage(&r, bow, &enemy), 0); + assert_eq!(factor(&r, bow, &enemy, &k), 1000); + assert_eq!(advantage(&r, shield, &enemy), -2000); + assert_eq!(factor(&r, shield, &enemy, &k), 250, "the floor"); + let base = standard_mix(&r); + let mix = weighted_mix(&r, &base, &enemy, &k, |_| true); + let heaviest = mix + .iter() + .max_by_key(|(id, w)| (*w, std::cmp::Reverse(*id))); + assert_eq!(heaviest.map(|(id, _)| *id), Some(skirm), "{mix:?}"); + assert_eq!(weight_of(&mix, skirm), weight_of(&base, skirm) * 2125); + assert_eq!(weight_of(&mix, bow), weight_of(&base, bow) * 1000); + assert_eq!( + weight_of(&mix, shield), + weight_of(&base, shield) * 250, + "a quarter of its weight, not rounded away" + ); + } + + /// M6: the Outrider is not in the Standard mix; it joins with its + /// listed weight against Mangonels (3000 per mille on them), only while + /// it is trainable, and never against Shieldbearers, which counter it. + #[test] + fn counter_kinds_join_only_through_counters() { + let r = rules(); + let k = knobs(&r); + let (outrider, mangonel, shield) = ( + unit_kind(&r, "outrider"), + unit_kind(&r, "mangonel"), + unit_kind(&r, "shieldbearer"), + ); + let base = standard_mix(&r); + assert_eq!(weight_of(&base, outrider), 0, "not in the M5b mix"); + let siege = shares(&BTreeMap::from([(mangonel, 4)])); + assert!(factor(&r, outrider, &siege, &k) >= k.join_permille); + let joined = weighted_mix(&r, &base, &siege, &k, |_| true); + assert_eq!( + weight_of(&joined, outrider), + 2 * factor(&r, outrider, &siege, &k) + ); + let untrainable = weighted_mix(&r, &base, &siege, &k, |x| x != outrider); + assert_eq!(weight_of(&untrainable, outrider), 0, "not trainable now"); + let shields = shares(&BTreeMap::from([(shield, 4)])); + assert!(factor(&r, outrider, &shields, &k) < k.join_permille); + let not = weighted_mix(&r, &base, &shields, &k, |_| true); + assert_eq!(weight_of(¬, outrider), 0); + assert!(not.iter().all(|(id, _)| base.iter().any(|(b, _)| b == id))); + } + + /// M6: a counter weight of 0, or no visible enemy army, keeps the + /// personality's mix exactly. + #[test] + fn counter_weight_zero_keeps_the_mix() { + let r = rules(); + let base = standard_mix(&r); + let mut sorted: Vec<(UnitKindId, u32)> = base.iter().map(|(k, w)| (*k, w * 1000)).collect(); + sorted.sort_by_key(|(k, _)| *k); + let off = Counters { + weight_permille: 0, + ..knobs(&r) + }; + let bows = shares(&BTreeMap::from([(unit_kind(&r, "bowman"), 7)])); + assert_eq!(weighted_mix(&r, &base, &bows, &off, |_| true), sorted); + assert_eq!( + weighted_mix(&r, &base, &BTreeMap::new(), &knobs(&r), |_| true), + sorted, + "no enemy army: every factor is 1000" + ); + } +} diff --git a/crates/ai/src/standard/ctx.rs b/crates/ai/src/standard/ctx.rs index 371dc1c..d90058f 100644 --- a/crates/ai/src/standard/ctx.rs +++ b/crates/ai/src/standard/ctx.rs @@ -2,11 +2,45 @@ //! plan's rules from spending the same stock twice. use super::facts::Facts; +use super::influence::{Influence, StatsCache}; use super::sites::MapAnalysis; -use rules::{BuildOrder, Personality}; -use sim::{BuildingKindId, Command, FxVec2, PlayerId, RejectReason, SimView, Tile, Unit, UnitId}; +use rules::{BuildOrder, FirstRaid, Personality, Pressure}; +use sim::{ + BuildingKindId, Command, FxVec2, PlayerId, RejectReason, SimView, Tile, Unit, UnitId, + UnitKindId, UnitStats, +}; use std::collections::{BTreeMap, BTreeSet}; +/// The salts of the bot's noise (M6, [`Ctx::noise`]): one per decision, +/// each combined with what makes the decision stable across plans (a wave +/// count, a step or kind index), so no draw needs remembering. +pub mod salt { + /// The wave interval after launch `n`. + pub const WAVE_INTERVAL: u64 = 1 << 40; + /// The first wave's extra wait. + pub const FIRST_WAVE: u64 = 2 << 40; + /// The upward wave-size bonus of launch `n`. + pub const WAVE_UNITS: u64 = 3 << 40; + /// A mix weight (kind index, then launch count). + pub const MIX: u64 = 4 << 40; + /// The site pick of build-order step `i`. + pub const SITE: u64 = 5 << 40; + /// The target pick of launch `n`. + pub const TARGET: u64 = 6 << 40; +} + +/// `n` jittered by up to `permille` either way with `noise`: +/// `n + n x ((noise mod (2p + 1)) - p) / 1000`, never negative. +pub fn jitter(n: u32, noise: u64, permille: u32) -> u32 { + if permille == 0 || n == 0 { + return n; + } + let span = u64::from(permille) * 2 + 1; + let off = i64::try_from(noise % span).expect("small") - i64::from(permille); + let v = i64::from(n) + i64::from(n) * off / 1000; + u32::try_from(v.max(0)).unwrap_or(u32::MAX) +} + /// Where `u` is headed or holds: its order's goal, else its post's goal. /// (A unit's order becomes a post on arrival, so "has an order goal near /// X" alone is not a stable fact.) @@ -111,9 +145,20 @@ pub struct Ctx<'a> { /// later site of the same plan keeps its gap from them /// ([`super::sites::fits`]). pub planned: Vec<(i32, i32, i32, i32)>, + /// The difficulty's deadline raid (M6), set by the bot after + /// [`Ctx::new`] (`None`: no deadline raid). + pub first_raid: Option, + /// How the difficulty presses its sieges (M6 owner decision 12), set + /// by the bot after [`Ctx::new`] ([`Pressure::OFF`]: none). + pub pressure: Pressure, + /// Unit stats per `(owner, kind)`, resolved once per plan (M6). + pub stats: StatsCache, + /// Whether this plan built the influence maps (bench counter). + pub influence_built: bool, out: Vec, occupied: Option>, rally: Option, + influence: Option, } impl<'a> Ctx<'a> { @@ -150,10 +195,42 @@ impl<'a> Ctx<'a> { probes_made: 0, commands_left: knobs.max_commands_per_think, planned: Vec::new(), + first_raid: None, + pressure: Pressure::OFF, + stats: StatsCache::new(), + influence_built: false, out: Vec::new(), occupied: None, rally: None, + influence: None, + } + } + + /// The plan's influence maps (M6), built on first use with the + /// personality's cell size and kept for the rest of the plan. + pub fn influence(&mut self) -> &Influence { + if self.influence.is_none() { + let built = Influence::build( + &self.view, + self.me, + self.knobs.influence_cell_tiles, + &mut self.stats, + ); + self.influence = Some(built); + self.influence_built = true; } + self.influence.as_ref().expect("just built") + } + + /// `owner`'s stats for `kind`, through the plan's cache (M6). + pub fn unit_stats(&mut self, owner: PlayerId, kind: UnitKindId) -> UnitStats { + self.stats.get(&self.view, owner, kind) + } + + /// The bot's noise for `salt` (M6): `SimView::ai_noise`, a pure hash + /// of the hashed seed, the player and the salt. + pub fn noise(&self, salt: u64) -> u64 { + self.view.ai_noise(self.me, salt) } /// The plan's tick. diff --git a/crates/ai/src/standard/defend.rs b/crates/ai/src/standard/defend.rs index 51d9a9b..7a3d5a7 100644 --- a/crates/ai/src/standard/defend.rs +++ b/crates/ai/src/standard/defend.rs @@ -32,7 +32,7 @@ use super::army::home_armed; use super::ctx::{Ctx, on_station}; -use super::facts::{kind_centre, tiles_sq}; +use super::facts::{kind_centre, tiles_sq, visible_enemies}; use super::memory::{BotMemory, Defend, WavePhase}; use super::waves::launch_due; use rules::{RecallWave, UnitClass}; @@ -49,32 +49,58 @@ pub const SIEGE_GUARDS: usize = 4; /// non-civilian unit in the radius counts, own land or not. pub fn threats(ctx: &Ctx<'_>) -> Vec<(BuildingId, Vec, u32)> { let rules = ctx.view.rules(); + let view = ctx.view; let r2 = tiles_sq(ctx.knobs.defend_radius_tiles); let military = |u: &sim::Unit| { rules .unit_kind(u.kind.0) .is_some_and(|k| k.class != UnitClass::Civilian) }; + // M6: with `defend_economy`, the centres of own worked gathering + // buildings, by the Town they are attributed to. + let e2 = tiles_sq(ctx.knobs.defend_economy_tiles); + let mut worked: BTreeMap> = BTreeMap::new(); + if ctx.knobs.defend_economy { + for node in ctx.facts.nodes.iter().flatten().filter(|n| n.taken > 0) { + if let Some(t) = view.building(node.id).and_then(|b| b.town) { + worked.entry(t).or_default().push(node.centre); + } + } + } ctx.facts .towns .iter() .filter_map(|t| { - let b = ctx.view.building(*t)?; + let b = view.building(*t)?; let c = kind_centre(rules, b.kind, b.tile); + let nodes = worked.get(t).map_or(&[][..], Vec::as_slice); + let near_node = |u: &sim::Unit| nodes.iter().any(|n| u.pos.dist_sq_i64(*n) <= e2); let near = |u: &&sim::Unit| u.pos.dist_sq_i64(c) <= r2; let units: Vec<&sim::Unit> = if ctx.facts.contested.contains(t) { - ctx.view - .units() - .filter(|u| u.owner != ctx.me && military(u)) - .filter(near) + visible_enemies(&view, ctx.me) + .filter(|u| military(u)) + .filter(|u| near(u) || near_node(u)) .collect() } else { - ctx.facts + let mut on_land: Vec<&sim::Unit> = ctx + .facts .threats .iter() - .filter_map(|id| ctx.view.unit(*id)) + .filter_map(|id| view.unit(*id)) .filter(near) - .collect() + .collect(); + if !nodes.is_empty() { + // Raiders at a worked building count, own land or not. + on_land.extend( + visible_enemies(&view, ctx.me) + .filter(|u| military(u) && near_node(u)) + .filter(|u| { + !(u.pos.dist_sq_i64(c) <= r2 && ctx.facts.threats.contains(&u.id)) + }), + ); + on_land.sort_by_key(|u| u.id); + } + on_land }; let pop = units.iter().fold(0u32, |p, u| { p.saturating_add(rules.unit_kind(u.kind.0).map_or(1, |k| k.pop)) @@ -211,18 +237,25 @@ pub fn step(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { let view = ctx.view; let pos = |id: UnitId| view.unit(id).map(|u| u.pos); // Siege first: each enemy siege unit gets its nearest free guards. + // M6: with `defend_wains_first`, enemy Supply Wains among the threats + // come before it, so the attacker's attrition cover goes first. let mut busy: BTreeSet = BTreeSet::new(); let mut attacks: BTreeMap> = BTreeMap::new(); - for e in &enemies { + let kind_of = |e: UnitId| ctx.view.unit(e).and_then(|u| rules.unit_kind(u.kind.0)); + let wains_first = ctx.knobs.defend_wains_first; + let mut marked: Vec = enemies + .iter() + .copied() + .filter(|e| wains_first && kind_of(*e).is_some_and(|k| k.supplies)) + .collect(); + marked.extend( + enemies + .iter() + .copied() + .filter(|e| kind_of(*e).is_some_and(|k| k.class == UnitClass::Siege)), + ); + for e in &marked { let Some(ep) = pos(*e) else { continue }; - let siege = ctx - .view - .unit(*e) - .and_then(|u| rules.unit_kind(u.kind.0)) - .is_some_and(|k| k.class == UnitClass::Siege); - if !siege { - continue; - } let mut near: Vec<(i64, UnitId)> = guard .iter() .filter(|g| !busy.contains(g)) @@ -351,6 +384,9 @@ fn release(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { } mem.defend = None; mem.defends = mem.defends.saturating_add(1); + if u32::from(mem.extra_towers) < ctx.knobs.reactive_towers { + mem.last_defended = Some(d.town); + } let rally = ctx.rally(); let tol = ctx.knobs.goal_tolerance_tiles; let back: Vec = home_armed(ctx, mem) @@ -499,6 +535,9 @@ mod tests { last_threat: tick - 10, since: tick - latch_age, }), + // M6: past the first wave's noise wait (its own test is + // in waves.rs). + next_wave_at: 1, ..BotMemory::default() }; let mut ctx = a.ctx(P0); @@ -644,6 +683,7 @@ mod tests { } fn wave(units: Vec, target: BuildingId, phase: WavePhase) -> Wave { + let launched_units = u16::try_from(units.len()).unwrap(); Wave { units, wain: None, @@ -652,6 +692,11 @@ mod tests { phase, launched_at: 0, phase_at: 0, + kind: crate::standard::memory::WaveKind::Siege, + launched_units, + last_contact_at: 0, + target_hp: 0, + progress_at: 0, } } @@ -894,4 +939,159 @@ mod tests { "{cmds:?}" ); } + + /// M6: with `defend_economy`, enemy military within + /// `defend_economy_tiles` of an own worked gathering building is a + /// threat to the Town it belongs to, though it stands outside + /// `defend_radius_tiles` of the Town; without it, it is not. + #[test] + fn defend_economy_answers_a_raid_on_a_lumber_yard() { + let r = crate::testkit::rules(); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + place(&r, P0, "croft", 24, 82), + spawn(&r, P0, "yeoman", 24, 80, 1), + ], + ); + let croft = a.buildings_of(P0, "croft")[0]; + let yeoman = *a.units_of(P0, "yeoman").last().unwrap(); + a.issue( + P0, + vec![Command::Gather { + units: vec![yeoman], + node: croft, + }], + ); + a.issue(P0, vec![spawn(&r, P1, "skirmisher", 26, 88, 2)]); + let raiders = a.units_of(P1, "skirmisher"); + let seat = a.seat(P0); + let ctx = a.ctx(P0); + assert!(ctx.knobs.defend_economy); + let centre = a + .sim + .view() + .building(seat) + .map(|b| kind_centre(&r, b.kind, b.tile)) + .unwrap(); + for u in &raiders { + let p = a.sim.view().unit(*u).unwrap().pos; + assert!(p.dist_sq_i64(centre) > tiles_sq(ctx.knobs.defend_radius_tiles)); + } + let found = threats(&ctx); + assert_eq!(found, vec![(seat, raiders.clone(), 2)]); + assert_eq!(engaged(&found), Some(seat)); + let mut off = ctx.knobs.clone(); + off.defend_economy = false; + let ctx = crate::standard::Ctx::new(a.sim.view(), P0, &off, a.map(P0)); + assert_eq!(engaged(&threats(&ctx)), None, "without the rule"); + } + + /// M6: with `defend_wains_first` the guards nearest an enemy Supply + /// Wain among the threats attack it explicitly; without it the Wain + /// gets no explicit attack. + #[test] + fn tower_guards_attack_wains_first() { + let r = crate::testkit::rules(); + let a = Arena::frozen( + standard_vs_passive(&r), + vec![ + spawn(&r, P0, "skirmisher", 30, 64, 6), + spawn(&r, P1, "supply_wain", 33, 58, 1), + spawn(&r, P1, "skirmisher", 33, 70, 2), + ], + ); + let wain = a.units_of(P1, "supply_wain")[0]; + let tower = r.ai.personality("tower").unwrap(); + assert!(tower.defend_wains_first); + let mut k = r.ai.personality("standard").unwrap().clone(); + k.defend_wains_first = true; + let mut ctx = crate::standard::Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + let cmds = ctx.into_commands(); + let on_wain: Vec<&Command> = cmds + .iter() + .filter( + |c| matches!(c, Command::Attack { target, .. } if *target == TargetId::Unit(wain)), + ) + .collect(); + assert_eq!(on_wain.len(), 1, "{cmds:?}"); + let Command::Attack { units, .. } = on_wain[0] else { + unreachable!() + }; + assert_eq!(units.len(), SIEGE_GUARDS); + k.defend_wains_first = false; + let mut ctx = crate::standard::Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + assert!( + !ctx.into_commands().iter().any( + |c| matches!(c, Command::Attack { target, .. } if *target == TargetId::Unit(wain)) + ), + "without the rule" + ); + } + + /// M6: a released defence with `reactive_towers` left marks the + /// defended Town, and the plan's build-order rule then places one + /// Watchtower near it (counting it); a personality with none places + /// nothing. + #[test] + fn reactive_tower_after_a_defence() { + let r = crate::testkit::rules(); + let a = Arena::frozen(standard_vs_passive(&r), grant_all(&r, P0, 200)); + let seat = a.seat(P0); + let tick = a.sim.tick(); + let mut k = r.ai.personality("standard").unwrap().clone(); + k.reactive_towers = 1; + let released = BotMemory { + defend: Some(Defend { + town: seat, + last_threat: 0, + since: 0, + }), + ..BotMemory::default() + }; + assert!(tick >= a.sim.rules().ticks_from_ds(k.defend_release_ds) || tick > 0); + let mut a = a; + a.run(400); + let mut ctx = crate::standard::Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + let mut mem = released.clone(); + step(&mut ctx, &mut mem); + assert_eq!((mem.defend, mem.last_defended), (None, Some(seat))); + crate::standard::build_order::reactive_tower(&mut ctx, &mut mem); + let tower = sim::BuildingKindId(r.building_kind_by_id("watchtower").unwrap().0); + let cmds = ctx.into_commands(); + let built: Vec = cmds + .iter() + .filter_map(|c| match c { + Command::Build { kind, tile, .. } if *kind == tower => Some(*tile), + _ => None, + }) + .collect(); + assert_eq!(built.len(), 1, "{cmds:?}"); + let c = a + .sim + .view() + .building(seat) + .map(|b| kind_centre(&r, b.kind, b.tile)) + .unwrap(); + let at = a.sim.view().map().center_of(built[0]); + assert!(at.dist_sq_i64(c) <= tiles_sq(k.near_town_max_tiles + 1)); + assert_eq!((mem.extra_towers, mem.last_defended), (1, None)); + // Once the allowance is spent, a release marks nothing. + let mut ctx = crate::standard::Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + let mut mem = BotMemory { + extra_towers: 1, + ..released.clone() + }; + step(&mut ctx, &mut mem); + assert_eq!(mem.last_defended, None); + // The standard personality has none. + let mut ctx = a.ctx(P0); + let mut mem = released; + step(&mut ctx, &mut mem); + assert_eq!(mem.last_defended, None); + } } diff --git a/crates/ai/src/standard/facts.rs b/crates/ai/src/standard/facts.rs index 70597ff..8b5970e 100644 --- a/crates/ai/src/standard/facts.rs +++ b/crates/ai/src/standard/facts.rs @@ -4,10 +4,36 @@ use rules::{Rules, UnitClass}; use sim::{ BuildingId, BuildingKindId, Construction, Fx, FxVec2, Hold, Job, PlayerId, QueueItem, SimView, - TechId, Tile, UnitId, UnitKindId, + TechId, Tile, Unit, UnitId, UnitKindId, }; use std::collections::{BTreeMap, BTreeSet}; +/// The other players' units the bot may see (M6: the fog seam). Every read +/// of enemy units by the bot's rules goes through here or through +/// [`Facts::enemy`], so M7's fog of war filters in one place; until then +/// the bot sees every unit (docs/design/fog.md lists the non-spatial +/// reads: enemy techs, Harrying, worked slots). +pub fn visible_enemies<'a>( + view: &SimView<'a>, + me: PlayerId, +) -> impl Iterator + 'a { + view.units().filter(move |u| u.owner != me) +} + +/// What one plan knows of the enemy (M6), gathered with the [`Facts`] +/// through the same seam as [`visible_enemies`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Enemies { + /// Armed (non-civilian, attacking) enemy units alive per kind: the + /// counter rule's input. + pub armed: BTreeMap, + /// Enemy complete gathering buildings with at least one unit on a + /// gather job there, and that count, in id order: the raid targets. + pub worked: Vec<(BuildingId, u32)>, + /// The highest Harrying level of any other player (the Wain rule). + pub harrying: u32, +} + /// The centre of a `w` x `h` footprint whose north-west tile is `nw`, in /// world coordinates (a 3x3 footprint's centre is the middle of its middle /// tile). @@ -141,6 +167,8 @@ pub struct Facts { pub enemy_shooters: Vec, /// Non-civilian units of the other players on own land. pub threats: Vec, + /// The enemy as the bot sees it (M6). + pub enemy: Enemies, } impl Facts { @@ -199,16 +227,36 @@ impl Facts { enemy_towns: Vec::new(), enemy_shooters: Vec::new(), threats: Vec::new(), + enemy: Enemies { + harrying: view + .players() + .iter() + .filter(|p| p.id != me) + .map(|p| view.harrying_level(p.id)) + .max() + .unwrap_or(0), + ..Enemies::default() + }, }; let territory = view.territory(); let map = view.map(); let mut taken: BTreeMap = BTreeMap::new(); + let mut enemy_taken: BTreeMap = BTreeMap::new(); + for u in visible_enemies(view, me) { + let k = flag(u.kind); + if !k.civilian && territory.owner_at(map.tile_of(u.pos)) == Some(me) { + f.threats.push(u.id); + } + if k.armed { + *f.enemy.armed.entry(u.kind).or_default() += 1; + } + if let Some(Job::Gather { node, .. }) = u.job { + *enemy_taken.entry(node).or_default() += 1; + } + } for u in view.units() { let k = flag(u.kind); if u.owner != me { - if !k.civilian && territory.owner_at(map.tile_of(u.pos)) == Some(me) { - f.threats.push(u.id); - } continue; } *f.alive.entry(u.kind).or_default() += 1; @@ -244,6 +292,12 @@ impl Facts { if complete && b.kind == town_kind { f.enemy_towns.push(b.id); } + if complete + && let Some(n) = enemy_taken.get(&b.id).copied() + && n > 0 + { + f.enemy.worked.push((b.id, n)); + } let shoots = rules .building_kind(b.kind.0) .is_some_and(|d| d.attack.is_some()); @@ -314,3 +368,36 @@ impl Facts { self.buildings.get(&kind).copied().unwrap_or(0) } } + +#[cfg(test)] +mod tests { + /// The fog seam (M6 plan 2b.6, docs/design/fog.md): no rule of the + /// Standard bot iterates every unit except through [`super::visible_enemies`]. + /// The only other `units()` reads are the seam itself, the own-unit + /// passes of `Facts::gather` and of the influence maps, and + /// `Ctx::occupied`, a placement-legality read (the sim's `SiteOccupied` + /// counts any player's unit) that fog.md lists for M7 to classify. A + /// new read of every unit elsewhere would see through M7's fog. + #[test] + fn enemy_units_are_read_only_through_the_seam() { + let needle = concat!(".", "units()"); + let files: [(&str, &str, usize); 13] = [ + ("army.rs", include_str!("army.rs"), 0), + ("build_order.rs", include_str!("build_order.rs"), 0), + ("compose.rs", include_str!("compose.rs"), 0), + ("ctx.rs", include_str!("ctx.rs"), 1), + ("defend.rs", include_str!("defend.rs"), 0), + ("economy.rs", include_str!("economy.rs"), 0), + ("facts.rs", include_str!("facts.rs"), 2), + ("influence.rs", include_str!("influence.rs"), 1), + ("memory.rs", include_str!("memory.rs"), 0), + ("mod.rs", include_str!("mod.rs"), 0), + ("retreat.rs", include_str!("retreat.rs"), 0), + ("sites.rs", include_str!("sites.rs"), 0), + ("waves.rs", include_str!("waves.rs"), 0), + ]; + for (name, src, want) in files { + assert_eq!(src.matches(needle).count(), want, "{name}: units() reads"); + } + } +} diff --git a/crates/ai/src/standard/influence.rs b/crates/ai/src/standard/influence.rs new file mode 100644 index 0000000..9e1592a --- /dev/null +++ b/crates/ai/src/standard/influence.rs @@ -0,0 +1,460 @@ +//! Influence maps (M6): per-plan integer grids of army strength, computed +//! in `crates/ai` from the view and never stored. +//! +//! M6 settles where the maps live (docs/design/ai.md, "Influence maps and +//! counter-weighted composition"; docs/DETERMINISM.md): not as an unhashed +//! sim cache refreshed every 10 ticks (which a restore between two +//! refreshes could not reproduce) but as bot state of one plan, built from +//! the view the first time a plan asks for them (`Ctx::influence`). So +//! they are not hashed, need no restore, and a bot rebuilt mid-match reads +//! the same grids. Retreat, the siege target score and the raid target +//! read them; enemy units come through `facts::visible_enemies`, the seam +//! M7's fog filters. +//! +//! The map is cut into `cell` x `cell`-tile cells (`ceil(map / cell)` per +//! side, 32 x 32 on `plains_1v1` at 4 tiles). Each [`Grid`] holds, per +//! cell, the summed current hit points and the summed damage per second +//! (times ten) of what stands in it: +//! +//! - `own`: the planning player's armed units; +//! - `enemy_units`: every other player's armed units; +//! - `enemy_shooters`: every other player's complete buildings with an +//! attack (Towns and Watchtowers) that still have hit points. +//! +//! An armed unit is one whose kind attacks and is not a civilian, so +//! Yeomen, Scribes and Supply Wains add nothing. A unit adds its current +//! hit points (Arms rows included) and `dps_x10 = 100 x damage / +//! cooldown_ds` with its resolved damage, read once per `(owner, kind)` +//! through a [`StatsCache`]; a building adds its hit points and its +//! `buildings.ron` attack (no Modifier row touches buildings). +//! +//! [`Influence::strength`] is the Lanchester estimate of a block of cells, +//! `sum(hp) x sum(dps_x10)` in `i64`, and [`isqrt`] turns it into an army +//! scale where a score needs one. Everything is integer arithmetic, in +//! id order, with no randomness. + +use rules::{Rules, UnitClass}; +use sim::{Construction, FxVec2, PlayerId, SimView, UnitKindId, UnitStats}; +use std::collections::BTreeMap; + +/// One layer of an [`Influence`]: per cell, row-major, the summed hit +/// points and the summed damage per second times ten. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Grid { + /// Summed current hit points per cell. + pub hp: Vec, + /// Summed damage per second, times ten, per cell. + pub dps_x10: Vec, +} + +impl Grid { + fn new(cells: usize) -> Grid { + Grid { + hp: vec![0; cells], + dps_x10: vec![0; cells], + } + } + + fn add(&mut self, cell: usize, hp: i32, dps_x10: i32) { + if let (Some(h), Some(d)) = (self.hp.get_mut(cell), self.dps_x10.get_mut(cell)) { + *h = h.saturating_add(hp.max(0)); + *d = d.saturating_add(dps_x10.max(0)); + } + } + + /// Summed hit points over every cell. + pub fn total_hp(&self) -> i64 { + self.hp.iter().map(|v| i64::from(*v)).sum() + } + + /// Summed damage per second times ten over every cell. + pub fn total_dps_x10(&self) -> i64 { + self.dps_x10.iter().map(|v| i64::from(*v)).sum() + } +} + +/// The combat stats of `(owner, kind)` pairs, resolved through the view's +/// Modifier rows once per plan (a plan reads at most a few kinds per +/// player, so the cache stays small). +#[derive(Debug, Clone, Default)] +pub struct StatsCache { + stats: BTreeMap<(PlayerId, UnitKindId), UnitStats>, + resolved: u32, +} + +impl StatsCache { + /// An empty cache. + pub fn new() -> StatsCache { + StatsCache::default() + } + + /// `owner`'s stats for `kind`, resolved on the first ask of the pair. + pub fn get(&mut self, view: &SimView<'_>, owner: PlayerId, kind: UnitKindId) -> UnitStats { + if let Some(s) = self.stats.get(&(owner, kind)) { + return *s; + } + let s = view.unit_stats(owner, kind); + self.resolved = self.resolved.saturating_add(1); + self.stats.insert((owner, kind), s); + s + } + + /// How many `(owner, kind)` pairs were resolved (each once). + pub fn resolved(&self) -> u32 { + self.resolved + } +} + +/// Damage per second times ten of `damage` every `cooldown_ds` +/// deciseconds: `100 x damage / cooldown_ds`, 0 for a zero cooldown. +pub fn dps_x10(damage: i32, cooldown_ds: u32) -> i32 { + if cooldown_ds == 0 { + return 0; + } + let v = i64::from(damage.max(0)) * 100 / i64::from(cooldown_ds); + i32::try_from(v).unwrap_or(i32::MAX) +} + +/// The integer square root, `floor(sqrt(n))`. +pub fn isqrt(n: u64) -> u64 { + if n < 2 { + return n; + } + // Newton's method from an over-estimate; it decreases monotonically + // to the floor root. + let mut x = n; + let mut y = x.div_ceil(2); + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} + +/// One plan's influence maps (see the module doc). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Influence { + /// Cells per row. + pub w: u32, + /// Cells per column. + pub h: u32, + /// Cell side in tiles. + pub cell: u32, + /// The planning player's armed units. + pub own: Grid, + /// Every other player's armed units. + pub enemy_units: Grid, + /// Every other player's complete shooting buildings with hit points. + pub enemy_shooters: Grid, +} + +impl Influence { + /// Build the maps for `me` from `view` with `cell_tiles`-tile cells (at + /// least 1): one pass over the units and one over the buildings, unit + /// stats through `stats`. + pub fn build( + view: &SimView<'_>, + me: PlayerId, + cell_tiles: u32, + stats: &mut StatsCache, + ) -> Influence { + let rules = view.rules(); + let cell = cell_tiles.max(1); + let map = view.map(); + let w = u32::from(map.width()).div_ceil(cell).max(1); + let h = u32::from(map.height()).div_ceil(cell).max(1); + let cells = usize::try_from(w * h).expect("a few thousand cells"); + let mut out = Influence { + w, + h, + cell, + own: Grid::new(cells), + enemy_units: Grid::new(cells), + enemy_shooters: Grid::new(cells), + }; + let armed = armed_kinds(rules); + // Own units, then the enemy's through the fog seam. + let own = view.units().filter(|u| u.owner == me); + for u in own.chain(super::facts::visible_enemies(view, me)) { + if !armed.get(usize::from(u.kind.0)).copied().unwrap_or(false) { + continue; + } + let s = stats.get(view, u.owner, u.kind); + let hp = s.max_hp.saturating_sub(u.damage_taken); + if hp <= 0 { + continue; + } + let cooldown_ds = rules + .unit_kind(u.kind.0) + .and_then(|k| k.attack.as_ref()) + .map_or(0, |a| a.cooldown_ds); + let dps = s.attack.map_or(0, |a| dps_x10(a.damage, cooldown_ds)); + let at = out.cell_index(out.cell_at(u.pos)); + if u.owner == me { + out.own.add(at, hp, dps); + } else { + out.enemy_units.add(at, hp, dps); + } + } + for b in view.buildings() { + if b.owner == me || b.construction != Construction::Complete || b.hp <= 0 { + continue; + } + let Some(kind) = rules.building_kind(b.kind.0) else { + continue; + }; + let Some(attack) = kind.attack.as_ref() else { + continue; + }; + let damage = i32::try_from(attack.damage).unwrap_or(i32::MAX); + let centre = sim::construction::centre_fx(kind, b.tile); + let at = out.cell_index(out.cell_at(centre)); + out.enemy_shooters + .add(at, b.hp, dps_x10(damage, attack.cooldown_ds)); + } + out + } + + /// The cell `(cx, cy)` holding world position `pos`, clamped to the + /// grid. + pub fn cell_at(&self, pos: FxVec2) -> (u32, u32) { + let clamp = |v: i32, n: u32| -> u32 { + let v = u32::try_from(v.max(0)).unwrap_or(0) / self.cell; + v.min(n.saturating_sub(1)) + }; + ( + clamp(pos.x.floor_to_int(), self.w), + clamp(pos.y.floor_to_int(), self.h), + ) + } + + /// The row-major index of cell `(cx, cy)`. + pub fn cell_index(&self, (cx, cy): (u32, u32)) -> usize { + usize::try_from(cy * self.w + cx).expect("a few thousand cells") + } + + /// The Lanchester strength of `grid` over the `(2r + 1)`-cell square + /// block centred on `cell` (clipped to the map): `sum(hp) x + /// sum(dps_x10)`. + pub fn strength(&self, grid: &Grid, (cx, cy): (u32, u32), r: u32) -> i64 { + let (mut hp, mut dps) = (0i64, 0i64); + let x0 = cx.saturating_sub(r); + let y0 = cy.saturating_sub(r); + let x1 = cx.saturating_add(r).min(self.w.saturating_sub(1)); + let y1 = cy.saturating_add(r).min(self.h.saturating_sub(1)); + for y in y0..=y1 { + for x in x0..=x1 { + let i = self.cell_index((x, y)); + hp += i64::from(grid.hp.get(i).copied().unwrap_or(0)); + dps += i64::from(grid.dps_x10.get(i).copied().unwrap_or(0)); + } + } + hp.saturating_mul(dps) + } +} + +/// Per unit kind: armed (it attacks) and not a civilian. +fn armed_kinds(rules: &Rules) -> Vec { + rules + .units + .iter() + .map(|u| u.attack.is_some() && u.class != UnitClass::Civilian) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::{ + Arena, P0, P1, building_kind, place, research, spawn, standard_vs_passive, unit_kind, + }; + use sim::{BuildingId, Command, TargetId}; + + fn rules() -> Rules { + crate::testkit::rules() + } + + #[test] + fn isqrt_and_dps_are_exact() { + for (n, r) in [ + (0, 0), + (1, 1), + (3, 1), + (4, 2), + (99, 9), + (100, 10), + (u64::MAX, 4_294_967_295), + ] { + assert_eq!(isqrt(n), r, "{n}"); + } + assert_eq!(dps_x10(12, 15), 80, "12 every 1.5 s is 8.0 a second"); + assert_eq!(dps_x10(10, 0), 0); + assert_eq!(dps_x10(-5, 10), 0); + } + + /// M6: a unit adds its current hit points and its damage per second + /// times ten to its own cell, own and enemy layers apart; Arms I raises + /// both by its 1100 per mille rows, and the cache resolves each + /// `(owner, kind)` once. + #[test] + fn influence_sums_hp_and_dps_per_cell_with_arms_upgrades() { + let r = rules(); + let skirmisher = unit_kind(&r, "skirmisher"); + let def = r.unit_kind(skirmisher.0).unwrap(); + let attack = def.attack.as_ref().unwrap(); + let base_dps = dps_x10(i32::try_from(attack.damage).unwrap(), attack.cooldown_ds); + let base_hp = i32::try_from(def.hp).unwrap(); + let a = Arena::frozen( + standard_vs_passive(&r), + vec![ + // Two own Skirmishers in the cell of tiles 40..44 x 60..64, + // one enemy Skirmisher with Arms I four cells east. + spawn(&r, P0, "skirmisher", 41, 61, 1), + spawn(&r, P0, "skirmisher", 42, 62, 1), + research(&r, P1, "arms_1"), + spawn(&r, P1, "skirmisher", 57, 61, 1), + ], + ); + let view = a.sim.view(); + let mut cache = StatsCache::new(); + let inf = Influence::build(&view, P0, 4, &mut cache); + assert_eq!((inf.w, inf.h, inf.cell), (32, 32, 4)); + let own = inf.cell_index((10, 15)); + assert_eq!(inf.own.hp[own], 2 * base_hp); + assert_eq!(inf.own.dps_x10[own], 2 * base_dps); + let enemy = inf.cell_index((14, 15)); + let upgraded = view.unit_stats(P1, skirmisher); + let arms_hp = upgraded.max_hp; + assert!(arms_hp > base_hp, "Arms I hit points: {arms_hp}"); + assert_eq!(inf.enemy_units.hp[enemy], arms_hp); + assert!(inf.enemy_units.dps_x10[enemy] > base_dps, "Arms I damage"); + assert_eq!( + inf.enemy_units.dps_x10[enemy], + dps_x10(upgraded.attack.unwrap().damage, attack.cooldown_ds) + ); + assert_eq!(inf.own.total_hp(), i64::from(2 * base_hp), "nothing else"); + assert_eq!(cache.resolved(), 2, "(P0, skirmisher) and (P1, skirmisher)"); + // The Lanchester block: one cell, then a block reaching the enemy. + assert_eq!( + inf.strength(&inf.own, (10, 15), 0), + i64::from(2 * base_hp) * i64::from(2 * base_dps) + ); + assert_eq!(inf.strength(&inf.enemy_units, (10, 15), 3), 0); + assert_eq!( + inf.strength(&inf.enemy_units, (10, 15), 4), + i64::from(arms_hp) * i64::from(inf.enemy_units.dps_x10[enemy]) + ); + // A second build with the same cache resolves nothing new. + let again = Influence::build(&view, P0, 4, &mut cache); + assert_eq!(again, inf); + assert_eq!(cache.resolved(), 2); + } + + /// M6: Yeomen, Supply Wains and a dead unit add nothing; a wounded unit + /// adds what it has left. + #[test] + fn influence_ignores_civilians_wains_and_the_dead() { + let r = rules(); + let skirmisher = unit_kind(&r, "skirmisher"); + let full = i32::try_from(r.unit_kind(skirmisher.0).unwrap().hp).unwrap(); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + spawn(&r, P1, "supply_wain", 70, 70, 2), + spawn(&r, P1, "skirmisher", 90, 90, 1), + spawn(&r, P1, "skirmisher", 100, 100, 1), + ], + ); + let skirmishers = a.units_of(P1, "skirmisher"); + let (dead, hurt) = (skirmishers[0], skirmishers[1]); + a.issue( + P0, + vec![ + Command::DebugDamage { + target: TargetId::Unit(dead), + amount: u32::MAX, + }, + Command::DebugDamage { + target: TargetId::Unit(hurt), + amount: 5, + }, + ], + ); + let view = a.sim.view(); + assert!(view.unit(dead).is_none(), "the dead are gone"); + let inf = Influence::build(&view, P0, 4, &mut StatsCache::new()); + assert_eq!(inf.enemy_units.total_hp(), i64::from(full - 5)); + assert_eq!(inf.own.total_hp(), 0, "the start Yeomen are civilians"); + let wains = a.units_of(P1, "supply_wain"); + assert_eq!(wains.len(), 2); + for w in wains { + let at = inf.cell_index(inf.cell_at(view.unit(w).unwrap().pos)); + assert_eq!(inf.enemy_units.hp[at], 0, "a Wain adds nothing"); + } + } + + /// M6: the shooter layer holds every complete enemy Town and + /// Watchtower with hit points, at its footprint centre, with its + /// `buildings.ron` attack; own shooters and sites add nothing. + #[test] + fn influence_shooter_layer_counts_towns_and_watchtowers() { + let r = rules(); + let tower = building_kind(&r, "watchtower"); + let town = building_kind(&r, "town"); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + place(&r, P1, "watchtower", 80, 60), + place(&r, P0, "watchtower", 40, 60), + Command::DebugPlaceBuilding { + owner: P1, + kind: tower, + tile: sim::Tile::new(80, 40), + complete: false, + }, + ], + ); + let view = a.sim.view(); + let inf = Influence::build(&view, P0, 4, &mut StatsCache::new()); + let shot = |kind: sim::BuildingKindId| { + let k = r.building_kind(kind.0).unwrap(); + let at = k.attack.as_ref().unwrap(); + ( + i32::try_from(k.hp).unwrap(), + dps_x10(i32::try_from(at.damage).unwrap(), at.cooldown_ds), + ) + }; + let (tower_hp, tower_dps) = shot(tower); + let (town_hp, town_dps) = shot(town); + assert_eq!( + inf.enemy_shooters.total_hp(), + i64::from(tower_hp + town_hp), + "P1's Seat and its complete Watchtower only" + ); + assert_eq!( + inf.enemy_shooters.total_dps_x10(), + i64::from(tower_dps + town_dps) + ); + let at = |b: BuildingId| { + let b = view.building(b).unwrap(); + let k = r.building_kind(b.kind.0).unwrap(); + inf.cell_index(inf.cell_at(sim::construction::centre_fx(k, b.tile))) + }; + let towers = a.buildings_of(P1, "watchtower"); + assert_eq!(inf.enemy_shooters.hp[at(towers[0])], tower_hp); + assert_eq!(inf.enemy_shooters.hp[at(a.seat(P1))], town_hp); + assert_eq!(inf.own.total_hp(), 0, "own buildings are not units"); + // A Town at 0 HP (annexable) no longer shoots. + let seat = a.seat(P1); + a.issue( + P0, + vec![Command::DebugDamage { + target: TargetId::Building(seat), + amount: u32::MAX, + }], + ); + let view = a.sim.view(); + let inf = Influence::build(&view, P0, 4, &mut StatsCache::new()); + assert_eq!(inf.enemy_shooters.total_hp(), i64::from(tower_hp)); + } +} diff --git a/crates/ai/src/standard/memory.rs b/crates/ai/src/standard/memory.rs index a411e0b..99600c5 100644 --- a/crates/ai/src/standard/memory.rs +++ b/crates/ai/src/standard/memory.rs @@ -3,7 +3,8 @@ //! //! Only facts that cannot be re-derived from the view live here (the wave //! roster and timers, the defend latch, the military latch, blocked-step -//! cooldowns, the chosen site). Everything else, the build-order window +//! cooldowns, the chosen site; M6 adds the raid, retreat and reactive +//! tower counters and the wave's kind, launch size and contact time). Everything else, the build-order window //! included, is recomputed every plan, so the memory stays small (it is //! hashed and snapshotted every tick) and a restore reproduces the bot's //! next decision exactly. @@ -36,6 +37,32 @@ pub struct BotMemory { pub defend: Option, /// Defences released so far. pub defends: u16, + // M6 (appended, so an M5b memory's prefix keeps its meaning). + /// Waves called home by the retreat rule ([`super::retreat`]). + pub retreats: u16, + /// Waves that ended with fewer than `recall_below_units` armed units. + pub wiped: u16, + /// Raids launched (personality raids and the deadline raid). + pub raids: u16, + /// The difficulty's deadline raid has launched. + pub raid_used: bool, + /// The tick of the last retreat (0: none yet). + pub last_retreat_at: u32, + /// The Town the last released defence was for, until a reactive + /// Watchtower goes up beside it. + pub last_defended: Option, + /// Reactive Watchtowers placed so far. + pub extra_towers: u16, +} + +/// What a wave is (M6). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum WaveKind { + /// Siege, staged, with a Supply Wain unless the Wain rule spares it. + #[default] + Siege, + /// A raid: no siege, no Wain, no stage; one attack-move to its point. + Raid, } /// An attack wave's roster and progress. @@ -57,6 +84,21 @@ pub struct Wave { /// The tick the current phase began (`stage_max_ds` runs from the /// start of a `Stage`). pub phase_at: u32, + // M6 (appended). + /// Siege wave or raid. + pub kind: WaveKind, + /// Armed units at launch (the retreat rule's loss base). + pub launched_units: u16, + /// The last tick an enemy unit stood within `raid_idle_tiles` of one + /// of the wave's units (a raid's idle timer). + pub last_contact_at: u32, + // M6 retune (appended). + /// The target Town's hit points at the last plan (a siege wave's + /// commit rule, `Pressure::commit_ds`). + pub target_hp: i32, + /// The last tick the target Town was seen losing hit points (0: not + /// yet). + pub progress_at: u32, } /// The phases of a wave. @@ -81,7 +123,8 @@ pub struct Defend { pub since: u32, } -/// The counts `sim-cli bench --bots` reads from a player's memory. +/// The counts `sim-cli bench --bots`, the play-bots CSV and the game-over +/// screen read from a player's memory (`ai::bot_stats`). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct BotStats { /// Waves launched. @@ -90,6 +133,12 @@ pub struct BotStats { pub defends: u16, /// Whether a defence is running now. pub defending: bool, + /// Raids launched (M6). + pub raids: u16, + /// Waves called home by the retreat rule (M6). + pub retreats: u16, + /// Waves that ended below `recall_below_units` (M6). + pub wiped: u16, } impl BotMemory { @@ -123,6 +172,9 @@ pub fn stats(bytes: &[u8]) -> BotStats { waves_launched: m.waves_launched, defends: m.defends, defending: m.defend.is_some(), + raids: m.raids, + retreats: m.retreats, + wiped: m.wiped, } } @@ -146,6 +198,11 @@ mod tests { phase: WavePhase::Assault, launched_at: 13_000, phase_at: 13_600, + kind: WaveKind::Raid, + launched_units: 2, + last_contact_at: 13_500, + target_hp: 1_400, + progress_at: 13_550, }), next_wave_at: 16_600, waves_launched: 1, @@ -155,6 +212,13 @@ mod tests { since: 12_900, }), defends: 2, + retreats: 3, + wiped: 4, + raids: 5, + raid_used: true, + last_retreat_at: 14_000, + last_defended: Some(BuildingId(7)), + extra_towers: 1, }; let bytes = m.encode(); assert!(!bytes.is_empty()); @@ -164,7 +228,10 @@ mod tests { BotStats { waves_launched: 1, defends: 2, - defending: true + defending: true, + raids: 5, + retreats: 3, + wiped: 4, } ); assert_eq!(stats(&[]), BotStats::default()); @@ -184,6 +251,11 @@ mod tests { phase: WavePhase::Hold, launched_at: 54_000, phase_at: 54_000, + kind: WaveKind::Siege, + launched_units: 40, + last_contact_at: 54_000, + target_hp: 2_000, + progress_at: 54_000, }), ..BotMemory::default() }; diff --git a/crates/ai/src/standard/mod.rs b/crates/ai/src/standard/mod.rs index 927cf0d..2f1bf4c 100644 --- a/crates/ai/src/standard/mod.rs +++ b/crates/ai/src/standard/mod.rs @@ -4,70 +4,109 @@ //! returns at once, or a plan: decode the memory, gather the [`Facts`], run //! the rules over one [`Ctx`] in a fixed order, write the memory back if it //! changed, and return the plan's commands. A bot plans on ticks where -//! `tick % every == player × every / players`, `every` being the -//! personality's `think_every_ds` in ticks (more than the command delay, -//! so a plan's commands apply before the next plan reads the view), so two -//! bots never plan on the same tick. +//! `tick % every == phase`, `every` being the personality's +//! `think_every_ds` in ticks (more than the command delay, so a plan's +//! commands apply before the next plan reads the view) and the phase +//! `((player + seed) mod players) × every / players` ([`phase`]), so two +//! bots never plan on the same tick and, since M6, which seat plans first +//! alternates with the seed's parity. //! //! The rules run in a fixed order over one plan context, each claiming //! the units it orders so a later rule leaves them alone: //! -//! 1. [`defend`]: the defend latch, the home guard, wave recall; -//! 2. [`build_order`]: the `Military` latch, the window, the site keeper; -//! 3. [`economy`]: idle workers and the gather split; -//! 4. [`army::produce`]: Supply Wains, then the army mix; -//! 5. [`waves`]: launch, stage, assault, ring hold, continue, recall, -//! all-in; -//! 6. [`army::rally_upkeep`]: trainers rallied to the army's rally point. +//! 1. [`defend`]: the defend latch, the home guard (M6: Wains first for +//! a `defend_wains_first` personality), threats to Towns and (M6) to +//! worked gathering buildings, wave recall; +//! 2. [`retreat`] (M6): a losing wave comes home with a plain `Move`; +//! 3. [`build_order`]: the `Military` latch, the window, the site keeper, +//! (M6) the reactive Watchtower; +//! 4. [`economy`]: idle workers and the gather split; +//! 5. [`army::produce`]: Supply Wains, then the army mix weighted by +//! counters ([`compose`], M6), and before a difficulty's deadline raid +//! emergency production; +//! 6. [`waves`]: launch, stage, assault, ring hold, continue, recall, +//! all-in; (M6) raids, the deadline raid, the Wain rule, `seat_last` +//! and influence-scored targets; +//! 7. [`army::rally_upkeep`]: trainers rallied to the army's rally point. +//! +//! [`influence`] holds the per-plan strength grids (from the view, never +//! stored), which retreat and the target scores read through +//! [`Ctx::influence`]. pub mod army; pub mod build_order; +pub mod compose; pub mod ctx; pub mod defend; pub mod economy; pub mod facts; +pub mod influence; pub mod memory; +pub mod retreat; pub mod sites; pub mod waves; pub use ctx::{Ctx, Ledger, goal_or_post, on_station}; pub use facts::Facts; -pub use memory::{BotMemory, BotStats, stats}; +pub use memory::{BotMemory, BotStats, WaveKind, stats}; pub use sites::MapAnalysis; -use rules::{Personality, Rules}; +use rules::{BuildOrder, Difficulty, FirstRaid, Personality, Pressure, Rules}; use sim::{AiController, AiMemory, Command, MatchSetup, PlayerId, SimView}; /// The Standard bot of one seat. Its only state besides the hashed memory -/// is the [`MapAnalysis`], a pure function of immutable inputs, so a bot -/// rebuilt mid-match decides exactly as the one it replaces. +/// is the [`MapAnalysis`] and (M6) the knobs and first raid resolved from +/// its personality and difficulty, all pure functions of immutable inputs +/// (the rules and the header), so a bot rebuilt mid-match decides exactly +/// as the one it replaces. #[derive(Debug, Clone)] pub struct StandardBot { player: PlayerId, - personality: String, + difficulty: String, + knobs: Personality, + order: BuildOrder, + first_raid: Option, + pressure: Pressure, map: MapAnalysis, } -/// The tick offset of `me`'s plans within the cadence: `me × every / -/// players`, so the seats of a match plan on different ticks. +/// The tick offset of `me`'s plans within the cadence: `((me + seed) mod +/// players) × every / players`, so the seats of a match plan on different +/// ticks and (M6) which one plans first alternates with the seed's parity +/// in a two-player match. pub fn phase(me: PlayerId, every: u32, setup: &MatchSetup) -> u32 { let players = u32::try_from(setup.players.len().max(1)).expect("at most 255 players"); - u32::from(me.0) * every / players + let slot = (u64::from(me.0) + setup.seed % u64::from(players)) % u64::from(players); + u32::try_from(slot).expect("below the player count") * every / players } impl StandardBot { - /// The bot for seat `player` with `personality` (a row of - /// `rules.ai`). + /// The bot for seat `player` with `personality` (a row of `rules.ai`) + /// at `difficulty` (M6): it plays `rules::ai::resolve(personality, + /// difficulty)`; for `standard` that is the personality with its all-in + /// at 36:00 (`rules_version` 30; its `pressure` block is read by the + /// bot, not by `resolve`). pub fn new( rules: &Rules, setup: &MatchSetup, player: PlayerId, personality: &Personality, + difficulty: &Difficulty, ) -> StandardBot { + let knobs = rules::ai::resolve(personality, difficulty); + let order = rules + .ai + .build_order(&knobs.build_order) + .map(|o| rules::ai::resolve_order(o, difficulty)) + .expect("Rules::load checks every personality's build order"); StandardBot { player, - personality: personality.id.clone(), - map: MapAnalysis::new(rules, setup, player, personality), + difficulty: difficulty.id.clone(), + map: MapAnalysis::new(rules, setup, player, &knobs), + knobs, + order, + first_raid: difficulty.first_raid, + pressure: difficulty.pressure, } } @@ -76,22 +115,47 @@ impl StandardBot { self.player } + /// The difficulty id the bot plays at. + pub fn difficulty(&self) -> &str { + &self.difficulty + } + /// The bot's static map analysis. pub fn map(&self) -> &MapAnalysis { &self.map } - /// The personality's knobs, looked up in the rules the sim runs on. - /// - /// # Panics - /// - /// If the rules have no such personality (`ai::controller_for` checks - /// it before building the bot). - pub fn knobs<'r>(&self, rules: &'r Rules) -> &'r Personality { - rules - .ai - .personality(&self.personality) - .expect("controller_for checked the personality") + /// The knobs the bot plays: its personality resolved for its + /// difficulty (`rules::ai::resolve`). + pub fn knobs(&self) -> &Personality { + &self.knobs + } + + /// The difficulty's guaranteed early raid (M6, [`waves`]). + pub fn first_raid(&self) -> Option { + self.first_raid + } + + /// How the difficulty presses its sieges (M6 owner decision 12). + pub fn pressure(&self) -> Pressure { + self.pressure + } + + /// A fresh plan context for `me` over `view` with the bot's knobs, map + /// analysis and (M6) resolved build order, first raid and pressure. + pub fn ctx<'a>(&'a self, view: SimView<'a>, me: PlayerId) -> Ctx<'a> { + let mut ctx = Ctx::new(view, me, &self.knobs, &self.map); + ctx.order = &self.order; + ctx.first_raid = self.first_raid; + ctx.pressure = self.pressure; + ctx + } + + /// The build order the bot plays: its personality's, with the + /// difficulty's `worker_target_permille` applied + /// (`rules::ai::resolve_order`, M6). + pub fn order(&self) -> &BuildOrder { + &self.order } /// One plan, whatever the tick: the pipeline over a fresh [`Ctx`], @@ -109,9 +173,8 @@ impl StandardBot { view: &SimView<'_>, memory: &mut AiMemory, ) -> (Vec, PlanStats) { - let knobs = self.knobs(view.rules()); let mut mem = BotMemory::decode(memory.bytes()); - let mut ctx = Ctx::new(*view, me, knobs, &self.map); + let mut ctx = self.ctx(*view, me); run_rules(&mut ctx, &mut mem); let bytes = mem.encode(); if bytes != memory.bytes() { @@ -119,6 +182,7 @@ impl StandardBot { } let stats = PlanStats { probes: ctx.probes_made, + influence: ctx.influence_built, }; let cmds = ctx.into_commands(); (cmds, stats) @@ -135,10 +199,7 @@ impl StandardBot { if view.outcome().is_some() { return (Vec::new(), None); } - let every = self - .knobs(view.rules()) - .think_every_ticks(view.rules()) - .max(1); + let every = self.knobs.think_every_ticks(view.rules()).max(1); if view.tick % every != phase(me, every, view.setup()) { return (Vec::new(), None); } @@ -154,11 +215,15 @@ pub struct PlanStats { /// `check_build` probes made (each a full-map search), counted by /// [`Ctx::check_build`]; at most `site_checks_per_think`. pub probes: u32, + /// Whether the plan built the influence maps (M6; `bench --bots` + /// counts these plans). + pub influence: bool, } /// The plan pipeline over `ctx` and `mem`, in its fixed order. pub fn run_rules(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { defend::step(ctx, mem); + retreat::step(ctx, mem); build_order::step(ctx, mem); economy::step(ctx, mem); army::produce(ctx, mem); @@ -171,3 +236,29 @@ impl AiController for StandardBot { self.think_with_stats(me, view, memory).0 } } + +#[cfg(test)] +mod tests { + use super::*; + + /// M6: which seat plans first alternates with the seed's parity: P0 on + /// even seeds, P1 on odd ones, half the cadence apart either way. + #[test] + fn phase_alternates_with_the_seed() { + let r = crate::testkit::rules(); + for seed in 0..6u64 { + let setup = + MatchSetup::bots(&r, seed, ("standard", "standard"), ("standard", "standard")); + let (p0, p1) = ( + phase(PlayerId(0), 10, &setup), + phase(PlayerId(1), 10, &setup), + ); + let want = if seed % 2 == 0 { (0, 5) } else { (5, 0) }; + assert_eq!((p0, p1), want, "seed {seed}"); + } + // A one-player header never divides by zero. + let mut solo = MatchSetup::bots(&r, 3, ("standard", "standard"), ("standard", "standard")); + solo.players.truncate(1); + assert_eq!(phase(PlayerId(0), 10, &solo), 0); + } +} diff --git a/crates/ai/src/standard/retreat.rs b/crates/ai/src/standard/retreat.rs new file mode 100644 index 0000000..61b5ace --- /dev/null +++ b/crates/ai/src/standard/retreat.rs @@ -0,0 +1,431 @@ +//! Retreat when losing (M6, docs/design/ai.md rule 7). +//! +//! Each plan, an active wave in its `Stage` or `Assault` phase (a raid is +//! always in `Assault`) is weighed against the enemy army around it: +//! +//! - `own`: `sum(hp) x sum(dps_x10)` of the wave's living armed units (the +//! Lanchester strength of [`super::influence`], their current hit +//! points and resolved damage); +//! - `enemy`: the enemy-unit layer's strength over the block of +//! `influence_radius_cells` around the cell of the wave's centroid +//! (Towns and Watchtowers are left out, so a wave never flees from a +//! building it is taking down); +//! - `lost`: `1000 - 1000 x alive / launched_units`, per mille. +//! +//! It retreats when `enemy > 0`, `own x 1000 < enemy x +//! retreat_ratio_permille` and `lost >= retreat_loss_permille`, except +//! during the all-in, in the `Hold` phase (the target is at 0 HP), within +//! `retreat_commit_ds` of the assault order (a raid's launch), and while +//! the target Town is at or below `retreat_commit_town_hp_permille` of its +//! hit points. A retreat orders one grouped plain `Move` of the wave's +//! armed units and its Wain to the rally, which disengages (a `Move` never +//! engages, `sim::combat`); arrivals hold the rally as guards and +//! re-acquire. The wave ends (`mem.wave = None`), `retreats` counts it and +//! the next launch waits at least `retreat_cooldown_ds`. + +use super::ctx::Ctx; +use super::defend::all_in; +use super::influence::dps_x10; +use super::memory::{BotMemory, WaveKind, WavePhase}; +use super::waves::committed; +use sim::{Command, Fx, FxVec2, UnitId}; + +/// The verdict of [`assess`] on the active wave. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Assessment { + /// The wave's own strength. + pub own: i64, + /// The enemy units' strength around the wave's centroid. + pub enemy: i64, + /// Per mille of the launched armed units lost. + pub lost: u32, +} + +/// The strength of the wave and of the enemy around it, `None` when the +/// rule does not apply (no wave, a `Hold`, the all-in, the commit window, +/// a target at or below the commit share, or no living armed unit). +pub fn assess(ctx: &mut Ctx<'_>, mem: &BotMemory) -> Option { + let wave = mem.wave.as_ref()?; + if ctx.knobs.retreat_ratio_permille == 0 || wave.phase == WavePhase::Hold || all_in(ctx) { + return None; + } + let tick = ctx.tick(); + if wave.phase == WavePhase::Assault + && tick.saturating_sub(wave.phase_at) < ctx.ticks(ctx.knobs.retreat_commit_ds) + { + return None; + } + // M6 owner decision 12: a siege committed to a target that is losing + // hit points stays. + if committed(ctx, wave) { + return None; + } + if wave.kind == WaveKind::Siege + && ctx.view.building_hp(wave.target).is_some_and(|(hp, max)| { + i64::from(hp.max(0)) * 1000 + <= i64::from(ctx.knobs.retreat_commit_town_hp_permille) * i64::from(max.max(1)) + }) + { + return None; + } + let view = ctx.view; + let me = ctx.me; + let rules = view.rules(); + let (mut hp, mut dps, mut alive) = (0i64, 0i64, 0u32); + let (mut sx, mut sy) = (0i64, 0i64); + let units: Vec = wave.units.clone(); + for id in units { + let Some(u) = view.unit(id).filter(|u| u.owner == me) else { + continue; + }; + let s = ctx.unit_stats(me, u.kind); + let cooldown = rules + .unit_kind(u.kind.0) + .and_then(|k| k.attack.as_ref()) + .map_or(0, |a| a.cooldown_ds); + hp += i64::from(s.max_hp.saturating_sub(u.damage_taken).max(0)); + dps += i64::from(s.attack.map_or(0, |a| dps_x10(a.damage, cooldown))); + sx += i64::from(u.pos.x.floor_to_int()); + sy += i64::from(u.pos.y.floor_to_int()); + alive += 1; + } + if alive == 0 { + return None; + } + let n = i64::from(alive); + let centroid = FxVec2::new( + Fx::from_int(i32::try_from(sx / n).unwrap_or(0)), + Fx::from_int(i32::try_from(sy / n).unwrap_or(0)), + ); + let launched = u32::from(wave.launched_units).max(1); + let lost = 1000u32.saturating_sub(alive.saturating_mul(1000) / launched); + let radius = ctx.knobs.influence_radius_cells; + let inf = ctx.influence(); + let enemy = inf.strength(&inf.enemy_units, inf.cell_at(centroid), radius); + Some(Assessment { + own: hp.saturating_mul(dps), + enemy, + lost, + }) +} + +/// Whether `a` calls for a retreat under the knobs. +pub fn losing(ctx: &Ctx<'_>, a: &Assessment) -> bool { + a.enemy > 0 + && i128::from(a.own) * 1000 + < i128::from(a.enemy) * i128::from(ctx.knobs.retreat_ratio_permille) + && a.lost >= ctx.knobs.retreat_loss_permille +} + +/// Run the rule. +pub fn step(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { + if ctx.commands_left == 0 { + return; + } + let Some(a) = assess(ctx, mem) else { + return; + }; + if !losing(ctx, &a) { + return; + } + let Some(wave) = mem.wave.take() else { + return; + }; + let tick = ctx.tick(); + let rally = ctx.rally(); + let view = ctx.view; + let me = ctx.me; + let units: Vec = wave + .units + .iter() + .chain(wave.wain.iter()) + .copied() + .filter(|id| view.unit(*id).is_some_and(|u| u.owner == me)) + .filter(|id| ctx.claim(*id)) + .collect(); + ctx.order(units, |units| Command::Move { + units, + target: rally, + queue: false, + }); + mem.retreats = mem.retreats.saturating_add(1); + mem.last_retreat_at = tick; + mem.next_wave_at = mem + .next_wave_at + .max(tick.saturating_add(ctx.ticks(ctx.knobs.retreat_cooldown_ds))); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::standard::memory::Wave; + use crate::testkit::{Arena, P0, P1, place, spawn, standard_vs_passive}; + use sim::{BuildingId, Intent, Tile}; + + /// A wave of `units` in `phase`, launched with `launched` armed units + /// at tick 0, against `target`. + fn wave(units: Vec, launched: u16, phase: WavePhase, target: BuildingId) -> Wave { + Wave { + units, + wain: None, + target, + stage: Tile::new(60, 64), + phase, + launched_at: 0, + phase_at: 0, + kind: WaveKind::Siege, + launched_units: launched, + last_contact_at: 0, + target_hp: 0, + progress_at: 0, + } + } + + /// Four of P0's Skirmishers at (60,64), `enemy` of P1's beside them. + fn fight(enemy: u16) -> Arena { + let r = crate::testkit::rules(); + let mut cmds = vec![spawn(&r, P0, "skirmisher", 60, 64, 4)]; + if enemy > 0 { + cmds.push(spawn(&r, P1, "skirmisher", 62, 64, enemy)); + } + Arena::frozen(standard_vs_passive(&r), cmds) + } + + /// M6: a staging wave outnumbered three to one that has lost half of + /// what it launched with comes home with one plain `Move` (the rally), + /// counts a retreat and pushes the next launch out by the cooldown; one + /// that has lost only a fifth, or that is not outnumbered, goes on. + #[test] + fn retreat_when_outnumbered_and_down_30_percent() { + let a = fight(12); + let units = a.units_of(P0, "skirmisher"); + let target = a.seat(P1); + let mut ctx = a.base_ctx(P0); + let rally = ctx.rally(); + let mut mem = BotMemory { + wave: Some(wave(units.clone(), 8, WavePhase::Stage, target)), + next_wave_at: 5, + ..BotMemory::default() + }; + let verdict = assess(&mut ctx, &mem).expect("the rule applies"); + assert_eq!(verdict.lost, 500, "4 of 8 left"); + assert!(verdict.enemy > verdict.own * 2, "{verdict:?}"); + assert!(losing(&ctx, &verdict)); + step(&mut ctx, &mut mem); + let tick = a.sim.tick(); + assert!(mem.wave.is_none()); + assert_eq!((mem.retreats, mem.last_retreat_at), (1, tick)); + assert_eq!( + mem.next_wave_at, + tick + ctx.ticks(ctx.knobs.retreat_cooldown_ds) + ); + assert_eq!( + ctx.into_commands(), + [Command::Move { + units, + target: rally, + queue: false + }] + ); + + // Down only a fifth: no retreat yet. + let units = a.units_of(P0, "skirmisher"); + let mut ctx = a.base_ctx(P0); + let mut mem = BotMemory { + wave: Some(wave(units.clone(), 5, WavePhase::Stage, target)), + ..BotMemory::default() + }; + step(&mut ctx, &mut mem); + assert!(mem.wave.is_some() && mem.retreats == 0); + assert!(ctx.into_commands().is_empty()); + + // Not outnumbered: no retreat, however many were lost. + let even = fight(3); + let units = even.units_of(P0, "skirmisher"); + let mut ctx = even.base_ctx(P0); + let mut mem = BotMemory { + wave: Some(wave(units, 8, WavePhase::Stage, even.seat(P1))), + ..BotMemory::default() + }; + step(&mut ctx, &mut mem); + assert!(mem.wave.is_some() && mem.retreats == 0); + } + + /// M6: a retreating wave in contact disengages: after the retreat no + /// unit of it attack-moves or attacks, and it heads for the rally. + #[test] + fn a_retreating_wave_in_contact_moves_away() { + let mut a = fight(12); + let units = a.units_of(P0, "skirmisher"); + let enemy = a.units_of(P1, "skirmisher"); + let target = a.seat(P1); + let centroid = |a: &Arena, ids: &[UnitId]| -> i64 { + let view = a.sim.view(); + let xs: Vec = ids + .iter() + .filter_map(|u| view.unit(*u)) + .map(|u| i64::from(u.pos.x.floor_to_int())) + .collect(); + xs.iter().sum::() / i64::try_from(xs.len().max(1)).unwrap() + }; + let gap0 = centroid(&a, &enemy) - centroid(&a, &units); + let x0 = centroid(&a, &units); + let mut mem = BotMemory { + wave: Some(wave(units.clone(), 8, WavePhase::Stage, target)), + ..BotMemory::default() + }; + let cmds = { + let mut ctx = a.base_ctx(P0); + step(&mut ctx, &mut mem); + ctx.into_commands() + }; + assert_eq!(mem.retreats, 1); + a.issue(P0, cmds); + a.run(80); + let view = a.sim.view(); + let alive: Vec = units + .iter() + .copied() + .filter(|u| view.unit(*u).is_some()) + .collect(); + assert!(!alive.is_empty(), "some got away"); + for u in &alive { + let x = view.unit(*u).unwrap(); + assert!( + !matches!(x.intent, Intent::AttackMove { .. } | Intent::Attack), + "{u:?} still fights: {:?}", + x.intent + ); + } + // The rally lies west, at x 36: the wave heads there instead of + // standing its ground (the frozen enemy chases it, so the gap + // itself need not grow). + let x1 = centroid(&a, &alive); + assert!(x1 + 5 <= x0, "the wave moves home: x {x0} -> {x1}"); + assert!(gap0 > 0); + } + + /// M6: no retreat in the `Hold` phase, within `retreat_commit_ds` of + /// the assault order, during the all-in, or while the target Town is at + /// or below `retreat_commit_town_hp_permille`; a ratio of 0 turns the + /// rule off. + #[test] + fn no_retreat_in_commit_window_hold_all_in_or_on_a_weak_target() { + let r = crate::testkit::rules(); + let a = fight(12); + let units = a.units_of(P0, "skirmisher"); + let target = a.seat(P1); + let tick = a.sim.tick(); + let check = |mem: &BotMemory, knobs: Option<&rules::Personality>, why: &str| { + let mut ctx = match knobs { + Some(k) => crate::standard::Ctx::new(a.sim.view(), P0, k, a.map(P0)), + None => a.base_ctx(P0), + }; + let mut mem = mem.clone(); + step(&mut ctx, &mut mem); + assert!(mem.wave.is_some() && mem.retreats == 0, "{why}"); + }; + let hold = BotMemory { + wave: Some(wave(units.clone(), 8, WavePhase::Hold, target)), + ..BotMemory::default() + }; + check(&hold, None, "hold"); + let mut fresh = wave(units.clone(), 8, WavePhase::Assault, target); + fresh.phase_at = tick; + let fresh = BotMemory { + wave: Some(fresh), + ..BotMemory::default() + }; + check(&fresh, None, "inside the commit window"); + let base = r.ai.personality("standard").unwrap().clone(); + let mut all_in = base.clone(); + all_in.all_in_after_ds = 0; + let staging = BotMemory { + wave: Some(wave(units.clone(), 8, WavePhase::Stage, target)), + ..BotMemory::default() + }; + check(&staging, Some(&all_in), "all-in"); + let mut off = base.clone(); + off.retreat_ratio_permille = 0; + check(&staging, Some(&off), "ratio 0"); + // The target Town at 25%: committed. The wave is staging, not + // assaulting, so the commit window (Assault only, and the arena is + // only a few ticks old) cannot be what holds it: the Town-HP clause + // must turn the rule off on its own. + let mut weak = fight(12); + weak.issue( + P0, + vec![Command::DebugDamage { + target: sim::TargetId::Building(target), + amount: 1500, + }], + ); + let mut ctx = weak.base_ctx(P0); + let mut mem = BotMemory { + wave: Some(wave( + weak.units_of(P0, "skirmisher"), + 8, + WavePhase::Stage, + target, + )), + ..BotMemory::default() + }; + assert!(assess(&mut ctx, &mem).is_none(), "weak target: rule off"); + step(&mut ctx, &mut mem); + assert!(mem.wave.is_some() && mem.retreats == 0, "weak target"); + // The same staging wave against the sound Town retreats (the + // paired control). + let mut ctx = a.base_ctx(P0); + let mut mem = staging.clone(); + step(&mut ctx, &mut mem); + assert_eq!(mem.retreats, 1, "control"); + let _ = place; + } + + /// M6: after a retreat the next launch waits for the cooldown: a full + /// wave at the rally does not launch until `next_wave_at`. + #[test] + fn retreat_cooldown_blocks_the_next_launch() { + let r = crate::testkit::rules(); + let mut a = fight(12); + let units = a.units_of(P0, "skirmisher"); + let target = a.seat(P1); + let mut mem = BotMemory { + wave: Some(wave(units, 8, WavePhase::Stage, target)), + ..BotMemory::default() + }; + { + let mut ctx = a.base_ctx(P0); + step(&mut ctx, &mut mem); + } + assert_eq!(mem.retreats, 1); + let until = mem.next_wave_at; + // A launchable wave at the rally: Masonry, 9 Skirmishers, a + // Mangonel and a Wain. + a.issue( + P0, + vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 36, 64, 9), + spawn(&r, P0, "mangonel", 36, 64, 1), + spawn(&r, P0, "supply_wain", 36, 64, 1), + ], + ); + assert!(a.sim.tick() < until); + let mut k = r.ai.personality("standard").unwrap().clone(); + k.noise_first_wave_ds = 0; + k.noise_wave_units = 0; + let mut ctx = crate::standard::Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + let mut m = mem.clone(); + crate::standard::waves::step(&mut ctx, &mut m); + assert!(m.wave.is_none(), "cooling down"); + let mut ctx = crate::standard::Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + let mut m = BotMemory { + next_wave_at: a.sim.tick(), + waves_launched: 1, + ..mem + }; + crate::standard::waves::step(&mut ctx, &mut m); + assert!(m.wave.is_some(), "the control launches"); + } +} diff --git a/crates/ai/src/standard/sites.rs b/crates/ai/src/standard/sites.rs index fe60850..7fea78f 100644 --- a/crates/ai/src/standard/sites.rs +++ b/crates/ai/src/standard/sites.rs @@ -12,10 +12,10 @@ //! policy's spacing, and only then `check_build` (a full-map search), at //! most `site_checks_per_think` per plan. -use super::ctx::Ctx; +use super::ctx::{Ctx, salt}; use super::facts::{kind_centre, kind_centre_tile, tiles_sq}; use super::memory::BotMemory; -use rules::{MapDef, Personality, Placement, Rules, Slots, Terrain}; +use rules::{MapDef, Personality, Placement, Rules, Slots, Symmetry, Terrain}; use sim::{BuildingId, BuildingKindId, MatchSetup, PlayerId, Tile, UnitId}; use std::collections::BTreeMap; @@ -37,16 +37,18 @@ pub struct MapAnalysis { /// inner ring first, each ring clockwise from its north-west corner. pub near_town_offsets: Vec<(i32, i32)>, /// `Terrain` candidates per building kind with `Adjacent` slots: - /// footprint north-west tiles on grass within `border_scan_tiles` of - /// the own start with at least one adjacent tile of the kind's terrain, - /// best first: most adjacent tiles (up to the kind's `max`), then - /// nearest the own start, then the lowest tile index. + /// footprint north-west tiles on grass whose footprint centre lies + /// within `border_scan_tiles` of the own start, with at least one + /// adjacent tile of the kind's terrain, best first: most adjacent tiles + /// (up to the kind's `max`), then footprint centre nearest the own + /// start, then the lowest [`MapAnalysis::tie_index`] (M6: mirror-aware; + /// M5b measured from the north-west tile and broke ties by its index). pub terrain: BTreeMap>, /// `Expansion` candidates: Town footprint north-west tiles on grass /// lying wholly inside the Seat's first border (every footprint tile /// closer than `town_radius_tiles` to the own start, so it is own land /// from the start) with the centre at least `expansion_min_tiles` from - /// the own start, best first by score, then the lowest tile index; at + /// the own start, best first by score, then the lowest `tie_index` (M6: mirror-aware); at /// most [`EXPANSION_CANDIDATES`]. The score is the Forest and Mountain /// tiles closer than `resource_radius_tiles` to the centre that lie /// outside the Seat's first border (what the new Town's border would @@ -54,13 +56,21 @@ pub struct MapAnalysis { /// to the enemy start than the own start is. pub expansion: Vec, /// `BorderEdge` candidates: grass tiles within `border_scan_tiles` of - /// the own start, nearest the enemy start first, then the lowest tile - /// index. + /// the own start, nearest the enemy start first, then the lowest + /// `tie_index` (M6: mirror-aware). pub border_edge: Vec, /// The rally seed: `rally_tiles_from_seat` from the own start toward /// the enemy start (the rally is the first passable owned tile of the /// spiral around it, chosen at plan time). pub rally_seed: Tile, + /// The map's size. + pub size: (u16, u16), + /// M6: the map's symmetry when this seat's start is the mirror partner + /// of a lower seat's start. Every tie between candidates then breaks + /// by the index of the mirrored footprint ([`MapAnalysis::tie_index`]) + /// and `NearTown` offsets are mirrored, so the two seats of a + /// symmetric map pick mirror images. + pub mirror: Option, } fn d2(a: Tile, b: Tile) -> i64 { @@ -73,6 +83,31 @@ fn index(def: &MapDef, t: Tile) -> u32 { u32::from(t.y) * u32::from(def.width) + u32::from(t.x) } +/// The north-west tile of the mirror image, under `sym` on a `size` map, +/// of the `w` x `h` footprint at `nw`. +pub fn mirrored_nw(sym: Symmetry, size: (u16, u16), nw: Tile, w: u16, h: u16) -> Tile { + let (far_x, far_y) = (nw.x + w.max(1) - 1, nw.y + h.max(1) - 1); + match sym { + Symmetry::MirrorX => { + let (x, _) = sym.partner(far_x, nw.y, size.0, size.1); + Tile::new(x, nw.y) + } + Symmetry::Rotate180 => { + let (x, y) = sym.partner(far_x, far_y, size.0, size.1); + Tile::new(x, y) + } + } +} + +/// Squared distance from the centre of the `w` x `h` footprint at `nw` to +/// the centre of tile `t`, in quarter tiles squared (doubled coordinates, +/// so it is exact and mirror-symmetric). +fn d2_centre(nw: Tile, w: u16, h: u16, t: Tile) -> i64 { + let dx = 2 * i64::from(nw.x) + i64::from(w) - 1 - 2 * i64::from(t.x); + let dy = 2 * i64::from(nw.y) + i64::from(h) - 1 - 2 * i64::from(t.y); + dx * dx + dy * dy +} + /// The tile `(x, y)` when it is on the map. fn tile_at(def: &MapDef, x: i32, y: i32) -> Option { let x = u16::try_from(x).ok()?; @@ -136,6 +171,22 @@ impl MapAnalysis { Tile::new(s.x, s.y) }; let own_start = start(usize::from(me.0)); + let size = (def.width, def.height); + let mirror = setup + .players + .iter() + .filter(|p| p.id < me) + .any(|p| { + let s = start(usize::from(p.id.0)); + def.symmetry.partner(s.x, s.y, def.width, def.height) == (own_start.x, own_start.y) + }) + .then_some(def.symmetry); + let tie = |nw: Tile, w: u16, h: u16| -> u32 { + mirror.map_or_else( + || index(def, nw), + |sym| index(def, mirrored_nw(sym, size, nw, w, h)), + ) + }; let enemy_start = setup .players .iter() @@ -175,13 +226,15 @@ impl MapAnalysis { }; let w = u16::try_from(kind.width_tiles).expect("validated <= 8"); let h = u16::try_from(kind.height_tiles).expect("validated <= 8"); + // Within the scan radius by footprint centre (doubled + // coordinates, so the scan is mirror-symmetric too). let mut found: Vec<(u32, i64, u32, Tile)> = tiles .iter() - .filter(|nw| d2(**nw, own_start) <= scan * scan) + .filter(|nw| d2_centre(**nw, w, h, own_start) <= 4 * scan * scan) .filter(|nw| footprint_on_grass(def, **nw, w, h)) .filter_map(|nw| { let adj = adjacent_count(def, *nw, w, h, *t).min(*max); - (adj > 0).then(|| (adj, d2(*nw, own_start), index(def, *nw), *nw)) + (adj > 0).then(|| (adj, d2_centre(*nw, w, h, own_start), tie(*nw, w, h), *nw)) }) .collect(); found.sort_by_key(|(adj, d, i, _)| (core::cmp::Reverse(*adj), *d, *i)); @@ -241,7 +294,7 @@ impl MapAnalysis { } } let gain = (home_to_enemy - d2(centre, enemy_start).isqrt()).max(0); - expansion.push((resources - penalty * gain, index(def, *nw), *nw)); + expansion.push((resources - penalty * gain, tie(*nw, tw, th), *nw)); } expansion.sort_by_key(|(score, i, _)| (core::cmp::Reverse(*score), *i)); expansion.truncate(EXPANSION_CANDIDATES); @@ -249,7 +302,7 @@ impl MapAnalysis { let mut border: Vec<(i64, u32, Tile)> = tiles .iter() .filter(|t| d2(**t, own_start) <= scan * scan && def.tile(t.x, t.y) == Terrain::Grass) - .map(|t| (d2(*t, enemy_start), index(def, *t), *t)) + .map(|t| (d2(*t, enemy_start), tie(*t, 1, 1), *t)) .collect(); border.sort_by_key(|(d, i, _)| (*d, *i)); @@ -273,10 +326,39 @@ impl MapAnalysis { expansion: expansion.into_iter().map(|c| c.2).collect(), border_edge: border.into_iter().map(|c| c.2).collect(), rally_seed, + size, + mirror, } } } +impl MapAnalysis { + /// The index a tie between candidate footprints breaks by (M6): the + /// row-major index of the footprint's north-west tile, or, for a + /// mirrored seat, of its mirror image's. + pub fn tie_index(&self, nw: Tile, w: u16, h: u16) -> u32 { + let t = self + .mirror + .map_or(nw, |sym| mirrored_nw(sym, self.size, nw, w, h)); + u32::from(t.y) * u32::from(self.size.0) + u32::from(t.x) + } + + /// The footprint north-west tile of a `w` x `h` building at `NearTown` + /// offset `(dx, dy)` from a Town centre tile `c`: `c + (dx, dy)`, or, + /// for a mirrored seat, the offset mirrored with the footprint, so the + /// two seats walk mirror-image rings. + pub fn near_town_tile(&self, c: Tile, (dx, dy): (i32, i32), w: u16, h: u16) -> Option { + let (dx, dy) = match self.mirror { + None => (dx, dy), + Some(Symmetry::MirrorX) => (-dx - (i32::from(w) - 1), dy), + Some(Symmetry::Rotate180) => (-dx - (i32::from(w) - 1), -dy - (i32::from(h) - 1)), + }; + let x = u16::try_from(i32::from(c.x) + dx).ok()?; + let y = u16::try_from(i32::from(c.y) + dy).ok()?; + Some(Tile::new(x, y)) + } +} + /// What [`site_for`] found. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SiteSearch { @@ -409,29 +491,78 @@ pub fn site_for( _ => mem.site = None, } } - let candidates: Vec = match at { + let mut candidates: Vec = match at { Placement::NearTown => { let Some(town) = near_town_anchor(ctx, kind) else { return SiteSearch::NoSite; }; - let Some(b) = ctx.view.building(town) else { - return SiteSearch::NoSite; - }; - let c = kind_centre_tile(ctx.view.rules(), b.kind, b.tile); - ctx.map - .near_town_offsets - .iter() - .filter_map(|(dx, dy)| { - let x = u16::try_from(i32::from(c.x) + dx).ok()?; - let y = u16::try_from(i32::from(c.y) + dy).ok()?; - Some(Tile::new(x, y)) - }) - .collect() + near_town_candidates(ctx, kind, town) } Placement::Terrain => ctx.map.terrain.get(&kind).cloned().unwrap_or_default(), Placement::Expansion => ctx.map.expansion.clone(), Placement::BorderEdge => ctx.map.border_edge.clone(), }; + // M6: an Expansion or BorderEdge site is picked among the first + // `noise_site_top_k` candidates that pass the cheap tests, by the + // bot's noise salted with the step, so seeds differ in where the + // second Town and the border towers go. + let top_k = usize::try_from(ctx.knobs.noise_site_top_k).unwrap_or(1); + if top_k > 1 && matches!(at, Placement::Expansion | Placement::BorderEdge) { + let mut firsts: Vec = Vec::new(); + for t in &candidates { + if firsts.len() >= top_k { + break; + } + if fits(ctx, kind, *t, at) && spaced(ctx, kind, *t, at) { + firsts.push(*t); + } + } + if firsts.len() > 1 { + let n = u64::try_from(firsts.len()).expect("few"); + let i = ctx.noise(salt::SITE + u64::from(step)) % n; + let pick = firsts[usize::try_from(i).expect("below the length")]; + candidates.retain(|t| *t != pick); + candidates.insert(0, pick); + } + } + search(ctx, mem, step, kind, at, pool, candidates) +} + +/// The `NearTown` candidates of a building of `kind` around `town`: the +/// ring offsets of the map analysis from the Town's centre tile (mirrored +/// for a mirrored seat). +pub fn near_town_candidates(ctx: &Ctx<'_>, kind: BuildingKindId, town: BuildingId) -> Vec { + let rules = ctx.view.rules(); + let Some(b) = ctx.view.building(town) else { + return Vec::new(); + }; + let Some(def) = rules.building_kind(kind.0) else { + return Vec::new(); + }; + let w = u16::try_from(def.width_tiles).expect("validated <= 8"); + let h = u16::try_from(def.height_tiles).expect("validated <= 8"); + let c = kind_centre_tile(rules, b.kind, b.tile); + ctx.map + .near_town_offsets + .iter() + .filter_map(|o| ctx.map.near_town_tile(c, *o, w, h)) + .collect() +} + +/// Walk `candidates` in order for [`site_for`]: the first that passes +/// every test is remembered for step `step` and returned. +pub fn search( + ctx: &mut Ctx<'_>, + mem: &mut BotMemory, + step: u16, + kind: BuildingKindId, + at: Placement, + pool: &[UnitId], + candidates: Vec, +) -> SiteSearch { + if pool.is_empty() { + return SiteSearch::NoBuilder; + } for tile in candidates { match probe(ctx, kind, tile, at, pool) { Some(SiteSearch::Found { builder, tile }) => { @@ -579,12 +710,21 @@ mod tests { assert_eq!(p1.rally_seed, Tile::new(91, 64)); } + /// The `standard` knobs without the site noise (`noise_site_top_k` + /// 1): the best candidate always. + fn no_site_noise(r: &Rules) -> Personality { + let mut k = r.ai.personality("standard").unwrap().clone(); + k.noise_site_top_k = 1; + k + } + /// Every expansion candidate lies on own land at the standard start, /// at least `expansion_min_tiles` from the Seat, and the site the bot - /// picks is pinned (computed once, on `plains_1v1`): south of each - /// Seat, toward the forest beyond the first border. P1's pick (106, 81) - /// is not the exact mirror of P0's (18, 81), which is (107, 81): equal - /// scores break to the lowest tile index on both sides. + /// picks with no site noise is pinned (computed once, on + /// `plains_1v1`): south of each Seat, toward the forest beyond the + /// first border. M6: P1's pick is the exact mirror of P0's (18, 81), + /// (107, 81) (M5b broke equal scores to the lowest tile index on both + /// sides, so P1 took (106, 81)). #[test] fn expansion_site_is_own_buildable_and_pinned() { use crate::testkit::{Arena, P0, P1}; @@ -593,7 +733,8 @@ mod tests { setup.debug_commands = true; let a = Arena::frozen(setup, Vec::new()); let town = BuildingKindId(r.town_kind()); - for (p, pinned) in [(P0, Tile::new(18, 81)), (P1, Tile::new(106, 81))] { + let k = no_site_noise(&r); + for (p, pinned) in [(P0, Tile::new(18, 81)), (P1, Tile::new(107, 81))] { let map = a.map(p); assert_eq!(map.expansion[0], pinned, "{p:?}"); let view = a.sim.view(); @@ -607,7 +748,7 @@ mod tests { let c = Tile::new(nw.x + 1, nw.y + 1); assert!(d2(c, map.own_start) >= 14 * 14); } - let mut ctx = a.ctx(p); + let mut ctx = Ctx::new(a.sim.view(), p, &k, a.map(p)); let mut mem = BotMemory::default(); let pool = view.idle_workers(p); let found = site_for(&mut ctx, &mut mem, 9, town, Placement::Expansion, &pool); @@ -636,9 +777,10 @@ mod tests { .concat(); let mut a = Arena::frozen(setup, grants); let tower = BuildingKindId(r.building_kind_by_id("watchtower").unwrap().0); + let k = no_site_noise(&r); for (p, want) in [(P0, Tile::new(43, 64)), (P1, Tile::new(84, 64))] { let view = a.sim.view(); - let mut ctx = a.ctx(p); + let mut ctx = Ctx::new(a.sim.view(), p, &k, a.map(p)); let mut mem = BotMemory::default(); let pool = view.idle_workers(p); let found = site_for(&mut ctx, &mut mem, 16, tower, Placement::BorderEdge, &pool); @@ -736,4 +878,47 @@ mod tests { } } } + + /// M6: the two seats of `plains_1v1` (mirrored across x) build mirror + /// images: every candidate list of P1 is P0's, footprint by footprint + /// mirrored, in the same order, and the `NearTown` offsets mirror + /// around mirrored Town centres. + #[test] + fn sites_are_mirror_symmetric_on_plains_1v1() { + let r = rules(); + let k = r.ai.personality("standard").unwrap(); + let setup = MatchSetup::bots(&r, 1, ("standard", "standard"), ("standard", "standard")); + let p0 = MapAnalysis::new(&r, &setup, PlayerId(0), k); + let p1 = MapAnalysis::new(&r, &setup, PlayerId(1), k); + assert_eq!(p0.mirror, None); + assert_eq!(p1.mirror, Some(Symmetry::MirrorX)); + let mirror = |t: Tile, w: u16, h: u16| mirrored_nw(Symmetry::MirrorX, p0.size, t, w, h); + for (kind, list) in &p0.terrain { + let def = r.building_kind(kind.0).unwrap(); + let (w, h) = ( + u16::try_from(def.width_tiles).unwrap(), + u16::try_from(def.height_tiles).unwrap(), + ); + let want: Vec = list.iter().map(|t| mirror(*t, w, h)).collect(); + assert_eq!(p1.terrain[kind], want, "{}", def.id); + } + let want: Vec = p0.expansion.iter().map(|t| mirror(*t, 3, 3)).collect(); + assert_eq!(p1.expansion, want); + let want: Vec = p0.border_edge.iter().map(|t| mirror(*t, 1, 1)).collect(); + assert_eq!(p1.border_edge, want); + assert_eq!(p1.rally_seed, mirror(p0.rally_seed, 1, 1)); + let (c0, c1) = (p0.own_start, p1.own_start); + assert_eq!(c1, mirror(c0, 1, 1)); + for o in p0.near_town_offsets.iter().take(40) { + for (w, h) in [(1u16, 1u16), (2, 2), (3, 3)] { + let a = p0.near_town_tile(c0, *o, w, h).unwrap(); + let b = p1.near_town_tile(c1, *o, w, h).unwrap(); + assert_eq!(b, mirror(a, w, h), "{o:?} {w}x{h}"); + } + } + assert_eq!( + p0.tie_index(Tile::new(18, 81), 3, 3), + p1.tie_index(Tile::new(107, 81), 3, 3) + ); + } } diff --git a/crates/ai/src/standard/waves.rs b/crates/ai/src/standard/waves.rs index 04233a2..bc5cab9 100644 --- a/crates/ai/src/standard/waves.rs +++ b/crates/ai/src/standard/waves.rs @@ -53,16 +53,20 @@ //! grows with the group: a group order gives each unit its own tile of a //! spiral around the point, and around a Town the spiral starts on its //! ring. A unit on an explicit `Attack` whose target still exists is left -//! alone. The wave issues `AttackMove` into enemy land only at the assault -//! (and the all-in's joiners then), so the first such tick is some wave's -//! assault order (a wave that ends before its assault issues none). +//! alone. A siege wave issues `AttackMove` into enemy land only at the +//! assault (and the all-in's joiners then); a raid and the difficulty's +//! deadline raid (M6) issue one `AttackMove` straight to their target at +//! launch, with no stage. So the first such tick (the harness's +//! `first_attack_tick`) is some siege wave's assault order or a raid's +//! launch (a siege wave that ends before its assault issues none). use super::army::home_armed; -use super::ctx::{Ctx, goal_or_post, on_station}; +use super::ctx::{Ctx, goal_or_post, jitter, on_station, salt}; use super::defend::{all_in, launch_blocked, recall_wave}; -use super::facts::{kind_centre, kind_centre_tile, tiles_sq}; -use super::memory::{BotMemory, Wave, WavePhase}; -use rules::{Rules, UnitClass}; +use super::facts::{kind_centre, kind_centre_tile, tiles_sq, visible_enemies}; +use super::influence::{dps_x10, isqrt}; +use super::memory::{BotMemory, Wave, WaveKind, WavePhase}; +use rules::{Rules, UnitClass, WainRule, WaveMode}; use sim::{Building, BuildingId, Command, Fx, FxVec2, Intent, TargetId, Tile, UnitId, UnitKindId}; use std::collections::BTreeSet; @@ -185,28 +189,205 @@ pub fn size(ctx: &Ctx<'_>, home: usize) -> u32 { .max(1) } -/// Home armed units a launch or the all-in leaves at home: one while the -/// pop is full (nothing could replace it), else none. +/// Home armed units a launch, a reinforcement or the all-in leaves at home: +/// one while the pop is full (nothing could replace it), plus (M6 owner +/// decision 12) the level's `pressure.home_guard_units`. pub fn home_guard(ctx: &Ctx<'_>) -> usize { let (used, cap) = ctx.facts.pop; usize::from(used >= cap) + .saturating_add(usize::try_from(ctx.pressure.home_guard_units).unwrap_or(usize::MAX)) } -/// The wave's target: the complete enemy Town nearest the rally, ties to -/// the lowest id (the one function influence maps replace in M6). -pub fn choose_target(ctx: &mut Ctx<'_>) -> Option { +/// Whether the siege wave `wave` is committed (M6 owner decision 12): the +/// level's `pressure.commit_ds` is set and the wave's target Town was seen +/// losing hit points within that long. A committed wave neither retreats +/// ([`super::retreat`]) nor ends on `wave_max_ds`. +pub fn committed(ctx: &Ctx<'_>, wave: &Wave) -> bool { + let commit = ctx.pressure.commit_ds; + commit > 0 + && wave.kind == WaveKind::Siege + && wave.progress_at > 0 + && ctx.tick().saturating_sub(wave.progress_at) < ctx.ticks(commit) +} + +/// The home armed units that may join the active wave as one group (M6 +/// owner decision 12, `pressure.reinforce_min_units`): those within +/// `assemble_radius_tiles` of the rally, by id, less the home guard +/// ([`home_guard`], and always at least one unit, so a pop that fills +/// after the group left still holds an armed unit at home), at most +/// `room`; none unless at least `reinforce_min_units` (and at least one) +/// qualify. +pub fn rally_group(ctx: &mut Ctx<'_>, mem: &BotMemory, room: usize) -> Vec { + let min = usize::try_from(ctx.pressure.reinforce_min_units.max(1)).unwrap_or(usize::MAX); let rally = ctx.rally(); - let rules = ctx.view.rules(); + let r2 = tiles_sq(ctx.knobs.assemble_radius_tiles); let view = ctx.view; - ctx.facts - .enemy_towns - .iter() - .filter_map(|id| { - let b = view.building(*id)?; - Some((kind_centre(rules, b.kind, b.tile).dist_sq_i64(rally), *id)) + let mut at_rally: Vec = home_armed(ctx, mem) + .into_iter() + .filter(|u| { + view.unit(*u) + .is_some_and(|x| x.pos.dist_sq_i64(rally) <= r2) }) - .min() - .map(|(_, id)| id) + .collect(); + let n = at_rally + .len() + .saturating_sub(home_guard(ctx).max(1)) + .min(room); + if n < min { + return Vec::new(); + } + at_rally.truncate(n); + at_rally +} + +/// Send a group from the rally to the active siege wave while it assaults +/// or holds its target, or (with `assault_ratio_permille`) stages short of +/// the strength to assault (M6 owner decision 12): [`rally_group`] joins +/// the roster (and its launch count, the retreat rule's loss base) and +/// attack-moves to the target Town, to the Wain point while the wave lures +/// ([`luring`]), or to the stage point while it stages. Never while the +/// defend latch blocks launches. +fn reinforce(ctx: &mut Ctx<'_>, mem: &BotMemory, wave: &mut Wave) { + if ctx.pressure.reinforce_min_units == 0 || ctx.commands_left == 0 || launch_blocked(ctx, mem) { + return; + } + let max = usize::try_from(ctx.knobs.max_wave_units).unwrap_or(usize::MAX); + let room = max.saturating_sub(wave.units.len()); + let joiners = rally_group(ctx, mem, room); + if joiners.is_empty() { + return; + } + let view = ctx.view; + let rules = view.rules(); + let Some(b) = view.building(wave.target) else { + return; + }; + let centre = kind_centre(rules, b.kind, b.tile); + let goal = if wave.phase == WavePhase::Stage { + view.map().center_of(wave.stage) + } else if luring(ctx, wave.phase_at, centre) { + wain_point(ctx, wave.target, wave.stage) + } else { + centre + }; + for u in &joiners { + ctx.claim(*u); + } + ctx.order(joiners.clone(), |units| Command::AttackMove { + units, + target: goal, + }); + let n = u16::try_from(joiners.len()).unwrap_or(u16::MAX); + wave.launched_units = wave.launched_units.saturating_add(n); + wave.units.extend(joiners); + wave.units.sort(); +} + +/// Whole tiles between two points (the integer square root of the +/// squared distance). +pub fn dist_tiles(a: FxVec2, b: FxVec2) -> i64 { + let d = u64::try_from(a.dist_sq_i64(b).max(0) >> 32).unwrap_or(u64::MAX); + i64::try_from(isqrt(d)).unwrap_or(i64::MAX) +} + +/// Whether Town `b` is its owner's Seat. +fn is_seat(b: &Building) -> bool { + b.town_state.is_some_and(|t| t.seat) +} + +/// The siege target score of the enemy Town `b` (lower is better, M6): +/// `dist_tiles x 1000 + target_strength_weight x isqrt(units + shooters)`, +/// the distance from the rally and the enemy units' and shooters' +/// strength over `influence_radius_cells` around the Town's cell. +pub fn target_score(ctx: &mut Ctx<'_>, b: BuildingId) -> Option { + let rally = ctx.rally(); + let view = ctx.view; + let rules = view.rules(); + let t = view.building(b)?; + let centre = kind_centre(rules, t.kind, t.tile); + let r = ctx.knobs.influence_radius_cells; + let weight = i64::from(ctx.knobs.target_strength_weight); + let inf = ctx.influence(); + let cell = inf.cell_at(centre); + let strength = inf + .strength(&inf.enemy_units, cell, r) + .saturating_add(inf.strength(&inf.enemy_shooters, cell, r)); + let root = i64::try_from(isqrt(u64::try_from(strength.max(0)).unwrap_or(0))).unwrap_or(0); + Some( + dist_tiles(centre, rally) + .saturating_mul(1000) + .saturating_add(weight.saturating_mul(root)), + ) +} + +/// The wave's target (M6): among the complete enemy Towns, the best +/// [`target_score`], ties to the lowest id. With `seat_last` the enemy +/// Seat is skipped while the enemy holds another complete Town that no +/// annexation timer runs on. When the runner-up scores within +/// `noise_mix_permille` of the best, the bot's noise (salted with the +/// launch count) picks between the two. +pub fn choose_target(ctx: &mut Ctx<'_>, launches: u16) -> Option { + let view = ctx.view; + let towns: Vec = ctx.facts.enemy_towns.clone(); + let seat_last = ctx.knobs.seat_last + && towns.iter().any(|t| { + view.building(*t).is_some_and(|b| !is_seat(b)) && view.annexation(*t).is_none() + }); + let mut scored: Vec<(i64, BuildingId)> = Vec::new(); + for t in towns { + if seat_last && view.building(t).is_some_and(is_seat) { + continue; + } + if let Some(s) = target_score(ctx, t) { + scored.push((s, t)); + } + } + scored.sort(); + let best = *scored.first()?; + if let Some(second) = scored.get(1).copied() { + let band = best + .0 + .max(0) + .saturating_mul(i64::from(ctx.knobs.noise_mix_permille)) + / 1000; + if second.0.saturating_sub(best.0) <= band + && ctx.noise(salt::TARGET + u64::from(launches)) % 2 == 1 + { + return Some(second.1); + } + } + Some(best.1) +} + +/// Whether a siege wave needs a Supply Wain now (M6, the personality's +/// Wain rule): always for `Always`; for `IfEnemyHarrying` once an enemy +/// has a Harrying level (attrition only exists then). +pub fn wain_needed(ctx: &Ctx<'_>) -> bool { + match ctx.knobs.wain_rule { + WainRule::Always => true, + WainRule::IfEnemyHarrying => ctx.facts.enemy.harrying > 0, + } +} + +/// The extra wait of the first wave (M6): up to `noise_first_wave_ds`, +/// from the bot's noise, in ticks. +pub fn first_wave_extra(ctx: &Ctx<'_>) -> u32 { + let max = ctx.knobs.noise_first_wave_ds; + if max == 0 { + return 0; + } + let ds = ctx.noise(salt::FIRST_WAVE) % (u64::from(max) + 1); + ctx.ticks(u32::try_from(ds).expect("at most MAX_AI_DS")) +} + +/// The wait after a launch (M6): `every_ds` in ticks jittered by up to +/// `noise_wave_interval_permille` with the noise of launch `n`. +pub fn interval_after(ctx: &Ctx<'_>, every_ds: u32, n: u16) -> u32 { + jitter( + ctx.ticks(every_ds), + ctx.noise(salt::WAVE_INTERVAL + u64::from(n)), + ctx.knobs.noise_wave_interval_permille, + ) } /// The stage point for a wave of `n` units against `target`: walking the @@ -318,49 +499,91 @@ pub fn launch_due(ctx: &Ctx<'_>, mem: &BotMemory) -> bool { { return false; } + let all_in = all_in(ctx); + // M6: the personality's tech gate (never during the all-in, so a lost + // Watchtower cannot hold the waves back for ever). + if !all_in + && let Some(tech) = &ctx.knobs.wave_requires_tech + && ctx + .view + .rules() + .tech_by_id(tech) + .is_none_or(|(t, _)| !ctx.view.researched(ctx.me).contains(&sim::TechId(t))) + { + return false; + } let not_before = mem .next_wave_at .max(ctx.ticks(ctx.knobs.first_wave_not_before_ds)); - all_in(ctx) || ctx.tick() >= not_before + all_in || ctx.tick() >= not_before } -/// Launch a wave when every condition holds (see the module docs). +/// Launch a wave when every condition holds (see the module docs): the +/// difficulty's deadline raid first (M6), then a raid while a +/// `RaidUntilSiege` personality has no siege assembled, else a siege wave. fn launch(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { let tick = ctx.tick(); let rules = ctx.view.rules(); let all_in = all_in(ctx); - if launch_blocked(ctx, mem) || ctx.commands_left < 2 || !launch_due(ctx, mem) { + if launch_blocked(ctx, mem) || ctx.commands_left < 2 { + return; + } + if deadline_raid(ctx, mem) || !launch_due(ctx, mem) { return; } let home = home_armed(ctx, mem); let rally = ctx.rally(); let r2 = tiles_sq(ctx.knobs.assemble_radius_tiles); let view = ctx.view; + // M6 owner decision 12: with `reinforce_min_units` the all-in launches + // a group from the rally, not every home unit wherever it stands. + let anywhere = all_in && ctx.pressure.reinforce_min_units == 0; let assembled: Vec = home .iter() .copied() .filter(|u| { - all_in + anywhere || view .unit(*u) .is_some_and(|x| x.pos.dist_sq_i64(rally) <= r2) }) .collect(); let keep = home_guard(ctx); + let siege = |u: &UnitId| view.unit(*u).is_some_and(|x| is_siege(rules, x.kind)); + let sieges = assembled.iter().filter(|u| siege(u)).count(); + let sieges = u32::try_from(sieges).unwrap_or(u32::MAX); + // M6: a raiding personality raids while it cannot field a siege wave. + if !all_in + && ctx.knobs.wave_mode == WaveMode::RaidUntilSiege + && sieges < ctx.knobs.min_siege.max(1) + { + let raiders: Vec = assembled.iter().copied().filter(|u| !siege(u)).collect(); + let n = raiders.len().saturating_sub(keep); + if u32::try_from(n).unwrap_or(u32::MAX) >= ctx.knobs.raid_min_armed { + let max = usize::try_from(ctx.knobs.raid_max_armed).unwrap_or(usize::MAX); + let mut units = raiders; + units.truncate(n.min(max)); + if let Some((target, point)) = raid_target(ctx, mem.raids) { + let every = ctx.knobs.raid_every_ds; + launch_raid(ctx, mem, units, target, point, every); + } + } + return; + } let (need, min_siege) = if all_in { - (1, 0) + (ctx.pressure.reinforce_min_units.max(1), 0) } else { - ( - size(ctx, home.len().saturating_sub(keep)), - ctx.knobs.min_siege, - ) + // M6: an upward-only noise bonus per launch, within the cap. + let bonus = ctx.noise(salt::WAVE_UNITS + u64::from(mem.waves_launched)) + % (u64::from(ctx.knobs.noise_wave_units) + 1); + let bonus = u32::try_from(bonus).expect("at most MAX_WAVE_UNITS"); + let need = size(ctx, home.len().saturating_sub(keep)) + .saturating_add(bonus) + .min(ctx.knobs.max_wave_units.max(1)); + (need, ctx.knobs.min_siege) }; - let siege = |u: &UnitId| view.unit(*u).is_some_and(|x| is_siege(rules, x.kind)); - let sieges = assembled.iter().filter(|u| siege(u)).count(); let going = assembled.len().saturating_sub(keep); - if u32::try_from(going).unwrap_or(u32::MAX) < need - || u32::try_from(sieges).unwrap_or(u32::MAX) < min_siege - { + if u32::try_from(going).unwrap_or(u32::MAX) < need || sieges < min_siege { return; } let wain = ctx @@ -369,16 +592,34 @@ fn launch(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { .iter() .copied() .find(|w| !ctx.is_claimed(*w)); - if wain.is_none() && !all_in { + if wain.is_none() && !all_in && wain_needed(ctx) { return; } - let Some(target) = choose_target(ctx) else { - return; - }; + // M6: the first wave waits a little longer, once, by the bot's noise. + if !all_in && mem.waves_launched == 0 && mem.next_wave_at == 0 { + let extra = first_wave_extra(ctx); + if extra > 0 { + mem.next_wave_at = tick.saturating_add(extra); + return; + } + } let mut roster = assembled; roster.sort_by_key(|u| (!siege(u), *u)); roster.truncate(going.min(usize::try_from(ctx.knobs.max_wave_units).unwrap_or(usize::MAX))); roster.sort(); + // M6 owner decision 12: a level with `launch_ratio_permille` waits + // until the wave outweighs the enemy army at home. + let ratio = ctx.pressure.launch_ratio_permille; + if !all_in + && ratio > 0 + && i128::from(army_strength(ctx, &roster)) * 1000 + < i128::from(enemy_home_strength(ctx)) * i128::from(ratio) + { + return; + } + let Some(target) = choose_target(ctx, mem.waves_launched) else { + return; + }; let stage = stage_point(ctx, target, roster.len() + usize::from(wain.is_some())); let s = ctx.view.map().center_of(stage); for u in roster.iter().chain(wain.iter()) { @@ -395,6 +636,7 @@ fn launch(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { queue: false, }); } + let launched_units = u16::try_from(roster.len()).unwrap_or(u16::MAX); mem.wave = Some(Wave { units: roster, wain, @@ -403,16 +645,243 @@ fn launch(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { phase: WavePhase::Stage, launched_at: tick, phase_at: tick, + kind: WaveKind::Siege, + launched_units, + last_contact_at: tick, + target_hp: ctx.view.building_hp(target).map_or(0, |(hp, _)| hp), + progress_at: 0, + }); + mem.waves_launched = mem.waves_launched.saturating_add(1); + mem.next_wave_at = tick.saturating_add(interval_after( + ctx, + ctx.knobs.wave_interval_ds, + mem.waves_launched, + )); +} + +/// The Lanchester strength of `units` (M6 owner decision 12): `sum(hp) x +/// sum(dps_x10)` of the living armed ones, with their owner's resolved +/// stats ([`super::influence`]'s measure). +pub fn army_strength(ctx: &mut Ctx<'_>, units: &[UnitId]) -> i64 { + let view = ctx.view; + let rules = view.rules(); + let (mut hp, mut dps) = (0i64, 0i64); + for id in units { + let Some(u) = view.unit(*id) else { continue }; + let Some(cooldown) = rules + .unit_kind(u.kind.0) + .and_then(|k| k.attack.as_ref()) + .map(|a| a.cooldown_ds) + else { + continue; + }; + let s = ctx.unit_stats(u.owner, u.kind); + hp = hp.saturating_add(i64::from(s.max_hp.saturating_sub(u.damage_taken).max(0))); + dps = dps.saturating_add(i64::from( + s.attack.map_or(0, |a| dps_x10(a.damage, cooldown)), + )); + } + hp.saturating_mul(dps) +} + +/// The Lanchester strength of the visible enemy armed units (no Supply +/// Wain) standing on their owner's own land: the army a wave would meet +/// at home (M6 owner decision 12, `pressure.launch_ratio_permille`). +pub fn enemy_home_strength(ctx: &mut Ctx<'_>) -> i64 { + let view = ctx.view; + let rules = view.rules(); + let map = view.map(); + let territory = view.territory(); + let home: Vec = visible_enemies(&view, ctx.me) + .filter(|u| { + rules + .unit_kind(u.kind.0) + .is_some_and(|k| k.class != UnitClass::Civilian && !k.supplies) + && territory.owner_at(map.tile_of(u.pos)) == Some(u.owner) + }) + .map(|u| u.id) + .collect(); + army_strength(ctx, &home) +} + +/// The difficulty's deadline raid (M6): once the tick reaches the first +/// raid's `by_ds` with no wave launched yet, and at least `min_armed` +/// non-siege armed units are home (anywhere, not only at the rally), up to +/// `max_armed` of them (by id, the home guard kept) raid at once. It +/// bypasses the age, tech, not-before, siege and Wain gates and does not +/// stage. Returns whether it launched. +fn deadline_raid(ctx: &mut Ctx<'_>, mem: &mut BotMemory) -> bool { + let Some(raid) = ctx.first_raid else { + return false; + }; + if mem.raid_used || mem.waves_launched > 0 || ctx.tick() < ctx.ticks(raid.by_ds) { + return false; + } + let view = ctx.view; + let rules = view.rules(); + let keep = home_guard(ctx); + let mut units: Vec = home_armed(ctx, mem) + .into_iter() + .filter(|u| view.unit(*u).is_some_and(|x| !is_siege(rules, x.kind))) + .collect(); + let n = units.len().saturating_sub(keep); + if u32::try_from(n).unwrap_or(u32::MAX) < raid.min_armed { + return false; + } + units.truncate(n.min(usize::try_from(raid.max_armed).unwrap_or(usize::MAX))); + let Some((target, point)) = raid_target(ctx, mem.raids) else { + return false; + }; + let every = ctx.knobs.raid_every_ds; + launch_raid(ctx, mem, units, target, point, every); + mem.raid_used = true; + true +} + +/// Launch a raid of `units` (sorted) at `point` (raiding `target`): one +/// `AttackMove` straight there, no Wain, no stage; the next launch waits +/// `every_ds` (jittered). +fn launch_raid( + ctx: &mut Ctx<'_>, + mem: &mut BotMemory, + mut units: Vec, + target: BuildingId, + point: Tile, + every_ds: u32, +) { + let tick = ctx.tick(); + units.sort(); + let goal = ctx.view.map().center_of(point); + for u in &units { + ctx.claim(*u); + } + ctx.order(units.clone(), |units| Command::AttackMove { + units, + target: goal, + }); + let launched_units = u16::try_from(units.len()).unwrap_or(u16::MAX); + mem.wave = Some(Wave { + units, + wain: None, + target, + stage: point, + phase: WavePhase::Assault, + launched_at: tick, + phase_at: tick, + kind: WaveKind::Raid, + launched_units, + last_contact_at: tick, + target_hp: 0, + progress_at: 0, }); mem.waves_launched = mem.waves_launched.saturating_add(1); - mem.next_wave_at = tick.saturating_add(ctx.ticks(ctx.knobs.wave_interval_ds)); + mem.raids = mem.raids.saturating_add(1); + mem.next_wave_at = mem.next_wave_at.max(tick.saturating_add(interval_after( + ctx, + every_ds, + mem.waves_launched, + ))); +} + +/// The raid's target and point (M6): the enemy complete building with +/// workers on it (`Facts::enemy`'s worked buildings) whose centre lies +/// more than `stage_margin_tiles` beyond every enemy shooter's range, with +/// the best score `raid_worker_weight x workers - isqrt(enemy unit +/// strength near it) - dist_tiles x raid_distance_permille / 1000` (the +/// distance from the rally), ties to the lowest id, the bot's noise +/// picking between the top two; its point is the building's centre tile. +/// When none qualifies (Passive has only its shooting Seat), the +/// fallback is the passable enemy-owned tile nearest the rally beyond +/// every shooter's reach plus the margin (ties to the mirror-aware tile +/// index), raiding the enemy Town nearest it. +pub fn raid_target(ctx: &mut Ctx<'_>, n: u16) -> Option<(BuildingId, Tile)> { + let rally = ctx.rally(); + let view = ctx.view; + let rules = view.rules(); + let map = view.map(); + let shooters = shooters(ctx); + let margin = ctx.knobs.stage_margin_tiles; + let r = ctx.knobs.influence_radius_cells; + let weight = i64::from(ctx.knobs.raid_worker_weight); + let per_tile = i64::from(ctx.knobs.raid_distance_permille); + let worked = ctx.facts.enemy.worked.clone(); + let mut scored: Vec<(i64, BuildingId, Tile)> = Vec::new(); + for (b, workers) in worked { + let Some(bb) = view.building(b) else { + continue; + }; + let centre = kind_centre(rules, bb.kind, bb.tile); + if !out_of_reach(&shooters, centre, margin) { + continue; + } + let inf = ctx.influence(); + let near = inf.strength(&inf.enemy_units, inf.cell_at(centre), r); + let near = i64::try_from(isqrt(u64::try_from(near.max(0)).unwrap_or(0))).unwrap_or(0); + let score = weight + .saturating_mul(i64::from(workers)) + .saturating_sub(near) + .saturating_sub(dist_tiles(centre, rally).saturating_mul(per_tile) / 1000); + scored.push(( + score.saturating_neg(), + b, + kind_centre_tile(rules, bb.kind, bb.tile), + )); + } + scored.sort(); + if let Some(best) = scored.first().copied() { + let pick = match scored.get(1) { + Some(second) if ctx.noise(salt::TARGET + (1 << 20) + u64::from(n)) % 2 == 1 => *second, + _ => best, + }; + return Some((pick.1, pick.2)); + } + let territory = view.territory(); + let me = ctx.me; + let rally_tile = map.tile_of(rally); + let mut best: Option<(i64, u32, Tile)> = None; + for y in 0..map.height() { + for x in 0..map.width() { + let t = Tile::new(x, y); + if territory.owner_at(t).is_none_or(|p| p == me) || !map.passable(t) { + continue; + } + let dx = i64::from(x) - i64::from(rally_tile.x); + let dy = i64::from(y) - i64::from(rally_tile.y); + let key = (dx * dx + dy * dy, ctx.map.tie_index(t, 1, 1), t); + if best.is_some_and(|b| (b.0, b.1) <= (key.0, key.1)) { + continue; + } + if out_of_reach(&shooters, map.center_of(t), margin) { + best = Some(key); + } + } + } + let point = best?.2; + let at = map.center_of(point); + let target = ctx + .facts + .enemy_towns + .iter() + .filter_map(|t| { + let b = view.building(*t)?; + Some((kind_centre(rules, b.kind, b.tile).dist_sq_i64(at), *t)) + }) + .min()? + .1; + Some((target, point)) } /// End the wave: its units come home ([`recall_wave`]). Waits for a plan -/// with room for the two commands, so no unit is left behind. +/// with room for the two commands, so no unit is left behind. A wave that +/// ends with fewer than `recall_below_units` armed units counts as wiped +/// (M6). fn end(ctx: &mut Ctx<'_>, mem: &mut BotMemory, wave: Wave) { + let wiped = u32::try_from(wave.units.len()).unwrap_or(u32::MAX) < ctx.knobs.recall_below_units; mem.wave = Some(wave); if ctx.commands_left >= 2 { + if wiped { + mem.wiped = mem.wiped.saturating_add(1); + } recall_wave(ctx, mem); } } @@ -431,13 +900,23 @@ fn run(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { if wave.wain.is_some_and(|w| !mine(&w)) { wave.wain = None; } + if wave.kind == WaveKind::Raid { + run_raid(ctx, mem, wave); + return; + } let all_in = all_in(ctx); if all_in { let max = usize::try_from(ctx.knobs.max_wave_units).unwrap_or(usize::MAX); let room = max.saturating_sub(wave.units.len()); - let home = home_armed(ctx, mem); - let free = home.len().saturating_sub(home_guard(ctx)); - let joiners: Vec = home.into_iter().take(room.min(free)).collect(); + // M6 owner decision 12: with `reinforce_min_units` the joiners go + // as a group from the rally, not one by one as they are trained. + let joiners: Vec = if ctx.pressure.reinforce_min_units > 0 { + rally_group(ctx, mem, room) + } else { + let home = home_armed(ctx, mem); + let free = home.len().saturating_sub(home_guard(ctx)); + home.into_iter().take(room.min(free)).collect() + }; wave.units.extend(joiners); wave.units.sort(); } @@ -456,7 +935,7 @@ fn run(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { end(ctx, mem, wave); return; } - let Some(next) = choose_target(ctx) else { + let Some(next) = choose_target(ctx, mem.waves_launched) else { end(ctx, mem, wave); return; }; @@ -465,6 +944,8 @@ fn run(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { wave.phase = WavePhase::Stage; wave.launched_at = tick; wave.phase_at = tick; + wave.target_hp = view.building_hp(next).map_or(0, |(hp, _)| hp); + wave.progress_at = 0; stage_upkeep(ctx, &wave); mem.wave = Some(wave); return; @@ -472,22 +953,46 @@ fn run(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { let annexing = view .annexation(wave.target) .is_some_and(|a| a.attacker == me); + // M6 owner decision 12: the commit rule's progress (the target losing + // hit points since the last plan). + if let Some((hp, _)) = view.building_hp(wave.target) { + if hp < wave.target_hp { + wave.progress_at = tick; + } + wave.target_hp = hp; + } let over = if all_in { wave.units.is_empty() } else { annexers == 0 || armed < ctx.knobs.recall_below_units || (tick.saturating_sub(wave.launched_at) >= ctx.ticks(ctx.knobs.wave_max_ds) - && !annexing) + && !annexing + && !committed(ctx, &wave)) }; if over { end(ctx, mem, wave); return; } + // M6 owner decision 12: reinforcements join an assault, and (with + // `assault_ratio_permille`) a staging wave still short of the strength + // to assault. + let ready = wave.phase != WavePhase::Stage || all_in || assault_ready(ctx, &wave); + if !all_in && (matches!(wave.phase, WavePhase::Assault | WavePhase::Hold) || !ready) { + reinforce(ctx, mem, &mut wave); + } match wave.phase { WavePhase::Stage => { if staged(ctx, &wave) && ctx.commands_left >= 3 { - start_assault(ctx, &mut wave); + if ready { + start_assault(ctx, &mut wave); + } else if tick.saturating_sub(wave.phase_at) >= ctx.ticks(ctx.knobs.stage_max_ds) { + // Still short at the stage's end: the wave comes home. + end(ctx, mem, wave); + return; + } else { + stage_upkeep(ctx, &wave); + } } else { stage_upkeep(ctx, &wave); } @@ -505,6 +1010,71 @@ fn run(ctx: &mut Ctx<'_>, mem: &mut BotMemory) { mem.wave = Some(wave); } +/// Whether a staged siege wave may start its assault (M6 owner decision +/// 12, `pressure.assault_ratio_permille`): the knob is off, or the +/// wave's Lanchester strength times 1000 reaches the knob times that of the +/// enemy armed units on the enemy's own land. +fn assault_ready(ctx: &mut Ctx<'_>, wave: &Wave) -> bool { + let ratio = ctx.pressure.assault_ratio_permille; + ratio == 0 + || i128::from(army_strength(ctx, &wave.units)) * 1000 + >= i128::from(enemy_home_strength(ctx)) * i128::from(ratio) +} + +/// Run the active raid (M6). Its contact timer is refreshed while any +/// enemy unit stands within `raid_idle_tiles` of one of its units; it +/// ends (its units come home) with fewer than `recall_below_units` armed +/// units, after `raid_idle_ds` with no contact, or `raid_max_ds` after the +/// launch. When its target is gone (destroyed, or no longer the enemy's) +/// it takes a new one while at least `continue_min_units` remain, and +/// ends otherwise. Units off station attack-move to the raid point again. +fn run_raid(ctx: &mut Ctx<'_>, mem: &mut BotMemory, mut wave: Wave) { + let view = ctx.view; + let me = ctx.me; + let tick = ctx.tick(); + let reach = tiles_sq(ctx.knobs.raid_idle_tiles); + let contact = wave + .units + .iter() + .filter_map(|u| view.unit(*u)) + .any(|u| visible_enemies(&view, me).any(|e| e.pos.dist_sq_i64(u.pos) <= reach)); + if contact { + wave.last_contact_at = tick; + } + let armed = u32::try_from(wave.units.len()).unwrap_or(u32::MAX); + if armed < ctx.knobs.recall_below_units + || tick.saturating_sub(wave.last_contact_at) >= ctx.ticks(ctx.knobs.raid_idle_ds) + || tick.saturating_sub(wave.launched_at) >= ctx.ticks(ctx.knobs.raid_max_ds) + { + end(ctx, mem, wave); + return; + } + let gone = view.building(wave.target).is_none_or(|b| b.owner == me); + if gone { + if armed < ctx.knobs.continue_min_units { + end(ctx, mem, wave); + return; + } + let Some((target, point)) = raid_target(ctx, mem.raids) else { + end(ctx, mem, wave); + return; + }; + wave.target = target; + wave.stage = point; + } + let goal = ctx.view.map().center_of(wave.stage); + let tol = station(ctx, wave.units.len()); + let moves = off_station(ctx, &wave.units, goal, tol); + for u in &moves { + ctx.claim(*u); + } + ctx.order(moves, |units| Command::AttackMove { + units, + target: goal, + }); + mem.wave = Some(wave); +} + /// Whether the stage is over: `stage_ready_permille` of the armed units /// and every siege unit within `stage_radius_tiles` of the stage point, or /// `stage_max_ds` since the stage began. @@ -587,15 +1157,42 @@ fn stage_upkeep(ctx: &mut Ctx<'_>, wave: &Wave) { keep_wain(ctx, wave, s, tol); } +/// Whether an assault ordered at `since` holds at the Wain point W (M6 +/// owner decision 12, `pressure.lure_ds`): within `lure_ds` of `since`, +/// while a visible enemy armed unit (not a Supply Wain) stands within +/// `siege_focus_tiles` of the target's centre. The wave waits out of every +/// enemy shooter's reach, so the defenders that come out to it fight +/// without the Town's and Watchtowers' fire. +fn luring(ctx: &Ctx<'_>, since: u32, centre: FxVec2) -> bool { + let lure = ctx.pressure.lure_ds; + if lure == 0 || ctx.tick().saturating_sub(since) >= ctx.ticks(lure) { + return false; + } + let r2 = tiles_sq(ctx.knobs.siege_focus_tiles); + let rules = ctx.view.rules(); + visible_enemies(&ctx.view, ctx.me).any(|e| { + e.pos.dist_sq_i64(centre) <= r2 + && rules + .unit_kind(e.kind.0) + .is_some_and(|k| k.class != UnitClass::Civilian && !k.supplies) + }) +} + /// The assault's first plan: every armed unit attack-moves to the target -/// Town (siege as its own group) and the Wain moves to W. +/// Town (siege as its own group), or (M6, [`luring`]) to the Wain point W, +/// and the Wain moves to W. fn start_assault(ctx: &mut Ctx<'_>, wave: &mut Wave) { let view = ctx.view; let rules = view.rules(); let Some(b) = view.building(wave.target) else { return; }; - let centre = kind_centre(rules, b.kind, b.tile); + let w = wain_point(ctx, wave.target, wave.stage); + let centre = if luring(ctx, ctx.tick(), kind_centre(rules, b.kind, b.tile)) { + w + } else { + kind_centre(rules, b.kind, b.tile) + }; let (siege, other): (Vec, Vec) = wave .units .iter() @@ -613,7 +1210,6 @@ fn start_assault(ctx: &mut Ctx<'_>, wave: &mut Wave) { units, target: centre, }); - let w = wain_point(ctx, wave.target, wave.stage); if let Some(wain) = wave.wain && !ctx.is_claimed(wain) { @@ -637,12 +1233,16 @@ fn assault_upkeep(ctx: &mut Ctx<'_>, wave: &Wave) { return; }; let centre = kind_centre(rules, b.kind, b.tile); + let w = wain_point(ctx, wave.target, wave.stage); + // M6 owner decision 12: while luring, the wave holds at W. + let lure = luring(ctx, wave.phase_at, centre); let focus_r2 = tiles_sq(ctx.knobs.siege_focus_tiles); let goal = TargetId::Building(wave.target); let focus: Vec = wave .units .iter() .copied() + .filter(|_| !lure) .filter(|u| !ctx.is_claimed(*u)) .filter(|u| { view.unit(*u).is_some_and(|x| { @@ -665,19 +1265,68 @@ fn assault_upkeep(ctx: &mut Ctx<'_>, wave: &Wave) { target: goal, }); } - let tol = town_station(ctx, wave); - let moves = off_station(ctx, &wave.units, centre, tol); + let (to, tol) = if lure { + (w, station(ctx, wave.units.len())) + } else { + (centre, town_station(ctx, wave)) + }; + hold_vanguard(ctx, wave, to); + let moves = off_station(ctx, &wave.units, to, tol); for u in &moves { ctx.claim(*u); } - ctx.order(moves, |units| Command::AttackMove { - units, - target: centre, - }); - let w = wain_point(ctx, wave.target, wave.stage); + ctx.order(moves, |units| Command::AttackMove { units, target: to }); keep_wain(ctx, wave, w, ctx.knobs.wain_leash_tiles); } +/// Keep the assaulting wave together (M6 owner decision 12, +/// `pressure.cohesion_tiles`): the unclaimed armed units more than +/// `cohesion_tiles` nearer `goal` than the wave's rear quarter (the unit +/// three quarters of the way down the roster sorted by distance) are claimed +/// and, while they still have an order, stopped (a stopped unit guards +/// where it stands and fights what comes into reach), until the rest close +/// up. Only the units past the stage point count (a reinforcement still on +/// its way from the rally holds nobody up), and a unit within an enemy +/// shooter's reach is never stopped (it is under fire: it goes on). +fn hold_vanguard(ctx: &mut Ctx<'_>, wave: &Wave, goal: FxVec2) { + let gap = ctx.pressure.cohesion_tiles; + if gap == 0 || wave.units.len() < 2 { + return; + } + let view = ctx.view; + let staged_at = dist_tiles(view.map().center_of(wave.stage), goal) + .saturating_add(i64::from(ctx.knobs.stage_radius_tiles)); + let mut dist: Vec<(i64, UnitId)> = wave + .units + .iter() + .filter_map(|u| Some((dist_tiles(view.unit(*u)?.pos, goal), *u))) + .filter(|(d, _)| *d <= staged_at) + .collect(); + if dist.len() < 2 { + return; + } + dist.sort(); + // The rear quarter's front: three quarters of the wave are at least + // this near the goal. + let rear = dist[(dist.len() * 3 / 4).min(dist.len() - 1)].0; + let fire = shooters(ctx); + let ahead: Vec = dist + .iter() + .filter(|(d, u)| d.saturating_add(i64::from(gap)) < rear && !ctx.is_claimed(*u)) + .filter(|(_, u)| view.unit(*u).is_some_and(|x| out_of_reach(&fire, x.pos, 0))) + .map(|(_, u)| *u) + .collect(); + let mut stop = Vec::new(); + for u in ahead { + ctx.claim(u); + if view.unit(u).is_some_and(|x| x.order.is_some()) && !attacking(ctx, u) { + stop.push(u); + } + } + stop.sort(); + ctx.order(stop, |units| Command::Stop { units }); +} + /// The ring hold while the target is at 0 HP and still the enemy's: ring /// tiles to annexing units, defenders attacked, the rest on station. fn hold(ctx: &mut Ctx<'_>, wave: &Wave) { @@ -754,11 +1403,11 @@ fn hold(ctx: &mut Ctx<'_>, wave: &Wave) { return; } } - // Defenders: every enemy non-civilian unit near the Town's centre. + // Defenders: every enemy non-civilian unit near the Town's centre, read + // through the fog seam. let reach = tiles_sq(rules.annexation_defender_radius_tiles.saturating_add(2)); - let defenders: Vec = view - .units() - .filter(|x| x.owner != ctx.me && x.pos.dist_sq_i64(centre) <= reach) + let defenders: Vec = visible_enemies(&view, ctx.me) + .filter(|x| x.pos.dist_sq_i64(centre) <= reach) .filter(|x| { rules .unit_kind(x.kind.0) @@ -839,14 +1488,17 @@ fn hold(ctx: &mut Ctx<'_>, wave: &Wave) { mod tests { use super::*; use crate::standard::defend; + use crate::standard::{Ctx, ctx::jitter}; use crate::testkit::{Arena, P0, P1, place, spawn, standard_vs_passive}; use rules::Personality; + use sim::MatchSetup; /// P0's rally in these arenas: the rally seed (36,64) is passable own /// land. const RALLY: (i32, i32) = (36, 64); fn wave(units: Vec, wain: Option, target: BuildingId, stage: Tile) -> Wave { + let launched_units = u16::try_from(units.len()).unwrap(); Wave { units, wain, @@ -855,6 +1507,11 @@ mod tests { phase: WavePhase::Stage, launched_at: 0, phase_at: 0, + kind: WaveKind::Siege, + launched_units, + last_contact_at: 0, + target_hp: 0, + progress_at: 0, } } @@ -890,12 +1547,14 @@ mod tests { #[test] fn launch_needs_age_size_siege_wain_and_timer() { let r = crate::testkit::rules(); + // M6: noise off (the M5b timing; noise has its own tests). + let quiet_k = quiet(&r, |_| {}); let a = Arena::frozen(standard_vs_passive(&r), at_rally(&r, true, 7, 1, 1)); let mut armed = a.units_of(P0, "skirmisher"); armed.extend(a.units_of(P0, "mangonel")); armed.sort(); let wain = a.units_of(P0, "supply_wain")[0]; - let mut ctx = a.ctx(P0); + let mut ctx = Ctx::new(a.sim.view(), P0, &quiet_k, a.map(P0)); assert_eq!(size(&ctx, 8), 8, "the table's first row"); let mut mem = BotMemory::default(); step(&mut ctx, &mut mem); @@ -938,13 +1597,13 @@ mod tests { ("no Wain", at_rally(&r, true, 7, 1, 0)), ] { let a = Arena::frozen(standard_vs_passive(&r), setup); - let mut ctx = a.ctx(P0); + let mut ctx = Ctx::new(a.sim.view(), P0, &quiet_k, a.map(P0)); let mut mem = BotMemory::default(); step(&mut ctx, &mut mem); assert!(mem.wave.is_none(), "{why}"); assert!(ctx.into_commands().is_empty(), "{why}"); } - let mut ctx = a.ctx(P0); + let mut ctx = Ctx::new(a.sim.view(), P0, &quiet_k, a.map(P0)); let mut mem = BotMemory { next_wave_at: a.sim.tick() + 1, ..BotMemory::default() @@ -961,7 +1620,7 @@ mod tests { ] .concat(), ); - let mut ctx = far.ctx(P0); + let mut ctx = Ctx::new(far.sim.view(), P0, &quiet_k, far.map(P0)); let mut mem = BotMemory::default(); step(&mut ctx, &mut mem); assert!(mem.wave.is_none(), "not assembled"); @@ -973,12 +1632,14 @@ mod tests { #[test] fn size_respects_free_pop() { let r = crate::testkit::rules(); + // M6: noise off (the M5b timing; noise has its own tests). + let quiet_k = quiet(&r, |_| {}); let (x, y) = RALLY; let a = Arena::frozen( standard_vs_passive(&r), vec![spawn(&r, P0, "skirmisher", x, y, 5)], ); - let ctx = a.ctx(P0); + let ctx = Ctx::new(a.sim.view(), P0, &quiet_k, a.map(P0)); assert_eq!(ctx.facts.pop, (10, 25)); assert_eq!((size(&ctx, 5), home_guard(&ctx)), (8, 0)); // A full pop: 5 + 13 Yeomen, 2 Skirmishers, a Mangonel and a Wain. @@ -990,7 +1651,7 @@ mod tests { ] .concat(), ); - let mut ctx = full.ctx(P0); + let mut ctx = Ctx::new(full.sim.view(), P0, &quiet_k, full.map(P0)); assert_eq!(ctx.facts.pop, (25, 25)); assert_eq!((size(&ctx, 3), size(&ctx, 0)), (3, 1)); assert_eq!(home_guard(&ctx), 1); @@ -1319,6 +1980,10 @@ mod tests { let rally = ctx.rally(); step(&mut ctx, &mut mem); assert_eq!(mem.wave.is_none(), ends, "{why}"); + // M6: only a wave that ends with fewer than `recall_below_units` + // armed units counts as wiped; one that ends by the annexer + // rule or by age with 3 does not, nor one that goes on. + assert_eq!(mem.wiped, u16::from(why == "two armed"), "{why}: wiped"); let home = ctx.into_commands().contains(&Command::AttackMove { units, target: rally, @@ -1329,10 +1994,12 @@ mod tests { /// Once the target is the bot's, a wave of at least /// `continue_min_units` armed units retargets to the enemy Town nearest - /// the rally with a fresh stage point; a smaller one comes home. + /// the rally with a fresh stage point; a smaller one comes home. (M6: + /// without `seat_last` and noise, which have their own tests.) #[test] fn retargets_after_capture() { let r = crate::testkit::rules(); + let quiet_k = quiet(&r, |k| k.seat_last = false); for (n, goes_on) in [(4, true), (3, false)] { let a = Arena::frozen( standard_vs_passive(&r), @@ -1349,7 +2016,7 @@ mod tests { wave: Some(w), ..BotMemory::default() }; - let mut ctx = a.ctx(P0); + let mut ctx = Ctx::new(a.sim.view(), P0, &quiet_k, a.map(P0)); let rally = ctx.rally(); let next = a.seat(P1); let stage = stage_point(&mut ctx, next, n as usize + 1); @@ -1403,7 +2070,7 @@ mod tests { wave: Some(w), ..BotMemory::default() }; - let mut ctx = a.ctx(P0); + let mut ctx = a.base_ctx(P0); ctx.knobs = &k; step(&mut ctx, &mut mem); let w = mem.wave.clone().expect("the wave goes on"); @@ -1504,7 +2171,7 @@ mod tests { next_wave_at: a.sim.tick() + 1000, ..BotMemory::default() }; - let mut ctx = a.ctx(P0); + let mut ctx = a.base_ctx(P0); ctx.knobs = &k; step(&mut ctx, &mut mem); let cmds = ctx.into_commands(); @@ -1558,4 +2225,1188 @@ mod tests { assert_eq!(dist_sq_to_rect(FxVec2::from_ints(16, 11), r), tiles_sq(3)); assert_eq!((spread(1), spread(9), spread(24)), (1, 2, 3)); } + + // ---- M6 ---- + + /// The `standard` knobs with every noise knob off (the M5b timing). + fn quiet(r: &Rules, edit: impl FnOnce(&mut Personality)) -> Personality { + knobs(r, |k| { + k.noise_wave_interval_permille = 0; + k.noise_wave_units = 0; + k.noise_first_wave_ds = 0; + k.noise_mix_permille = 0; + k.noise_site_top_k = 1; + edit(k); + }) + } + + /// A raid of `units` at `point` against `target`, launched at + /// `launched_at`, last in contact at `contact`. + fn raid( + units: Vec, + target: BuildingId, + point: Tile, + launched_at: u32, + contact: u32, + ) -> Wave { + let launched_units = u16::try_from(units.len()).unwrap(); + Wave { + units, + wain: None, + target, + stage: point, + phase: WavePhase::Assault, + launched_at, + phase_at: launched_at, + kind: WaveKind::Raid, + launched_units, + last_contact_at: contact, + target_hp: 0, + progress_at: 0, + } + } + + /// P1's gathering buildings, each worked by the units sent to it. + fn worked_p1(cmds: Vec, jobs: &[(&str, &str, u16)]) -> Arena { + let r = crate::testkit::rules(); + let mut a = Arena::frozen(standard_vs_passive(&r), cmds); + let scribes = a.units_of(P1, "scribe"); + let yeomen = a.units_of(P1, "yeoman"); + let (mut s, mut y) = (scribes.into_iter(), yeomen.into_iter()); + let mut orders = Vec::new(); + for (kind, worker, n) in jobs { + let node = *a + .buildings_of(P1, kind) + .iter() + .find(|b| { + !orders + .iter() + .any(|c| matches!(c, Command::Gather { node, .. } if node == *b)) + }) + .expect("a node of the kind"); + let units: Vec = (0..*n) + .filter_map(|_| { + if *worker == "scribe" { + s.next() + } else { + y.next() + } + }) + .collect(); + orders.push(Command::Gather { units, node }); + } + a.issue(P1, orders); + a + } + + /// M6: the raid goes for the worked enemy building outside every + /// shooter's reach (plus the stage margin) with the best score; a + /// building inside the reach of the Seat or a Watchtower is never + /// picked however many work it, and between the top two the bot's + /// noise picks. + #[test] + fn raid_targets_the_most_worked_building_outside_tower_reach() { + let r = crate::testkit::rules(); + let base = vec![ + // A: out of every reach, three Scribes. + place(&r, P1, "scriptorium", 86, 57), + // B: beside the Seat, three Scribes. + place(&r, P1, "scriptorium", 96, 61), + // C: a Croft under a Watchtower. + place(&r, P1, "croft", 90, 74), + place(&r, P1, "watchtower", 92, 77), + spawn(&r, P1, "scribe", 92, 58, 6), + ]; + let a = worked_p1( + base.clone(), + &[ + ("scriptorium", "scribe", 3), + ("scriptorium", "scribe", 3), + ("croft", "yeoman", 1), + ], + ); + let scriptoria = a.buildings_of(P1, "scriptorium"); + let croft = a.buildings_of(P1, "croft")[0]; + let mut ctx = a.ctx(P0); + let worked: Vec = ctx.facts.enemy.worked.iter().map(|(b, _)| *b).collect(); + assert_eq!(worked.len(), 3, "{:?}", ctx.facts.enemy.worked); + let (target, point) = raid_target(&mut ctx, 0).expect("a target"); + assert_eq!(target, scriptoria[0], "the one out of reach"); + let view = a.sim.view(); + let b = view.building(target).unwrap(); + assert_eq!(point, kind_centre_tile(&r, b.kind, b.tile)); + assert!(target != scriptoria[1] && target != croft); + + // A second Croft out of reach with one Yeoman: the noise picks + // between the two, the Scriptorium (three workers) scoring best. + let mut more = base; + more.push(place(&r, P1, "croft", 86, 64)); + let a = worked_p1( + more, + &[ + ("scriptorium", "scribe", 3), + ("scriptorium", "scribe", 3), + ("croft", "yeoman", 1), + ("croft", "yeoman", 1), + ], + ); + let crofts = a.buildings_of(P1, "croft"); + let scriptorium = a.buildings_of(P1, "scriptorium")[0]; + let mut ctx = a.ctx(P0); + let second = ctx.noise(salt::TARGET + (1 << 20)) % 2 == 1; + let (target, _) = raid_target(&mut ctx, 0).unwrap(); + assert_eq!(target, if second { crofts[1] } else { scriptorium }); + } + + /// M6: against Passive (only its shooting Seat, nothing worked) the + /// raid's point is the passable tile of P1's land nearest the rally + /// that lies beyond every shooter's reach plus the margin. + #[test] + fn raid_falls_back_to_the_nearest_enemy_tile_against_passive() { + let r = crate::testkit::rules(); + let a = Arena::frozen(standard_vs_passive(&r), Vec::new()); + let mut ctx = a.ctx(P0); + assert!(ctx.facts.enemy.worked.is_empty()); + let (target, point) = raid_target(&mut ctx, 0).expect("a fallback"); + assert_eq!(target, a.seat(P1)); + let view = a.sim.view(); + let map = view.map(); + let territory = view.territory(); + assert_eq!(territory.owner_at(point), Some(P1)); + assert!(map.passable(point)); + let shooters = shooters(&ctx); + let margin = ctx.knobs.stage_margin_tiles; + assert!(out_of_reach(&shooters, map.center_of(point), margin)); + let rally = map.tile_of(ctx.rally()); + let d = |t: Tile| { + let (dx, dy) = ( + i64::from(t.x) - i64::from(rally.x), + i64::from(t.y) - i64::from(rally.y), + ); + dx * dx + dy * dy + }; + for y in 0..map.height() { + for x in 0..map.width() { + let t = Tile::new(x, y); + if d(t) < d(point) && territory.owner_at(t) == Some(P1) && map.passable(t) { + assert!( + !out_of_reach(&shooters, map.center_of(t), margin), + "{t:?} is nearer and out of reach" + ); + } + } + } + // It is on P1's side of the map, short of the Seat's reach. + assert!(point.x > 64 && point.x < 100, "{point:?}"); + } + + /// M6: a raid comes home after `raid_idle_ds` with no enemy near, or + /// `raid_max_ds` after the launch; an enemy near keeps it going; when + /// its target is gone it takes a new one while `continue_min_units` + /// remain and comes home otherwise. + #[test] + fn raid_ends_when_idle_gone_or_old() { + let r = crate::testkit::rules(); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + spawn(&r, P0, "skirmisher", 70, 40, 6), + place(&r, P1, "croft", 90, 74), + ], + ); + a.run(2500); + let tick = a.sim.tick(); + let units = a.units_of(P0, "skirmisher"); + let seat = a.seat(P1); + let point = Tile::new(70, 40); + let k = quiet(&r, |_| {}); + let idle = k.raid_idle_ds * 2; + let run_one = |a: &Arena, w: Wave| -> (BotMemory, Vec) { + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + let mut mem = BotMemory { + wave: Some(w), + ..BotMemory::default() + }; + step(&mut ctx, &mut mem); + (mem, ctx.into_commands()) + }; + // Idle: launched a moment ago, no contact for long. + let (mem, cmds) = run_one(&a, raid(units.clone(), seat, point, tick - 10, tick - idle)); + assert!(mem.wave.is_none(), "idle"); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::AttackMove { units: u, .. } if *u == units)), + "{cmds:?}" + ); + // Old: in contact, but launched past raid_max_ds. + let (mem, _) = run_one(&a, raid(units.clone(), seat, point, 0, tick)); + assert!(mem.wave.is_none(), "old"); + // A recent, fresh raid goes on. + let (mem, _) = run_one(&a, raid(units.clone(), seat, point, tick - 10, tick - 10)); + assert!(mem.wave.is_some(), "fresh"); + // Contact refreshes the timer. + let mut near = Arena::frozen( + standard_vs_passive(&r), + vec![ + spawn(&r, P0, "skirmisher", 70, 40, 6), + // Out of the Skirmishers' acquire range (6), inside the + // raid's contact radius (10). + spawn(&r, P1, "yeoman", 78, 40, 1), + ], + ); + near.run(2500); + let tick = near.sim.tick(); + let (mem, _) = run_one( + &near, + raid( + near.units_of(P0, "skirmisher"), + near.seat(P1), + point, + tick - 10, + tick - idle, + ), + ); + assert_eq!(mem.wave.map(|w| w.last_contact_at), Some(tick), "contact"); + // Target gone: six left retarget, three come home. + let croft = a.buildings_of(P1, "croft")[0]; + a.issue( + P0, + vec![Command::DebugDamage { + target: TargetId::Building(croft), + amount: u32::MAX, + }], + ); + assert!(a.sim.view().building(croft).is_none(), "destroyed"); + let tick = a.sim.tick(); + let (mem, _) = run_one(&a, raid(units.clone(), croft, point, tick - 10, tick - 10)); + let w = mem.wave.expect("retargeted"); + assert_ne!(w.target, croft); + let (mem, _) = run_one( + &a, + raid(units[..3].to_vec(), croft, point, tick - 10, tick - 10), + ); + assert!(mem.wave.is_none(), "too few to go on"); + } + + /// M6: under `IfEnemyHarrying` a siege wave needs no Wain until the + /// enemy has a Harrying level; then it does. + #[test] + fn wain_needed_only_after_enemy_harrying() { + let r = crate::testkit::rules(); + let k = quiet(&r, |k| k.wain_rule = rules::WainRule::IfEnemyHarrying); + let a = Arena::frozen(standard_vs_passive(&r), at_rally(&r, true, 7, 1, 0)); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + assert!(!wain_needed(&ctx)); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + let w = mem.wave.expect("a siege wave without a Wain"); + assert_eq!((w.kind, w.wain), (WaveKind::Siege, None)); + let mut a = Arena::frozen(standard_vs_passive(&r), at_rally(&r, true, 7, 1, 0)); + a.issue(P0, vec![crate::testkit::research(&r, P1, "harrying_1")]); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + assert!(wain_needed(&ctx), "Harrying I"); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + assert!(mem.wave.is_none(), "now it waits for a Wain"); + } + + /// M6: the `standard` personality keeps the M5b Wain rule: no Wain, no + /// wave, whatever the enemy has researched. + #[test] + fn standard_still_needs_a_wain() { + let r = crate::testkit::rules(); + let k = quiet(&r, |_| {}); + assert_eq!(k.wain_rule, rules::WainRule::Always); + let a = Arena::frozen(standard_vs_passive(&r), at_rally(&r, true, 7, 1, 0)); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + assert!(wain_needed(&ctx)); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + assert!(mem.wave.is_none()); + } + + /// M6: Hard's deadline raid launches once its tick is reached with no + /// wave out yet: without Masonry, without siege or a Wain, from units + /// away from the rally, at most `max_armed` of them, with one + /// `AttackMove` straight onto the enemy's land (no stage); not with + /// fewer than `min_armed`, not twice. + #[test] + fn hard_deadline_raid_launches_by_12000_without_masonry_and_does_not_stage() { + let r = crate::testkit::rules(); + let hard = r.ai.difficulty("hard").unwrap(); + let first = hard.first_raid.expect("Hard has a deadline raid"); + assert_eq!(r.ticks_from_ds(first.by_ds), 12_000); + let k = quiet(&r, |_| {}); + let raid_now = rules::FirstRaid { by_ds: 0, ..first }; + let a = Arena::frozen( + standard_vs_passive(&r), + vec![spawn(&r, P0, "skirmisher", 30, 50, 12)], + ); + let units = a.units_of(P0, "skirmisher"); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.first_raid = Some(raid_now); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + let w = mem.wave.clone().expect("the deadline raid"); + let max = usize::try_from(first.max_armed).unwrap(); + assert_eq!(w.units, units[..max].to_vec()); + assert_eq!( + (w.kind, w.phase, w.wain), + (WaveKind::Raid, WavePhase::Assault, None) + ); + assert!(mem.raid_used && mem.raids == 1 && mem.waves_launched == 1); + let view = a.sim.view(); + assert_eq!( + view.territory().owner_at(w.stage), + Some(P1), + "onto P1's land" + ); + assert_eq!( + ctx.into_commands(), + [Command::AttackMove { + units: units[..max].to_vec(), + target: view.map().center_of(w.stage) + }], + "one order, no stage" + ); + // Before the tick, short of min_armed, or once used: nothing. + let few = Arena::frozen( + standard_vs_passive(&r), + vec![spawn( + &r, + P0, + "skirmisher", + 30, + 50, + u16::try_from(first.min_armed - 1).unwrap(), + )], + ); + let mut ctx = Ctx::new(few.sim.view(), P0, &k, few.map(P0)); + ctx.first_raid = Some(raid_now); + let mut m = BotMemory::default(); + step(&mut ctx, &mut m); + assert!(m.wave.is_none(), "short of min_armed"); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.first_raid = Some(first); + let mut m = BotMemory::default(); + step(&mut ctx, &mut m); + assert!(m.wave.is_none(), "before 12000"); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.first_raid = Some(raid_now); + let mut m = BotMemory { + raid_used: true, + ..BotMemory::default() + }; + step(&mut ctx, &mut m); + assert!(m.wave.is_none(), "once only"); + } + + /// M6: Easy plays its personality with the Masonry gate and every wave + /// at the largest size, capped at 14. + #[test] + fn easy_waits_for_masonry_and_sends_largest_capped_waves() { + let r = crate::testkit::rules(); + let mut setup = MatchSetup::bots(&r, 1, ("easy", "standard"), ("passive", "")); + setup.debug_commands = true; + let a = Arena::frozen(setup.clone(), at_rally(&r, false, 20, 1, 1)); + let ctx = a.ctx(P0); + let easy = r.ai.difficulty("easy").unwrap(); + let cap = easy.max_wave_units.unwrap(); + assert_eq!(ctx.knobs.max_wave_units, cap); + assert!(ctx.knobs.wave_sizes.iter().all(|w| w.armed == cap)); + assert_eq!(size(&ctx, 40), cap, "the largest row, capped, from tick 0"); + assert_eq!(ctx.knobs.wave_requires_age.as_deref(), Some("masonry")); + let mut mem = BotMemory { + waves_launched: 1, + ..BotMemory::default() + }; + let mut ctx = a.ctx(P0); + step(&mut ctx, &mut mem); + assert!(mem.wave.is_none(), "no Masonry"); + let a = Arena::frozen(setup.clone(), at_rally(&r, true, 20, 1, 1)); + let mut ctx = a.ctx(P0); + let mut mem = BotMemory { + waves_launched: 1, + ..BotMemory::default() + }; + step(&mut ctx, &mut mem); + let w = mem.wave.expect("a wave"); + assert_eq!(w.units.len(), usize::try_from(cap).unwrap(), "capped"); + let short = u16::try_from(cap - 2).unwrap(); + let a = Arena::frozen(setup, at_rally(&r, true, short, 1, 1)); + let mut ctx = a.ctx(P0); + let mut mem = BotMemory { + waves_launched: 1, + ..BotMemory::default() + }; + step(&mut ctx, &mut mem); + assert!(mem.wave.is_none(), "{} armed of {cap}", cap - 1); + } + + /// M6: with `seat_last` the wave skips the enemy Seat while the enemy + /// holds another complete Town that no annexation runs on, even when + /// the Seat scores better; without it the best score wins. + #[test] + fn seat_last_skips_the_seat_while_another_town_stands() { + let r = crate::testkit::rules(); + // A second P1 Town to the south-east: farther from P0's rally + // than the Seat. + let a = Arena::frozen( + standard_vs_passive(&r), + vec![place(&r, P1, "town", 104, 84)], + ); + let seat = a.seat(P1); + let other = a + .buildings_of(P1, "town") + .into_iter() + .find(|b| *b != seat) + .unwrap(); + let k = quiet(&r, |_| {}); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + assert!(target_score(&mut ctx, seat) < target_score(&mut ctx, other)); + assert_eq!(choose_target(&mut ctx, 0), Some(other)); + let free = quiet(&r, |k| k.seat_last = false); + let mut ctx = Ctx::new(a.sim.view(), P0, &free, a.map(P0)); + assert_eq!(choose_target(&mut ctx, 0), Some(seat)); + // Only the Seat left: it is the target. + let alone = Arena::frozen(standard_vs_passive(&r), Vec::new()); + let mut ctx = Ctx::new(alone.sim.view(), P0, &k, alone.map(P0)); + assert_eq!(choose_target(&mut ctx, 0), Some(alone.seat(P1))); + } + + /// M6: `seat_last` skips the Seat only while another enemy Town is + /// uncontested: once the bot's own annexation timer runs on the only + /// other Town (0 HP, P0's Skirmishers on its ring, no P1 defender), the + /// Seat is a target again and, scoring better, is the one chosen. + #[test] + fn seat_last_takes_the_seat_when_the_other_town_is_contested() { + let r = crate::testkit::rules(); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![place(&r, P1, "town", 104, 84)], + ); + let seat = a.seat(P1); + let other = a + .buildings_of(P1, "town") + .into_iter() + .find(|b| *b != seat) + .unwrap(); + let k = quiet(&r, |_| {}); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + assert_eq!(choose_target(&mut ctx, 0), Some(other), "uncontested"); + a.issue( + P0, + vec![ + Command::DebugDamage { + target: TargetId::Building(other), + amount: 5000, + }, + spawn(&r, P0, "skirmisher", 103, 86, 2), + ], + ); + a.run(5); + let view = a.sim.view(); + assert_eq!(view.annexation(other).map(|x| x.attacker), Some(P0)); + assert_eq!(view.building(other).map(|b| b.owner), Some(P1)); + let mut ctx = Ctx::new(view, P0, &k, a.map(P0)); + assert!( + ctx.facts.enemy_towns.contains(&other), + "still an enemy Town" + ); + assert!(target_score(&mut ctx, seat) < target_score(&mut ctx, other)); + assert_eq!(choose_target(&mut ctx, 0), Some(seat), "contested"); + } + + /// M6: the target score adds the enemy's strength at a Town: an + /// army parked at the nearer Town sends the wave to the other. + #[test] + fn influence_scored_target_avoids_a_defended_town() { + let r = crate::testkit::rules(); + let a = Arena::frozen( + standard_vs_passive(&r), + vec![ + place(&r, P1, "town", 104, 84), + spawn(&r, P1, "shieldbearer", 98, 64, 30), + ], + ); + let seat = a.seat(P1); + let other = a + .buildings_of(P1, "town") + .into_iter() + .find(|b| *b != seat) + .unwrap(); + let k = quiet(&r, |k| { + k.seat_last = false; + k.target_strength_weight = 1000; + }); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + assert_eq!(choose_target(&mut ctx, 0), Some(other)); + let blind = quiet(&r, |k| { + k.seat_last = false; + k.target_strength_weight = 0; + }); + let mut ctx = Ctx::new(a.sim.view(), P0, &blind, a.map(P0)); + let nearest = [seat, other] + .into_iter() + .min_by_key(|t| target_score(&mut ctx, *t)) + .unwrap(); + assert_eq!(choose_target(&mut ctx, 0), Some(nearest)); + } + + /// M6: `jitter` stays within `permille` either way, the noise is the + /// same at every tick for one seed and salt, and differs by seed. + #[test] + fn noise_jitter_is_bounded_stable_across_plans_and_differs_by_seed() { + for noise in [0u64, 1, 150, 300, 301, 12_345, u64::MAX] { + let v = jitter(1000, noise, 150); + assert!((850..=1150).contains(&v), "{noise}: {v}"); + } + assert_eq!(jitter(1000, 7, 0), 1000); + assert_eq!(jitter(0, 7, 150), 0); + let r = crate::testkit::rules(); + let at = |seed: u64, ticks: u32| { + let mut s = MatchSetup::bots(&r, seed, ("standard", "standard"), ("passive", "")); + s.debug_commands = true; + let mut a = Arena::frozen(s, Vec::new()); + a.run(ticks); + let ctx = a.ctx(P0); + ( + ctx.noise(salt::WAVE_INTERVAL), + interval_after(&ctx, 1800, 0), + first_wave_extra(&ctx), + ) + }; + assert_eq!(at(3, 0), at(3, 777), "tick-free"); + let values: std::collections::BTreeSet<_> = (1..=6).map(|s| at(s, 0)).collect(); + assert!(values.len() >= 5, "seeds differ: {values:?}"); + let k = r.ai.personality("standard").unwrap(); + for (_, interval, extra) in values { + let base = r.ticks_from_ds(1800); + let span = base * k.noise_wave_interval_permille / 1000; + assert!( + (base - span..=base + span).contains(&interval), + "{interval}" + ); + assert!(extra <= r.ticks_from_ds(k.noise_first_wave_ds)); + } + } + + /// M6: the wave-size noise only ever adds: across seeds no wave + /// launches below the table's size, and every one launches with the + /// size plus `noise_wave_units` at the rally. + #[test] + fn wave_size_noise_never_lowers_a_wave() { + let r = crate::testkit::rules(); + let k = r.ai.personality("standard").unwrap(); + let bonus = u16::try_from(k.noise_wave_units).unwrap(); + assert!(bonus > 0); + let mut launched_at_size = 0; + for seed in 1..=6 { + // With the Mangonel: 7 armed (below the size of 8), 8 + the + // largest bonus (always), and exactly 8 (only with no bonus). + for (skirm, must) in [(6u16, false), (7 + bonus, true), (7, false)] { + let mut s = MatchSetup::bots(&r, seed, ("standard", "standard"), ("passive", "")); + s.debug_commands = true; + let a = Arena::frozen(s, at_rally(&r, true, skirm, 1, 1)); + let mut ctx = a.ctx(P0); + let mut mem = BotMemory { + waves_launched: 1, + ..BotMemory::default() + }; + step(&mut ctx, &mut mem); + if skirm == 6 { + assert!(mem.wave.is_none(), "seed {seed}: below the size"); + } else if must { + assert!(mem.wave.is_some(), "seed {seed}: size plus the bonus"); + } else { + launched_at_size += usize::from(mem.wave.is_some()); + } + } + } + assert!(launched_at_size < 6, "the bonus holds some waves back"); + } + + /// The level pressure `edit` makes of [`rules::Pressure::OFF`] (M6 + /// owner decision 12). + fn pressed(edit: impl FnOnce(&mut rules::Pressure)) -> rules::Pressure { + let mut p = rules::Pressure::OFF; + edit(&mut p); + p + } + + /// M6 owner decision 12: while a siege wave assaults, home armed units + /// at the rally join it as one group once at least + /// `reinforce_min_units` stand there beyond the home guard. The group + /// joins the roster and its launch count (the retreat rule's loss base) + /// and attack-moves to the target; a smaller group, and any group while + /// the defend latch blocks launches, stays home. + #[test] + fn reinforcements_join_an_assault_in_groups_from_the_rally() { + let r = crate::testkit::rules(); + let (x, y) = RALLY; + let k = quiet(&r, |_| {}); + // One armed unit always stays at the rally (a pop that fills after + // the group left must still hold one at home), so 6 at the rally is + // one short of a group of 6. + // `held`: the target is at 0 HP, so the wave holds its ring (a group + // joins a holding wave as it joins an assaulting one). + for (at_rally, guard, latched, held, joins) in [ + (7u16, 0u32, false, false, 6usize), + (6, 0, false, false, 0), + (7, 2, false, false, 0), + (8, 2, false, false, 6), + (7, 0, true, false, 0), + (7, 0, false, true, 6), + ] { + let why = + format!("{at_rally} at the rally, guard {guard}, latched {latched}, held {held}"); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 90, 64, 4), + spawn(&r, P0, "bowman", x, y, at_rally), + ], + ); + let out = a.units_of(P0, "skirmisher"); + let home = a.units_of(P0, "bowman"); + let target = a.seat(P1); + if held { + a.issue( + P0, + vec![Command::DebugDamage { + target: TargetId::Building(target), + amount: 1_000_000, + }], + ); + a.run(r.cmd_delay_ticks + 1); + assert_eq!(a.sim.view().building_hp(target).map(|h| h.0), Some(0)); + } + let tick = a.sim.tick(); + let mut w = wave(out.clone(), None, target, Tile::new(72, 64)); + // A wave whose target fell to 0 HP on an earlier plan holds. + w.phase = if held { + WavePhase::Hold + } else { + WavePhase::Assault + }; + w.phase_at = tick; + let mut mem = BotMemory { + wave: Some(w), + defend: latched.then_some(crate::standard::memory::Defend { + town: a.seat(P0), + last_threat: tick, + since: tick, + }), + ..BotMemory::default() + }; + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = pressed(|p| { + p.reinforce_min_units = 6; + p.home_guard_units = guard; + }); + step(&mut ctx, &mut mem); + let w = mem.wave.clone().expect("the wave goes on"); + let phase = if held { + WavePhase::Hold + } else { + WavePhase::Assault + }; + assert_eq!(w.phase, phase, "{why}"); + let mut want = out.clone(); + want.extend(home[..joins].iter().copied()); + want.sort(); + assert_eq!(w.units, want, "{why}"); + assert_eq!(usize::from(w.launched_units), out.len() + joins, "{why}"); + let view = a.sim.view(); + let b = view.building(target).unwrap(); + let centre = kind_centre(&r, b.kind, b.tile); + let cmds = ctx.into_commands(); + let sent = cmds.iter().any(|c| { + matches!(c, Command::AttackMove { units, target } + if *target == centre && home.iter().any(|h| units.contains(h))) + }); + assert_eq!(sent, joins > 0, "{why}: {cmds:?}"); + } + } + + /// M6 owner decision 12: a siege wave whose target was seen losing hit + /// points within `commit_ds` is committed: it neither ends on + /// `wave_max_ds` nor retreats. With no recent progress, or with the rule + /// off, it times out as before. + #[test] + fn a_committed_siege_neither_times_out_nor_retreats() { + let r = crate::testkit::rules(); + let k = quiet(&r, |k| { + k.wave_max_ds = 1; + k.retreat_commit_ds = 0; + }); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 62, 64, 4), + spawn(&r, P1, "skirmisher", 64, 64, 12), + ], + ); + a.run(10); + let units = a.units_of(P0, "skirmisher"); + let target = a.seat(P1); + let tick = a.sim.tick(); + let hp = a.sim.view().building_hp(target).unwrap().0; + // A 3 ds (6-tick) commit window. + let commit = pressed(|p| p.commit_ds = 3); + let window = r.ticks_from_ds(3); + // (pressure, target hp last seen, last progress) -> stays out. + for (pressure, seen, progress_at, stays) in [ + (commit, hp + 50, 0, true), + (commit, hp, tick - window + 1, true), + (commit, hp, 0, false), + (commit, hp, tick - window, false), + (rules::Pressure::OFF, hp + 50, tick, false), + ] { + let why = format!( + "seen {seen} progress {progress_at} commit {}", + pressure.commit_ds + ); + let mut w = wave(units.clone(), None, target, Tile::new(72, 64)); + w.phase = WavePhase::Assault; + w.launched_units = 8; + w.target_hp = seen; + w.progress_at = progress_at; + let mut mem = BotMemory { + wave: Some(w), + ..BotMemory::default() + }; + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = pressure; + // The retreat rule sees the memory before this plan's progress. + if progress_at > 0 && stays { + assert!( + crate::standard::retreat::assess(&mut ctx, &mem).is_none(), + "{why}: committed, no retreat" + ); + } + step(&mut ctx, &mut mem); + assert_eq!(mem.wave.is_some(), stays, "{why}"); + } + // The same losing wave with no progress is one the rule would call + // home: outnumbered three to one and down half. + let mut w = wave(units.clone(), None, target, Tile::new(72, 64)); + w.phase = WavePhase::Assault; + w.launched_units = 8; + let mem = BotMemory { + wave: Some(w), + ..BotMemory::default() + }; + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = commit; + let assessed = crate::standard::retreat::assess(&mut ctx, &mem).expect("assessed"); + assert!(crate::standard::retreat::losing(&ctx, &assessed)); + } + + /// M6 owner decision 12: for `lure_ds` after its assault order a siege + /// wave holds at its Wain point W, out of every shooter's reach, while + /// enemy armed units stand within `siege_focus_tiles` of the target; with + /// none there, or once `lure_ds` has passed, it goes for the Town. + #[test] + fn a_lure_holds_at_the_wain_point_while_defenders_stand_near() { + let r = crate::testkit::rules(); + let k = quiet(&r, |_| {}); + for (defenders, since_lure, to_w) in + [(3u16, false, true), (0, false, false), (3, true, false)] + { + let why = format!("{defenders} defenders, lure over {since_lure}"); + let mut setup = vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 76, 64, 6), + ]; + if defenders > 0 { + setup.push(spawn(&r, P1, "skirmisher", 100, 60, defenders)); + } + let mut a = Arena::frozen(standard_vs_passive(&r), setup); + a.run(r.ticks_from_ds(600) + 1); + let units = a.units_of(P0, "skirmisher"); + let target = a.seat(P1); + let tick = a.sim.tick(); + let mut w = wave(units.clone(), None, target, Tile::new(72, 64)); + w.phase = WavePhase::Assault; + w.phase_at = if since_lure { 0 } else { tick }; + let mut mem = BotMemory { + wave: Some(w.clone()), + ..BotMemory::default() + }; + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = pressed(|p| p.lure_ds = 600); + let point = wain_point(&ctx, target, w.stage); + step(&mut ctx, &mut mem); + let view = a.sim.view(); + let b = view.building(target).unwrap(); + let centre = kind_centre(&r, b.kind, b.tile); + let want = if to_w { point } else { centre }; + let cmds = ctx.into_commands(); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::AttackMove { units: u, target: t } + if *t == want && u.contains(&units[0]))), + "{why}: {cmds:?}" + ); + assert_ne!(point, centre); + } + } + + /// M6 owner decision 12: the lure already holds the assault's first + /// plan. A staged wave whose target has defenders within + /// `siege_focus_tiles` sends its units to the Wain point W, not to the + /// Town; with no defenders there it goes for the Town's centre. + #[test] + fn a_staged_wave_starts_its_assault_at_the_wain_point_while_defenders_stand_near() { + let r = crate::testkit::rules(); + let k = quiet(&r, |_| {}); + for (defenders, to_w) in [(3u16, true), (0, false)] { + let why = format!("{defenders} defenders"); + let mut setup = vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 72, 64, 6), + ]; + if defenders > 0 { + setup.push(spawn(&r, P1, "skirmisher", 100, 60, defenders)); + } + let mut a = Arena::frozen(standard_vs_passive(&r), setup); + a.run(10); + let units = a.units_of(P0, "skirmisher"); + let target = a.seat(P1); + let tick = a.sim.tick(); + let mut w = wave(units.clone(), None, target, Tile::new(72, 64)); + w.phase_at = tick; + w.launched_at = tick; + let mut mem = BotMemory { + wave: Some(w.clone()), + ..BotMemory::default() + }; + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = pressed(|p| p.lure_ds = 600); + let point = wain_point(&ctx, target, w.stage); + step(&mut ctx, &mut mem); + assert_eq!( + mem.wave.as_ref().map(|w| w.phase), + Some(WavePhase::Assault), + "{why}" + ); + let view = a.sim.view(); + let b = view.building(target).unwrap(); + let centre = kind_centre(&r, b.kind, b.tile); + assert_ne!(point, centre); + let want = if to_w { point } else { centre }; + let cmds = ctx.into_commands(); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::AttackMove { units: u, target: t } + if *t == want && u.contains(&units[0]))), + "{why}: {cmds:?}" + ); + } + } + + /// M6 owner decision 12: with `cohesion_tiles`, the assaulting units more + /// than that many tiles nearer the goal than the wave's rear quarter + /// stop (a stopped unit guards and fights where it stands) and get no + /// order toward the goal; the rest attack-move on. + #[test] + fn cohesion_stops_the_units_ahead_of_the_rear_quarter() { + let r = crate::testkit::rules(); + let k = quiet(&r, |_| {}); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 60, 64, 6), + spawn(&r, P0, "bowman", 78, 64, 2), + ], + ); + let rear = a.units_of(P0, "skirmisher"); + let ahead = a.units_of(P0, "bowman"); + // The two ahead are walking somewhere (only a unit with an order + // needs a Stop). + a.issue( + P0, + vec![Command::Move { + units: ahead.clone(), + target: a.sim.view().map().center_of(Tile::new(80, 70)), + queue: false, + }], + ); + let target = a.seat(P1); + let mut units = rear.clone(); + units.extend(ahead.iter().copied()); + units.sort(); + for (cohesion, stops) in [(4u32, true), (0, false), (40, false)] { + let mut w = wave(units.clone(), None, target, Tile::new(56, 64)); + w.phase = WavePhase::Assault; + w.phase_at = a.sim.tick(); + let mut mem = BotMemory { + wave: Some(w), + ..BotMemory::default() + }; + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = pressed(|p| p.cohesion_tiles = cohesion); + step(&mut ctx, &mut mem); + let cmds = ctx.into_commands(); + let stopped = cmds + .iter() + .any(|c| matches!(c, Command::Stop { units } if *units == ahead)); + assert_eq!(stopped, stops, "cohesion {cohesion}: {cmds:?}"); + let ahead_sent = cmds.iter().any(|c| { + matches!(c, Command::AttackMove { units, .. } if ahead.iter().any(|u| units.contains(u))) + }); + assert_eq!(ahead_sent, !stops, "cohesion {cohesion}: {cmds:?}"); + assert!( + cmds.iter().any( + |c| matches!(c, Command::AttackMove { units, .. } if units.contains(&rear[0])) + ), + "cohesion {cohesion}: the rear goes on" + ); + } + } + + /// M6 owner decision 12: cohesion counts only the units past the stage + /// point (reinforcements on their way from the rally hold nobody up) + /// and never stops a unit within an enemy shooter's reach. + #[test] + fn cohesion_ignores_units_behind_the_stage_and_never_stops_one_under_fire() { + let r = crate::testkit::rules(); + let k = quiet(&r, |_| {}); + let probe = Arena::frozen(standard_vs_passive(&r), vec![]); + let seat = probe.seat(P1); + let rect = footprint(&r, probe.sim.view().building(seat).unwrap()); + // A tile 3 tiles west of the Seat's footprint, well inside its reach. + let fire = (rect.0 - 3, (rect.1 + rect.3) / 2); + for (ahead_at, stops) in [((86, 64), true), (fire, false)] { + let why = format!("ahead at {ahead_at:?}"); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 76, 64, 6), + spawn(&r, P0, "bowman", ahead_at.0, ahead_at.1, 2), + spawn(&r, P0, "outrider", 40, 64, 6), + ], + ); + let rear = a.units_of(P0, "skirmisher"); + let ahead = a.units_of(P0, "bowman"); + let joiners = a.units_of(P0, "outrider"); + // Everyone is walking somewhere (only a unit with an order needs + // a Stop). + let walk: Vec = rear + .iter() + .chain(ahead.iter()) + .chain(joiners.iter()) + .copied() + .collect(); + a.issue( + P0, + vec![Command::Move { + units: walk, + target: a.sim.view().map().center_of(Tile::new(90, 70)), + queue: false, + }], + ); + let mut units: Vec = rear + .iter() + .chain(ahead.iter()) + .chain(joiners.iter()) + .copied() + .collect(); + units.sort(); + let mut w = wave(units, None, seat, Tile::new(72, 64)); + w.phase = WavePhase::Assault; + w.phase_at = a.sim.tick(); + let mut mem = BotMemory { + wave: Some(w), + ..BotMemory::default() + }; + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = pressed(|p| p.cohesion_tiles = 4); + step(&mut ctx, &mut mem); + let cmds = ctx.into_commands(); + let stopped = |u: UnitId| { + cmds.iter() + .any(|c| matches!(c, Command::Stop { units } if units.contains(&u))) + }; + assert_eq!(stopped(ahead[0]), stops, "{why}: {cmds:?}"); + // Without the stage filter the joiners 36 tiles back would be the + // rear quarter and stop the skirmishers too. + assert!(!stopped(rear[0]), "{why}: the rear goes on: {cmds:?}"); + assert!(!stopped(joiners[0]), "{why}: {cmds:?}"); + } + } + + /// M6 owner decision 12: with `launch_ratio_permille` a siege wave + /// launches only when the Lanchester strength of the units it would take + /// reaches that share of the enemy armed units on their own land; an + /// enemy army away from home does not count. + #[test] + fn a_launch_waits_until_the_wave_outweighs_the_army_at_home() { + let r = crate::testkit::rules(); + let k = quiet(&r, |_| {}); + let all_in = quiet(&r, |k| k.all_in_after_ds = 0); + for (enemy, at, ratio, during_all_in, launches) in [ + (20u16, (100, 58), 1000u32, false, false), + (2, (100, 58), 1000, false, true), + (20, (100, 58), 0, false, true), + (20, (56, 40), 1000, false, true), + // The all-in launches whatever the army at home. + (20, (100, 58), 1000, true, true), + ] { + let why = format!("{enemy} enemies at {at:?}, ratio {ratio}, all-in {during_all_in}"); + let mut setup = at_rally(&r, true, 7, 1, 1); + setup.push(spawn(&r, P1, "skirmisher", at.0, at.1, enemy)); + let a = Arena::frozen(standard_vs_passive(&r), setup); + let knobs = if during_all_in { &all_in } else { &k }; + let mut ctx = Ctx::new(a.sim.view(), P0, knobs, a.map(P0)); + ctx.pressure = pressed(|p| p.launch_ratio_permille = ratio); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + assert_eq!(mem.wave.is_some(), launches, "{why}"); + } + } + + /// M6 owner decision 12: with `assault_ratio_permille` a staged siege + /// wave starts its assault only when it outweighs the enemy army at + /// home; short, it holds at its stage point (a rally group joins it + /// there) and comes home once `stage_max_ds` has passed. + #[test] + fn a_gated_wave_assaults_only_when_it_outweighs_the_army_at_home() { + let r = crate::testkit::rules(); + let k = quiet(&r, |_| {}); + let (x, y) = RALLY; + let all_in = quiet(&r, |k| k.all_in_after_ds = 0); + for (enemy, ratio, stage_over, during_all_in, want) in [ + (20u16, 1000u32, false, false, "stage"), + (20, 1000, true, false, "home"), + (2, 1000, false, false, "assault"), + (20, 0, false, false, "assault"), + // The all-in assaults whatever the army at home. + (20, 1000, false, true, "assault"), + ] { + let why = format!( + "{enemy} enemies, ratio {ratio}, stage over {stage_over}, all-in {during_all_in}" + ); + let mut a = Arena::frozen( + standard_vs_passive(&r), + vec![ + Command::DebugAdvanceAge { owner: P0 }, + spawn(&r, P0, "skirmisher", 72, 64, 7), + spawn(&r, P0, "skirmisher", x, y, 7), + spawn(&r, P1, "skirmisher", 100, 58, enemy), + ], + ); + // Past one `stage_max_ds`, so a stage begun at tick 0 is over. + a.run(r.ticks_from_ds(k.stage_max_ds) + 1); + let all = a.units_of(P0, "skirmisher"); + let staged: Vec = all + .iter() + .copied() + .filter(|u| { + a.sim + .view() + .unit(*u) + .is_some_and(|v| v.pos.x > sim::Fx::from_int(60)) + }) + .collect(); + let joiners: Vec = all + .iter() + .copied() + .filter(|u| !staged.contains(u)) + .collect(); + assert_eq!((staged.len(), joiners.len()), (7, 7)); + let mut w = wave(staged.clone(), None, a.seat(P1), Tile::new(72, 64)); + w.phase = WavePhase::Stage; + let tick = a.sim.tick(); + w.phase_at = if stage_over { 0 } else { tick }; + w.launched_at = tick; + let mut mem = BotMemory { + wave: Some(w), + ..BotMemory::default() + }; + let knobs = if during_all_in { &all_in } else { &k }; + let mut ctx = Ctx::new(a.sim.view(), P0, knobs, a.map(P0)); + ctx.pressure = pressed(|p| { + p.assault_ratio_permille = ratio; + // The all-in case keeps its rally group home (no group of 64 + // there), so the staged seven alone decide. + p.reinforce_min_units = if during_all_in { 64 } else { 6 }; + }); + step(&mut ctx, &mut mem); + let cmds = ctx.into_commands(); + let got = match &mem.wave { + None => "home", + Some(w) if w.phase == WavePhase::Assault => "assault", + Some(_) => "stage", + }; + assert_eq!(got, want, "{why}: {cmds:?}"); + if want == "stage" { + // The rally group joined the held wave and walks to its stage + // point. + let w = mem.wave.as_ref().unwrap(); + // Six of the seven join; one stays at the rally. + let joined = joiners.iter().filter(|u| w.units.contains(u)).count(); + assert_eq!(joined, 6, "{why}"); + let s = a.sim.view().map().center_of(Tile::new(72, 64)); + assert!( + cmds.iter() + .any(|c| matches!(c, Command::AttackMove { units, target } + if *target == s && units.contains(&joiners[0]))), + "{why}: {cmds:?}" + ); + } + } + } + + /// M6 owner decision 12: `home_guard_units` stay home at a launch (the + /// highest ids, siege kept first in the roster), and with + /// `reinforce_min_units` the all-in launches and joins in groups from + /// the rally instead of one unit at a time from anywhere. + #[test] + fn the_home_guard_stays_and_the_all_in_goes_in_groups() { + let r = crate::testkit::rules(); + let (x, y) = RALLY; + let k = quiet(&r, |_| {}); + let a = Arena::frozen(standard_vs_passive(&r), at_rally(&r, true, 10, 1, 1)); + let mut ctx = Ctx::new(a.sim.view(), P0, &k, a.map(P0)); + ctx.pressure = pressed(|p| p.home_guard_units = 2); + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + let w = mem.wave.expect("a wave launches"); + let skirm = a.units_of(P0, "skirmisher"); + assert_eq!(w.units.len(), 9, "11 armed, 2 stay"); + assert!(!w.units.contains(&skirm[9]) && !w.units.contains(&skirm[8])); + assert!(w.units.contains(&a.units_of(P0, "mangonel")[0])); + + let all_in = quiet(&r, |k| k.all_in_after_ds = 0); + let group = pressed(|p| p.reinforce_min_units = 3); + for (near, far, launches) in [(0u16, 1u16, false), (2, 4, false), (3, 0, true)] { + let why = format!("{near} at the rally, {far} away"); + let mut setup = vec![Command::DebugAdvanceAge { owner: P0 }]; + if near > 0 { + setup.push(spawn(&r, P0, "skirmisher", x, y, near)); + } + if far > 0 { + setup.push(spawn(&r, P0, "bowman", x - 14, y, far)); + } + let a = Arena::frozen(standard_vs_passive(&r), setup); + let mut ctx = Ctx::new(a.sim.view(), P0, &all_in, a.map(P0)); + ctx.pressure = group; + let mut mem = BotMemory::default(); + step(&mut ctx, &mut mem); + assert_eq!(mem.wave.is_some(), launches, "{why}"); + if launches { + assert_eq!( + mem.wave.unwrap().units, + a.units_of(P0, "skirmisher"), + "{why}" + ); + } + } + } } diff --git a/crates/ai/tests/common/mod.rs b/crates/ai/tests/common/mod.rs index f70ccea..a644376 100644 --- a/crates/ai/tests/common/mod.rs +++ b/crates/ai/tests/common/mod.rs @@ -96,13 +96,12 @@ impl Arena { .iter() .map(|slot| { let seat = setup.ai_seats.iter().find(|s| s.player == slot.id)?; - (seat.bot == ai::BOT_STANDARD).then(|| { - let knobs = rules - .ai - .personality(&seat.personality) - .expect("personality"); - StandardBot::new(&rules, &setup, slot.id, knobs) - }) + let difficulty = rules.ai.difficulty(&seat.bot)?; + let knobs = rules + .ai + .personality(&seat.personality) + .expect("personality"); + Some(StandardBot::new(&rules, &setup, slot.id, knobs, difficulty)) }) .collect(); let memories = vec![AiMemory::default(); setup.players.len()]; @@ -168,9 +167,29 @@ impl Arena { /// A live plan context for `me` at the current tick. pub fn ctx(&self, me: PlayerId) -> Ctx<'_> { + self.bot(me).ctx(self.sim.view(), me) + } + + /// [`Arena::ctx`] with the seat's personality as written (not resolved + /// for its level) and no level pressure: the bot the slice-2 tests + /// describe, which a Standard seat played until the M6 retune (owner + /// decision 12) gave the level its pressure and all-in time. + pub fn base_ctx(&self, me: PlayerId) -> Ctx<'_> { + let mut ctx = self.ctx(me); let view = self.sim.view(); - let knobs = self.bot(me).knobs(view.rules()); - Ctx::new(view, me, knobs, self.bot(me).map()) + let seat = view + .setup() + .ai_seats + .iter() + .find(|s| s.player == me) + .expect("the seat is a bot"); + ctx.knobs = view + .rules() + .ai + .personality(&seat.personality) + .expect("personality"); + ctx.pressure = rules::Pressure::OFF; + ctx } /// What `me`'s Standard bot would plan now, whatever its cadence, and diff --git a/crates/ai/tests/m5b.rs b/crates/ai/tests/m5b.rs index ef8d4af..54a7ca9 100644 --- a/crates/ai/tests/m5b.rs +++ b/crates/ai/tests/m5b.rs @@ -35,10 +35,12 @@ impl AiController for Ticks { } } -/// The Standard bot plans every 10 ticks, P0 on ticks 0, 10, ... and P1 -/// on ticks 5, 15, ...: commands come only from plan ticks, both seats -/// act from their first plan (a Yeoman, builders, gatherers), and a plan -/// of the arena starts from the documented context. +/// The Standard bot plans every 10 ticks, the two seats 5 ticks apart +/// (M6: at seed 1, an odd seed, P1 on ticks 0, 10, ... and P0 on ticks 5, +/// 15, ...; the order alternates with the seed's parity): commands come +/// only from plan ticks, both seats act from their first plan (a Yeoman, +/// builders, gatherers), and a plan of the arena starts from the +/// documented context. #[test] fn standard_keeps_its_cadence() { let r = common::rules(); @@ -50,7 +52,7 @@ fn standard_keeps_its_cadence() { phase(PlayerId(0), every, &setup), phase(PlayerId(1), every, &setup) ), - (0, 5) + (5, 0) ); let calls = Arc::new(Mutex::new(Vec::new())); let inner = ai::controller_for(&setup, &r).unwrap(); @@ -221,16 +223,30 @@ fn no_unit_gets_the_same_order_twice_in_three_plans() { /// header, at tick 1500) issues exactly the commands the running one /// issues, and leaves the same memory, on every tick of both seats for /// the next 3000 ticks: the bot holds nothing but its hashed memory and -/// tables computed from immutable inputs. +/// tables computed from immutable inputs. M6: also for a Hard seat against +/// an Easy one, whose knobs `rules::ai::resolve` derives from the header +/// and whose disclosed bonus is paid from tick 4000 (Hard) and 4200 +/// (Easy), inside the compared span. #[test] fn controller_rebuilt_mid_match_issues_the_same_commands() { let r = common::rules(); - let setup = MatchSetup::bots(&r, 2, ("standard", "standard"), ("standard", "standard")); - let (mut m, log) = logged(&setup, &r, None); + for seats in [ + (("standard", "standard"), ("standard", "standard")), + (("hard", "standard"), ("easy", "standard")), + ] { + let setup = MatchSetup::bots(&r, 2, seats.0, seats.1); + rebuilt_bot_issues_the_same_commands(&setup, &r); + } +} + +/// [`controller_rebuilt_mid_match_issues_the_same_commands`] for one +/// header. +fn rebuilt_bot_issues_the_same_commands(setup: &MatchSetup, r: &Rules) { + let (mut m, log) = logged(setup, r, None); while m.sim.tick() < 1500 { m.step(); } - let mut fresh = ai::controller_for(&setup, &r).unwrap(); + let mut fresh = ai::controller_for(setup, r).unwrap(); let mut compared = 0; while m.sim.tick() < 4500 { let at = m.sim.tick(); @@ -255,7 +271,11 @@ fn controller_rebuilt_mid_match_issues_the_same_commands() { compared += usize::from(!cmds.is_empty()); } } - assert!(compared >= 20, "{compared} non-empty plans compared"); + assert!( + compared >= 20, + "{:?}: {compared} non-empty plans compared", + setup.ai_seats + ); } /// `ai::describe` names the window, the latches and the wave. @@ -670,11 +690,20 @@ fn m5b_defend_answers_a_raid_at_minute_5() { /// (from tick 6412 to 7971 in this game, the Muster Hall idle all along). /// While the bot defends, that saving no longer holds the stock back from /// production, so P0 queues an armed unit within 200 ticks of the raid. +/// M6: the game runs on a copy of the rules whose `standard` level pays no +/// bonus (`income_interval_ds` 0), so it keeps the M5b timeline above; the +/// Standard level's disclosed bonus from tick 4100 would fill the stock +/// and keep the Muster Hall busy before the raid. #[test] #[ignore = "long: a 6900-tick game; CI runs --ignored m5b_"] fn m5b_defend_outranks_saving_for_an_age() { - let r = common::rules(); + let mut r = common::rules(); + r.ai.difficulties + .get_mut("standard") + .expect("the standard level") + .income_interval_ds = 0; let setup = common::standard_vs_passive(&r); + assert!(sim::bonus_table(&setup, &r).is_empty(), "no bonus is paid"); let raid_at = 6700; let raid = common::spawn(&r, P1, "skirmisher", 24, 51, 3); let (mut m, log) = logged(&setup, &r, Some((raid_at, P1, raid))); @@ -725,47 +754,53 @@ fn m5b_defend_outranks_saving_for_an_age() { /// Hidden state: restoring a Standard-vs-Standard match at tick 3003 or /// 3007 (one in each seat's planning phase) into a fresh `Sim` with /// fresh bots from the header matches the uninterrupted run, every -/// sub-hash, every tick, for the next 3000 ticks. +/// sub-hash, every tick, for the next 3000 ticks. M6: also a Hard seat +/// against an Easy one restored at 3003, so the disclosed bonus schedule +/// (rebuilt from the header) pays on the same ticks after the restore. #[test] #[ignore = "long: 6000-tick games; CI runs --ignored m5b_"] fn m5b_restore_at_3003_and_3007_matches_uninterrupted() { let r = common::rules(); let setup = MatchSetup::bots(&r, 5, ("standard", "standard"), ("standard", "standard")); - let games: Vec u32 + Send>> = [3003u32, 3007] - .into_iter() - .map(|at| { - let (setup, r) = (setup.clone(), r.clone()); - Box::new(move || { - let fresh = || { - Sim::new( - setup.clone(), - r.clone(), - ai::controller_for(&setup, &r).unwrap(), - ) - }; - let mut a = fresh(); - while a.tick() < at { - a.step(&[]); - } - let mut b = fresh(); - b.restore(&a.snapshot()).unwrap(); - for _ in 0..3000 { - a.step(&[]); - b.step(&[]); - let (ha, hb) = (a.sub_hashes(), b.sub_hashes()); - assert_eq!( - ha.first_difference(&hb), - None, - "restored at {at}, tick {}", - a.tick() + let levels = MatchSetup::bots(&r, 5, ("hard", "standard"), ("easy", "standard")); + let games: Vec u32 + Send>> = + [(&setup, 3003u32), (&setup, 3007), (&levels, 3003)] + .into_iter() + .map(|(setup, at)| { + let (setup, r) = (setup.clone(), r.clone()); + Box::new(move || { + let fresh = || { + Sim::new( + setup.clone(), + r.clone(), + ai::controller_for(&setup, &r).unwrap(), + ) + }; + let mut a = fresh(); + while a.tick() < at { + a.step(&[]); + } + let mut b = fresh(); + b.restore(&a.snapshot()).unwrap(); + for _ in 0..3000 { + a.step(&[]); + b.step(&[]); + let (ha, hb) = (a.sub_hashes(), b.sub_hashes()); + assert_eq!( + ha.first_difference(&hb), + None, + "restored at {at}, tick {}", + a.tick() + ); + } + assert!( + !a.view().ai_memory(P0).is_empty() && !a.view().ai_memory(P1).is_empty() ); - } - assert!(!a.view().ai_memory(P0).is_empty() && !a.view().ai_memory(P1).is_empty()); - a.tick() - }) as Box<_> - }) - .collect(); - assert_eq!(par(games), [6003, 6007]); + a.tick() + }) as Box<_> + }) + .collect(); + assert_eq!(par(games), [6003, 6007, 6003]); } // --------------------------------------------------------------------------- @@ -969,10 +1004,11 @@ fn m5b_standard_respects_work_caps() { let r = common::rules(); let setup = MatchSetup::bots(&r, 1, ("standard", "standard"), ("standard", "standard")); let k = r.ai.personality("standard").unwrap().clone(); + let level = r.ai.difficulty("standard").unwrap().clone(); let plans = Arc::new(Mutex::new(Vec::new())); let bots = [P0, P1] .into_iter() - .map(|p| (p, ai::standard::StandardBot::new(&r, &setup, p, &k))) + .map(|p| (p, ai::standard::StandardBot::new(&r, &setup, p, &k, &level))) .collect(); let mut m = BotMatch::new( setup, @@ -1197,7 +1233,16 @@ fn bot_takes_seat() { } } let launched_at = launched_at.expect("a launch"); - assert!(launched_at <= 20, "launched at {launched_at}"); + // M6: the first wave waits up to `noise_first_wave_ds` more, by the + // bot's noise (`waves::first_wave_extra`), then launches at once. + let k = r.ai.personality("standard").unwrap(); + let extra_ds = sim::ai_noise(1, P1, ai::standard::ctx::salt::FIRST_WAVE) + % (u64::from(k.noise_first_wave_ds) + 1); + let extra = r.ticks_from_ds(u32::try_from(extra_ds).unwrap()); + assert!( + launched_at <= 20 + extra, + "launched at {launched_at} (first-wave wait {extra})" + ); let result = a.sim.result().expect("decided before 4000"); assert_eq!( (result.outcome, result.reason), diff --git a/crates/ai/tests/m6.rs b/crates/ai/tests/m6.rs new file mode 100644 index 0000000..43915c5 --- /dev/null +++ b/crates/ai/tests/m6.rs @@ -0,0 +1,227 @@ +//! M6 bot tests that run whole matches (`cargo test -p ai`): every one +//! plays several games or games longer than 5000 ticks, so each is +//! `#[ignore]`d with an `m6_` prefix; CI's `fun-gate` job runs +//! `cargo test -p ai --locked -- --ignored m6_`. + +mod common; + +use ai::harness::{BotMatch, Side}; +use ai::standard::{BotMemory, WaveKind}; +use common::{P0, P1}; +use sim::{MatchSetup, PlayerId, Sim}; + +/// Run `games` on scoped threads, results in input order. +fn par(games: Vec T + Send>>) -> Vec { + std::thread::scope(|s| { + let handles: Vec<_> = games.into_iter().map(|g| s.spawn(g)).collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }) +} + +/// The four shipped personalities. +const PERSONALITIES: [&str; 4] = ["standard", "rush", "boom", "tower"]; + +/// A `bot`/`personality` seat against Passive, the bot in `seat`. +fn vs_passive(seed: u64, seat: PlayerId, bot: &str, personality: &str) -> MatchSetup { + let r = common::rules(); + if seat == P0 { + MatchSetup::bots(&r, seed, (bot, personality), ("passive", "")) + } else { + MatchSetup::bots(&r, seed, ("passive", ""), (bot, personality)) + } +} + +/// The bot side's facts after running `setup` to `ticks` or the outcome, +/// as the play-bots row reports them (the row adds the `MatchStats` +/// columns, `first_on_land_tick` among them, to the observed sides). +fn bot_side(setup: MatchSetup, seat: PlayerId, ticks: u32) -> (Side, Option) { + let r = common::rules(); + let seed = setup.seed; + let mut m = BotMatch::with_bots(setup, r).unwrap(); + m.run(ticks); + let row = m.row(seed); + let sides = row.sides.expect("no panic"); + (sides[usize::from(seat.0)], m.sim.result()) +} + +/// ROADMAP M6 acceptance 4, first clause, for every personality (Random +/// may pick any of rush, boom and tower) and from both seats: a Hard bot +/// against Passive orders an attack into the enemy's land before tick +/// 14400 (minute 12) and has an armed unit standing on it before then, +/// in seeds 1 to 3. +#[test] +#[ignore = "long: 24 games to 14400 ticks; CI runs --ignored m6_"] +fn m6_hard_attacks_before_14400_vs_passive_every_personality_both_sides() { + let mut games: Vec (String, Side) + Send>> = Vec::new(); + for p in PERSONALITIES { + for seat in [P0, P1] { + for seed in 1..=3u64 { + games.push(Box::new(move || { + let (side, _) = bot_side(vs_passive(seed, seat, "hard", p), seat, 14_400); + (format!("{p} seat {} seed {seed}", seat.0), side) + })); + } + } + } + for (what, side) in par(games) { + let attack = side.first_attack_tick.unwrap_or(u32::MAX); + let on_land = side.first_on_land_tick.unwrap_or(u32::MAX); + assert!(attack < 14_400, "{what}: first_attack_tick {attack}"); + assert!(on_land < 14_400, "{what}: first_on_land_tick {on_land}"); + } +} + +/// The rush personality (at Standard) is the early one: its first attack +/// into Passive's land comes before tick 8400 (minute 7), from either +/// seat, in seeds 1 to 3. +#[test] +#[ignore = "long: six games to 8400 ticks; CI runs --ignored m6_"] +fn m6_rush_first_attack_before_8400_vs_passive() { + let mut games: Vec (String, Side) + Send>> = Vec::new(); + for seat in [P0, P1] { + for seed in 1..=3u64 { + games.push(Box::new(move || { + let (side, _) = bot_side(vs_passive(seed, seat, "standard", "rush"), seat, 8_400); + (format!("seat {} seed {seed}", seat.0), side) + })); + } + } + for (what, side) in par(games) { + let attack = side.first_attack_tick.unwrap_or(u32::MAX); + assert!(attack < 8_400, "{what}: first_attack_tick {attack}"); + } +} + +/// Every personality at every difficulty takes Passive's Seat before tick +/// 36000 from either seat (seed 1): a decisive `SeatCaptured` victory. +#[test] +#[ignore = "long: 24 games to about 15000-36000 ticks; CI runs --ignored m6_"] +fn m6_every_personality_and_difficulty_beats_passive_by_seat_before_36000() { + let mut games: Vec (String, PlayerId, Option) + Send>> = + Vec::new(); + for bot in ["easy", "standard", "hard"] { + for p in PERSONALITIES { + for seat in [P0, P1] { + games.push(Box::new(move || { + let (_, result) = bot_side(vs_passive(1, seat, bot, p), seat, 36_000); + (format!("{bot}/{p} seat {}", seat.0), seat, result) + })); + } + } + } + for (what, seat, result) in par(games) { + let result = result.unwrap_or_else(|| panic!("{what}: undecided at 36000")); + assert_eq!( + (result.outcome, result.reason), + ( + sim::Outcome::Victory { + player: seat, + decisive: true + }, + sim::OutcomeReason::SeatCaptured + ), + "{what}" + ); + } +} + +/// The bot's hashed memory stays under 1 KiB on every tick of two +/// personality matches that use every M6 field (raids, retreats, the +/// deadline raid, reactive towers) to the outcome or tick 30000. +#[test] +#[ignore = "long: two games to 30000 ticks; CI runs --ignored m6_"] +fn m6_memory_stays_under_1_kib() { + let games: Vec (usize, u32) + Send>> = [ + (("hard", "rush"), ("standard", "tower")), + (("standard", "boom"), ("easy", "rush")), + ] + .into_iter() + .map(|(a, b)| { + Box::new(move || { + let r = common::rules(); + let mut m = BotMatch::with_bots(MatchSetup::bots(&r, 3, a, b), r).unwrap(); + let mut max = 0usize; + while m.sim.result().is_none() && m.sim.tick() < 30_000 { + m.step(); + let view = m.sim.view(); + for p in [P0, P1] { + max = max.max(view.ai_memory(p).len()); + } + } + (max, m.sim.tick()) + }) as Box<_> + }) + .collect(); + for (max, tick) in par(games) { + assert!(max < 1024, "{max} bytes by tick {tick}"); + assert!(max > 0); + } +} + +/// Hidden state in the M6 behaviours: a rush-against-tower match (seed 2, +/// where the Standard rush seat raids and the Easy tower seat retreats; +/// a Standard seat retreats as its personality does) restored into a fresh +/// `Sim` with fresh bots from the header, once while a raid is out and +/// once just after a retreat, matches the uninterrupted run, every +/// sub-hash, every tick, for the next 2000 ticks. +#[test] +#[ignore = "long: games to about 20000 ticks; CI runs --ignored m6_"] +fn m6_restore_during_a_raid_idle_and_after_a_retreat_matches_uninterrupted() { + type Pick = fn(&BotMemory, &BotMemory) -> bool; + let raid_out: Pick = |_, now| now.wave.as_ref().is_some_and(|w| w.kind == WaveKind::Raid); + let after_retreat: Pick = |before, now| now.retreats > before.retreats; + let cases: Vec<(&str, Pick)> = vec![("a raid out", raid_out), ("a retreat", after_retreat)]; + let games: Vec (String, u32) + Send>> = cases + .into_iter() + .map(|(what, pick)| { + Box::new(move || { + let r = common::rules(); + let setup = MatchSetup::bots(&r, 2, ("standard", "rush"), ("easy", "tower")); + let fresh = || { + Sim::new( + setup.clone(), + r.clone(), + ai::controller_for(&setup, &r).unwrap(), + ) + }; + let mut a = fresh(); + let mut before = [BotMemory::default(), BotMemory::default()]; + let mut found = None; + while a.result().is_none() && a.tick() < 40_000 { + a.step(&[]); + let view = a.view(); + for p in [P0, P1] { + let now = BotMemory::decode(view.ai_memory(p)); + if pick(&before[usize::from(p.0)], &now) { + found = Some(a.tick()); + } + before[usize::from(p.0)] = now; + } + if found.is_some() { + break; + } + } + let at = found.unwrap_or_else(|| panic!("no {what} by {}", a.tick())); + let mut b = fresh(); + b.restore(&a.snapshot()).unwrap(); + for _ in 0..2000 { + if a.result().is_some() { + break; + } + a.step(&[]); + b.step(&[]); + assert_eq!( + a.sub_hashes().first_difference(&b.sub_hashes()), + None, + "{what}: restored at {at}, tick {}", + a.tick() + ); + } + (what.to_string(), at) + }) as Box<_> + }) + .collect(); + for (what, at) in par(games) { + println!("m6 restore: {what} at {at}"); + } +} diff --git a/crates/game/src/app.rs b/crates/game/src/app.rs index 68a4e81..8d36004 100644 --- a/crates/game/src/app.rs +++ b/crates/game/src/app.rs @@ -112,6 +112,31 @@ pub fn spectate_setup(rules: &Rules, seed: u64) -> MatchSetup { ) } +/// The setup of a plain launch or `--skip-menu` (M6): the local player +/// against `--difficulty` (default `standard`) with `--personality` +/// (default `standard`; `random` resolved for the bot's seat at `seed`, +/// `ai::resolve_personality`), or `passive` with no personality. +/// +/// # Errors +/// +/// A personality for `passive`, or `random` with no personality in the +/// pool. Unknown ids are reported when the bots are built. +pub fn vs_bot_setup(cli: &Cli, rules: &Rules, seed: u64) -> Result { + let bot = cli.difficulty.as_deref().unwrap_or(ai::BOT_STANDARD); + let personality = match (bot, cli.personality.as_deref()) { + (ai::BOT_PASSIVE, None) => String::new(), + (ai::BOT_PASSIVE, Some(p)) => { + return Err(format!("--personality {p}: the passive bot takes none")); + } + (_, None) => STANDARD_PERSONALITY.to_string(), + (_, Some(ai::RANDOM_PERSONALITY)) => { + ai::resolve_personality(rules, seed, sim::PlayerId(1))? + } + (_, Some(p)) => p.to_string(), + }; + Ok(MatchSetup::vs_bot(rules, seed, bot, &personality)) +} + /// The sim and the input source a windowed session starts with: a match /// against the Standard bot (a plain launch, `--skip-menu`), the Standard /// bot against itself (`--spectate`, fast-forwarded to @@ -136,16 +161,16 @@ pub fn build_match(cli: &Cli, rules: Rules) -> Result<(SimHandle, Box Ok(( - SimHandle::vs_bot( - rules, - match_seed(cli), - ai::BOT_STANDARD, - STANDARD_PERSONALITY, - ), - Box::new(LocalInput), - )), + // M5b: a plain launch plays the Standard bot (`MatchSetup::vs_bot`); + // M6: `--difficulty` and `--personality` choose it. + None => { + let seed = match_seed(cli); + let setup = vs_bot_setup(cli, &rules, seed)?; + Ok(( + SimHandle::try_from_setup(setup, rules)?, + Box::new(LocalInput), + )) + } Some(name) => { let Some((mut setup, stream, _ticks)) = scenarios::by_name(name, &rules) else { return Err(format!( @@ -1094,6 +1119,60 @@ mod tests { } } + /// M6: `--difficulty` and `--personality` choose the opponent of + /// `--skip-menu` (and of a plain launch); an unknown id, a personality + /// for `passive` and a `random` with an empty pool are errors. + #[test] + fn difficulty_and_personality_choose_the_opponent() { + let rules = + Rules::load(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")) + .unwrap(); + let (hard, _) = build_match( + &launch(&["--skip-menu", "--seed", "5", "--difficulty", "hard"]), + rules.clone(), + ) + .unwrap(); + assert_eq!( + *hard.setup(), + MatchSetup::vs_bot(&rules, 5, "hard", "standard") + ); + let (passive, _) = build_match( + &launch(&["--seed", "5", "--difficulty", "passive"]), + rules.clone(), + ) + .unwrap(); + assert_eq!(passive.setup().ai_seats[0].bot, "passive"); + assert!(passive.setup().ai_seats[0].personality.is_empty()); + let err = |args: &[&str]| { + build_match(&launch(args), rules.clone()) + .err() + .unwrap_or_else(|| panic!("{args:?} builds")) + }; + assert!(err(&["--difficulty", "nightmare"]).contains("unknown bot \"nightmare\"")); + assert!(err(&["--personality", "zerg"]).contains("no personality \"zerg\"")); + assert!(err(&["--difficulty", "passive", "--personality", "x"]).contains("takes none")); + // The shipped pool (M6 slice 2: rush, boom and tower) resolves + // `random` into the header; an empty pool is an error. + let (random, _) = build_match( + &launch(&["--seed", "5", "--personality", "random"]), + rules.clone(), + ) + .unwrap(); + let picked = random.setup().ai_seats[0].personality.clone(); + assert!( + ["rush", "boom", "tower"].contains(&picked.as_str()), + "{picked}" + ); + let mut empty = rules.clone(); + for p in &mut empty.ai.personalities { + p.random = false; + } + let e = build_match(&launch(&["--personality", "random"]), empty) + .err() + .expect("an empty pool is an error"); + assert!(e.contains("random: true"), "{e}"); + } + /// `--scenario`, `--skip-menu` and `--spectate` start in `Skirmish`. /// `--spectate --seed 1` is the `m5_first_match` fixture's setup (its /// header, the informational git sha aside) with local input, and a diff --git a/crates/game/src/cli.rs b/crates/game/src/cli.rs index 678bf32..1b4a383 100644 --- a/crates/game/src/cli.rs +++ b/crates/game/src/cli.rs @@ -17,6 +17,9 @@ //! `crate::draw_stats`), `--click-check` (every unit selectable by a //! real click, `crate::click_check`) and `--minimap-check` (the minimap's //! input with real clicks, `crate::minimap_check`). +//! M6 adds `--difficulty ` and `--personality `: the opponent of a +//! plain launch or `--skip-menu` (`app::build_match`), validated against +//! the rules at start-up. //! Parsing is complete and tested here. use std::path::{Path, PathBuf}; @@ -64,6 +67,11 @@ Options: own, and --headless-run uses 1. --skip-menu Start the match against the Standard bot at once instead of opening the main menu. + --difficulty The bot of a plain launch or --skip-menu: easy, standard + (default) or hard (data/ai/difficulty/), or passive. + The bot's disclosed bonus income follows the difficulty. + --personality That bot's personality (data/ai/personalities.ron, + default standard); random draws one per seed. --spectate Watch the Standard bot play itself (both slots AI; with --seed 1 it is the m5_first_match setup, otherwise a fresh seed). You can select units and move the camera @@ -219,6 +227,12 @@ pub struct Cli { pub fast_forward_to: Option, /// `--new-game-after-seconds ` (M5b): the menu's New Game click proxy. pub new_game_after_seconds: Option, + /// `--difficulty ` (M6): the opponent's bot id; `None` is + /// `standard`. + pub difficulty: Option, + /// `--personality ` (M6): the opponent's personality; `None` is + /// `standard`, `random` draws per seed. + pub personality: Option, /// `--assets-dir ` (art pass): the asset root, see /// [`Cli::resolve_assets_dir`]. pub assets_dir: Option, @@ -259,6 +273,8 @@ impl Default for Cli { spectate: false, fast_forward_to: None, new_game_after_seconds: None, + difficulty: None, + personality: None, assets_dir: None, window_size: None, grid: false, @@ -344,6 +360,17 @@ impl Cli { } cli.new_game_after_seconds = Some(secs); } + "--difficulty" | "--personality" => { + let id = args.next().ok_or_else(|| format!("{arg}: missing value"))?; + if id.is_empty() || id.starts_with("--") { + return Err(format!("{arg}: expected an id")); + } + if arg == "--difficulty" { + cli.difficulty = Some(id); + } else { + cli.personality = Some(id); + } + } "--data-dir" => cli.data_dir = Some(parse_path(&arg, args.next())?), "--assets-dir" => cli.assets_dir = Some(parse_path(&arg, args.next())?), "--window-size" => { @@ -444,12 +471,22 @@ impl Cli { cli.new_game_after_seconds.is_some(), "--new-game-after-seconds", ), + (cli.difficulty.is_some(), "--difficulty"), + (cli.personality.is_some(), "--personality"), ] { if given { return Err(format!("{flag} needs a window; drop --headless-run")); } } } + if (cli.difficulty.is_some() || cli.personality.is_some()) + && (cli.spectate || cli.scenario.is_some()) + { + return Err( + "--difficulty and --personality choose the opponent of a plain launch or --skip-menu; drop --spectate and --scenario" + .into(), + ); + } if cli.click_check && cli.headless_run.is_none() && cli.scenario.is_none() { return Err( "--click-check judges clicks on a scenario's units; add --scenario unit_looks" @@ -710,6 +747,8 @@ mod tests { spectate: false, fast_forward_to: None, new_game_after_seconds: None, + difficulty: None, + personality: None, assets_dir: None, window_size: None, grid: false, @@ -914,6 +953,41 @@ mod tests { } } + /// M6: `--difficulty` and `--personality` parse for a plain launch and + /// `--skip-menu`, need an id and a window, and do not go with + /// `--spectate` or `--scenario`. + #[test] + fn m6_difficulty_and_personality_flags_parse() { + assert_eq!( + parse(&[ + "--skip-menu", + "--difficulty", + "hard", + "--personality", + "rush" + ]), + Ok(Parsed::Run(Cli { + skip_menu: true, + difficulty: Some("hard".into()), + personality: Some("rush".into()), + ..Cli::default() + })) + ); + let Ok(Parsed::Run(plain)) = parse(&["--difficulty", "easy"]) else { + panic!("a plain launch takes --difficulty"); + }; + assert!(plain.starts_in_menu()); + for bad in [ + vec!["--difficulty"], + vec!["--personality", "--skip-menu"], + vec!["--spectate", "--difficulty", "hard"], + vec!["--scenario", "units200", "--personality", "rush"], + vec!["--headless-run", "10", "--difficulty", "hard"], + ] { + assert!(parse(&bad).is_err(), "{bad:?}"); + } + } + #[test] fn help_short_circuits() { assert_eq!(parse(&["--seed", "3", "--help"]), Ok(Parsed::Help)); diff --git a/crates/game/src/economy_hud.rs b/crates/game/src/economy_hud.rs index 1308582..142ca70 100644 --- a/crates/game/src/economy_hud.rs +++ b/crates/game/src/economy_hud.rs @@ -252,9 +252,10 @@ pub fn resource_text( format!("{} {stock} {rate_text}", def.name) } -/// `true` while production of this resource is discarded at the Yield Cap. +/// `true` while production of this resource is discarded at the Yield Cap: +/// `RateInfo::at_cap`, the one rule `MatchStats` shares (M6). pub fn at_cap(rate: &sim::RateInfo) -> bool { - rate.raw > rate.credited + rate.at_cap() } /// The resource bar, pop readout and message line. diff --git a/crates/game/src/sim_driver.rs b/crates/game/src/sim_driver.rs index 677fd15..d9356d5 100644 --- a/crates/game/src/sim_driver.rs +++ b/crates/game/src/sim_driver.rs @@ -92,6 +92,8 @@ pub const LOCAL_PLAYER: PlayerId = PlayerId(0); /// outside this module calls `step`; every other module reads `view()`. pub struct SimHandle { sim: Sim, + /// The match statistics (M6), fed by [`SimHandle::drain_events`]. + stats: sim::MatchStats, } impl SimHandle { @@ -122,8 +124,10 @@ impl SimHandle { pub fn try_from_setup(setup: MatchSetup, rules: Rules) -> Result { let ai: Box = ai::controller_for(&setup, &rules).map_err(|e| e.to_string())?; + let stats = sim::MatchStats::new(&setup, &rules); Ok(Self { sim: Sim::new(setup, rules, ai), + stats, }) } @@ -228,9 +232,20 @@ impl SimHandle { /// Events since the last drain (`UnitSpawned`, `UnitArrived`, /// `CommandRejected`, ...). The fixed-step driver drains them every tick - /// and forwards them as [`SimEventMsg`]. + /// and forwards them as [`SimEventMsg`]; [`SimHandle::fast_forward`] + /// drains after every step too. Every drain also feeds the match + /// statistics (M6, [`SimHandle::stats`]) with the view after the step. pub fn drain_events(&mut self) -> Vec { - self.sim.drain_events() + let events = self.sim.drain_events(); + self.stats.observe(&self.sim.view(), &events); + events + } + + /// The match statistics so far (M6): the game-over screen's numbers, + /// the same `sim::MatchStats` the play-bots harness and `sim-cli + /// match-log` derive. + pub fn stats(&self) -> &sim::MatchStats { + &self.stats } } @@ -2533,6 +2548,33 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// M6: `SimHandle::drain_events` (and so `fast_forward`) feeds the + /// match statistics: the same numbers the play-bots harness derives + /// for the same setup, the Hard bot's disclosed bonus among them. + #[test] + fn drain_events_feeds_the_match_stats_like_the_harness() { + let rules = Rules::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")).unwrap(); + let mut handle = SimHandle::vs_bot(rules.clone(), 5, "hard", "standard"); + handle.fast_forward(4500).unwrap(); + let mut m = + ai::harness::BotMatch::with_bots(handle.setup().clone(), rules.clone()).unwrap(); + m.run(4500); + assert_eq!(handle.stats(), m.observer.stats()); + let bundle: i64 = rules + .ai + .difficulty("hard") + .unwrap() + .bundle(&rules) + .iter() + .sum(); + assert_eq!(handle.stats().player(PlayerId(1)).bonus_total(), 2 * bundle); + assert_eq!( + handle.stats().player(PlayerId(0)).bonus_total(), + 0, + "the human gets nothing" + ); + } + /// `SimHandle::fast_forward` steps to its target with the events /// drained after every step, and is refused once the match is decided /// on the way, or already: the overlay needs a live deciding step. @@ -2568,6 +2610,7 @@ mod tests { rules.clone(), Box::new(Script { surrender_at: 40 }), ), + stats: sim::MatchStats::new(&setup, &rules), }; // Control: the same 30 ticks without draining leave the rejection. let mut control = scripted(); diff --git a/crates/rules/src/ai.rs b/crates/rules/src/ai.rs index 7672fb0..8426bcb 100644 --- a/crates/rules/src/ai.rs +++ b/crates/rules/src/ai.rs @@ -14,6 +14,12 @@ //! order that could stall for ever fail here instead of at run time: a //! step that cannot be reached from the steps before it must be //! `optional`, and an `Advance` never may be. +//! +//! M6 adds the difficulty levels, one file per level under +//! `ai/difficulty/.ron` ([`Difficulty`]): the disclosed bonus bundle +//! the sim pays a bot seat (the income cheat) and the wave knobs the level +//! scales, applied to a personality by [`resolve`]. A header's +//! `AiSeat.bot` names a difficulty id (or `passive`). use crate::{Error, Rules, UnitClass, load_ron}; use serde::{Deserialize, Serialize}; @@ -38,6 +44,38 @@ pub const MAX_AI_DS: u32 = 72_000; pub const MAX_THINK_EVERY_DS: u32 = 600; /// Largest `army_mix` weight and `toward_enemy_penalty`. pub const MAX_AI_WEIGHT: u32 = 1000; +/// Largest amount of one resource in a difficulty's bonus bundle. +pub const MAX_BONUS_AMOUNT: u32 = 500; +/// Shortest non-zero bonus interval (`income_interval_ds`, 5 s). +pub const MIN_INCOME_INTERVAL_DS: u32 = 50; +/// Smallest `worker_target_permille` (M6). +pub const MIN_WORKER_TARGET_PERMILLE: u32 = 500; +/// Most `extra_research` entries of a difficulty (M6 owner decision 12). +pub const MAX_EXTRA_RESEARCH: usize = 8; +/// Smallest difficulty multiplier (`wave_size_permille`, +/// `wave_interval_permille`). +pub const MIN_DIFFICULTY_PERMILLE: u32 = 250; +/// Largest difficulty multiplier. +pub const MAX_DIFFICULTY_PERMILLE: u32 = 4000; +/// Largest counter factor cap (`counter_cap_permille`) and counter weight. +pub const MAX_COUNTER_PERMILLE: u32 = 8000; +/// Largest jitter share (`noise_wave_interval_permille`, +/// `noise_mix_permille`). +pub const MAX_NOISE_PERMILLE: u32 = 500; +/// Largest `noise_site_top_k`. +pub const MAX_SITE_TOP_K: u32 = 8; +/// Largest `influence_cell_tiles`. +pub const MAX_INFLUENCE_CELL_TILES: u32 = 16; +/// Largest `influence_radius_cells`. +pub const MAX_INFLUENCE_RADIUS_CELLS: u32 = 8; +/// Largest `retreat_ratio_permille` and `raid_distance_permille`. +pub const MAX_AI_RATIO_PERMILLE: u32 = 10_000; +/// The bot id of the bot that never acts: reserved, so no difficulty file +/// may take it. +pub const PASSIVE_ID: &str = "passive"; +/// The difficulty every tool defaults to (`play-bots`, New Game); its file +/// must exist. +pub const STANDARD_DIFFICULTY: &str = "standard"; /// Everything under `data/ai/`, attached to [`crate::Rules::ai`]. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -47,6 +85,9 @@ pub struct AiRules { /// The build orders of `ai/build_orders/*.ron`, keyed by file stem /// (which equals each one's `id`). pub build_orders: BTreeMap, + /// The difficulty levels of `ai/difficulty/*.ron` (M6), keyed by file + /// stem (which equals each one's `id`). + pub difficulties: BTreeMap, } impl AiRules { @@ -59,6 +100,290 @@ impl AiRules { pub fn build_order(&self, id: &str) -> Option<&BuildOrder> { self.build_orders.get(id) } + + /// The difficulty level with the given id, if any (M6). + pub fn difficulty(&self, id: &str) -> Option<&Difficulty> { + self.difficulties.get(id) + } +} + +/// Which age gate a difficulty puts on a personality's waves (M6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum WaveAgeGate { + /// The personality's own `wave_requires_age`. + Personality, + /// No wave launches before the bot reaches this age (an age id). + Age(String), + /// No age gate at all. + Off, +} + +/// A difficulty's guaranteed early raid (M6, slice 2's behaviour): by +/// `by_ds` the bot attacks with at least `min_armed` armed units. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FirstRaid { + /// The raid launches by this match time (`by_ds` below the match cap). + pub by_ds: u32, + /// Emergency production toward `min_armed` starts this long before + /// `by_ds` (less than `by_ds`). + pub prep_ds: u32, + /// Armed units the raid needs (at least 1). + pub min_armed: u32, + /// Most armed units it takes (`min_armed..=MAX_WAVE_UNITS`). + pub max_armed: u32, +} + +/// How a difficulty presses its sieges (M6 owner decision 12, 2026-10-29): +/// the behaviour the ROADMAP names for each level (Easy defensive; Standard +/// and Hard keep a siege going until a Town falls). [`Pressure::OFF`] is +/// the slice-2 bot: no reinforcement, no commit, the personality's retreat +/// and no extra home guard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Pressure { + /// While a siege wave assaults or holds its target, home armed units + /// assembled at the rally join it once at least this many are there + /// (beyond the home guard), and during the all-in the joiners and the + /// launch go in such groups from the rally instead of one by one; 0: + /// never (`0..=MAX_WAVE_UNITS`). + pub reinforce_min_units: u32, + /// A siege wave whose target Town lost hit points within this long + /// neither retreats nor ends on `wave_max_ds`; 0: off + /// (`0..=MAX_AI_DS`). + pub commit_ds: u32, + /// Scales the personality's `retreat_ratio_permille` (0: the level + /// never retreats; 1000 keeps it), `0..=MAX_DIFFICULTY_PERMILLE`. + pub retreat_ratio_permille: u32, + /// Scales the personality's `retreat_cooldown_ds`, + /// `MIN_DIFFICULTY_PERMILLE..=MAX_DIFFICULTY_PERMILLE`. + pub retreat_cooldown_permille: u32, + /// Armed units every launch, reinforcement and all-in joiner leaves at + /// home (`0..=MAX_WAVE_UNITS`), besides the one a full pop keeps. + pub home_guard_units: u32, + /// For up to this long after its assault order a siege wave holds at + /// its Wain point (out of every enemy shooter's reach) while enemy + /// armed units stand within `siege_focus_tiles` of its target, so the + /// defenders come out to it and the fight is not under the Town's and + /// Watchtowers' fire; 0: off (`0..=MAX_AI_DS`). + pub lure_ds: u32, + /// While a siege wave assaults, its armed units more than this many + /// tiles nearer the goal than its rear quarter stop and wait, so + /// the wave arrives together instead of fast units first; 0: off + /// (`0..=MAX_AI_TILES`). + pub cohesion_tiles: u32, + /// A siege wave (not the all-in, not a raid) launches only when the + /// Lanchester strength (`sum(hp) x sum(dps)`) of the units it would + /// take times 1000 reaches this times the strength of the enemy armed + /// units standing on the enemy's own land; 0: off + /// (`0..=MAX_AI_RATIO_PERMILLE`). + pub launch_ratio_permille: u32, + /// A staged siege wave (not the all-in) starts its assault only when + /// its Lanchester strength times 1000 reaches this times that of the + /// enemy armed units on the enemy's own land; until then it holds at + /// its stage point, where reinforcements join it, and if it is still + /// short at `stage_max_ds` it comes home; 0: off + /// (`0..=MAX_AI_RATIO_PERMILLE`). + pub assault_ratio_permille: u32, +} + +impl Pressure { + /// No pressure behaviour: the bot as slice 2 built it. + pub const OFF: Pressure = Pressure { + reinforce_min_units: 0, + commit_ds: 0, + retreat_ratio_permille: 1000, + retreat_cooldown_permille: 1000, + home_guard_units: 0, + lure_ds: 0, + cohesion_tiles: 0, + launch_ratio_permille: 0, + assault_ratio_permille: 0, + }; +} + +impl Default for Pressure { + fn default() -> Pressure { + Pressure::OFF + } +} + +/// One difficulty level, `data/ai/difficulty/.ron` (M6). Difficulty is +/// a disclosed cheat plus wave timing: the sim pays a bot seat whose +/// `AiSeat.bot` names the level the same `income` bundle every +/// `income_interval_ds`, from `income_from_ds` on (step 6d'), [`resolve`] +/// scales the personality's wave and retreat knobs, and (M6 owner decision +/// 12) its [`Pressure`] sets how hard the level presses a siege. Counters, +/// influence and noise are personality knobs, equal at every level. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Difficulty { + /// Equals the file stem; never `passive`. + pub id: String, + /// Display name. + pub name: String, + /// The bonus is paid every this many deciseconds; 0 pays nothing. + /// Otherwise `MIN_INCOME_INTERVAL_DS..=MAX_AI_DS`, and more than + /// `cmd_delay_ticks` in ticks. + pub income_interval_ds: u32, + /// The first payment completes one interval after this match time + /// (below the match cap). + pub income_from_ds: u32, + /// The bonus bundle per resource id, each `0..=MAX_BONUS_AMOUNT`. + pub income: BTreeMap, + /// The age gate of the bot's waves. + pub wave_age_gate: WaveAgeGate, + /// Every wave waits for the personality's largest wave size. + pub largest_wave_only: bool, + /// Caps every wave (and `max_wave_units`) at this many armed units. + pub max_wave_units: Option, + /// Scales every `wave_sizes` row (rounded up, at least 1), + /// `MIN_DIFFICULTY_PERMILLE..=MAX_DIFFICULTY_PERMILLE`. + pub wave_size_permille: u32, + /// Scales `wave_interval_ds` (rounded to nearest), same range. + pub wave_interval_permille: u32, + /// Replaces the personality's `all_in_after_ds` (`None` keeps it). + pub all_in_after_ds: Option, + /// The guaranteed early raid, if any. + pub first_raid: Option, + /// Scales every `Workers` count of the bot's build order (rounded up, + /// at least 1), `MIN_WORKER_TARGET_PERMILLE..=1000`: the disclosed + /// economic handicap M6 owner decision 2 allows for Easy alone (850: 15% + /// fewer Yeomen); 1000 at the other levels. + pub worker_target_permille: u32, + /// Techs the level researches once its build order is through (M6 + /// owner decision 12: a defensive level widens the border it holds), + /// each appended to the build order as an optional `Research` step + /// unless the order already researches it; at most + /// `MAX_EXTRA_RESEARCH`, each a tech id, each once. + pub extra_research: Vec, + /// How the level presses its sieges (M6 owner decision 12). + pub pressure: Pressure, +} + +impl Difficulty { + /// The bonus interval in ticks (0: no bonus). + pub fn income_interval_ticks(&self, rules: &Rules) -> u32 { + rules.ticks_from_ds(self.income_interval_ds) + } + + /// The match time the bonus schedule counts from, in ticks. + pub fn income_from_ticks(&self, rules: &Rules) -> u32 { + rules.ticks_from_ds(self.income_from_ds) + } + + /// The bonus bundle as a dense vector in `resources.ron` order. + pub fn bundle(&self, rules: &Rules) -> Vec { + rules.amounts(&self.income) + } + + /// Whether the level pays any bonus at all: a non-zero interval and a + /// non-empty bundle. + pub fn pays(&self) -> bool { + self.income_interval_ds > 0 && self.income.values().any(|v| *v > 0) + } +} + +/// The knobs a bot of difficulty `d` plays `p` with (M6): a pure function +/// of the two rows, so a bot rebuilt mid-match resolves the same knobs. +/// +/// - `max_wave_units` becomes the smaller of `p`'s and `d`'s cap; +/// - every `wave_sizes[i].armed` is scaled by `wave_size_permille`, rounded +/// up, at least 1, at most that cap (the rows stay non-decreasing); +/// - `largest_wave_only` sets every row to the last (largest) row's size; +/// - `wave_interval_ds` is scaled by `wave_interval_permille`, rounded to +/// nearest (half up), at least 1 and at most `MAX_AI_DS`; +/// - the age gate is `d.wave_age_gate`, the all-in time +/// `d.all_in_after_ds` when set, and `min_siege`, `raid_max_armed` and +/// `raid_min_armed` at most the cap. +/// +/// - (M6 owner decision 12) `retreat_ratio_permille` and +/// `retreat_cooldown_ds` are scaled by the level's +/// `pressure.retreat_ratio_permille` and `retreat_cooldown_permille`, +/// rounded to nearest (half up), at most `MAX_AI_RATIO_PERMILLE` and +/// `MAX_AI_DS`. +/// +/// Nothing else changes: counters, influence and noise are the +/// personality's at every level. +pub fn resolve(p: &Personality, d: &Difficulty) -> Personality { + let mut out = p.clone(); + let cap = d + .max_wave_units + .map_or(p.max_wave_units, |m| m.min(p.max_wave_units)) + .max(1); + out.max_wave_units = cap; + for w in &mut out.wave_sizes { + let scaled = (u64::from(w.armed) * u64::from(d.wave_size_permille)).div_ceil(1000); + let scaled = u32::try_from(scaled).unwrap_or(u32::MAX); + w.armed = scaled.clamp(1, cap); + } + if d.largest_wave_only + && let Some(last) = out.wave_sizes.last().map(|w| w.armed) + { + for w in &mut out.wave_sizes { + w.armed = last; + } + } + let interval = + (u64::from(p.wave_interval_ds) * u64::from(d.wave_interval_permille) + 500) / 1000; + out.wave_interval_ds = u32::try_from(interval) + .unwrap_or(MAX_AI_DS) + .clamp(1, MAX_AI_DS); + out.wave_requires_age = match &d.wave_age_gate { + WaveAgeGate::Personality => p.wave_requires_age.clone(), + WaveAgeGate::Age(age) => Some(age.clone()), + WaveAgeGate::Off => None, + }; + if let Some(ds) = d.all_in_after_ds { + out.all_in_after_ds = ds; + } + out.min_siege = out.min_siege.min(cap); + out.raid_max_armed = out.raid_max_armed.min(cap); + out.raid_min_armed = out.raid_min_armed.min(out.raid_max_armed); + let scale = |v: u32, permille: u32, max: u32| -> u32 { + let scaled = (u64::from(v) * u64::from(permille) + 500) / 1000; + u32::try_from(scaled).unwrap_or(max).min(max) + }; + out.retreat_ratio_permille = scale( + p.retreat_ratio_permille, + d.pressure.retreat_ratio_permille, + MAX_AI_RATIO_PERMILLE, + ); + out.retreat_cooldown_ds = scale( + p.retreat_cooldown_ds, + d.pressure.retreat_cooldown_permille, + MAX_AI_DS, + ); + out +} + +/// The build order a bot of difficulty `d` plays (M6): `order` with every +/// `Workers` count scaled by `d.worker_target_permille`, rounded up, at +/// least 1 (the split weights stay as they are: they are shares), and (M6 +/// owner decision 12) an optional `Research` step appended for each of +/// `d.extra_research` that the order does not research already, in the +/// level's order. A pure function of the two rows, like [`resolve`]. +pub fn resolve_order(order: &BuildOrder, d: &Difficulty) -> BuildOrder { + let mut out = order.clone(); + for step in &mut out.steps { + if let Step::Workers { count, .. } = step { + let scaled = (u64::from(*count) * u64::from(d.worker_target_permille)).div_ceil(1000); + *count = u32::try_from(scaled).unwrap_or(u32::MAX).max(1); + } + } + for tech in &d.extra_research { + let has = out + .steps + .iter() + .any(|s| matches!(s, Step::Research { tech: t, .. } if t == tech)); + if !has { + out.steps.push(Step::Research { + tech: tech.clone(), + optional: true, + }); + } + } + out } /// Contents of `data/ai/personalities.ron`. @@ -181,6 +506,27 @@ pub enum RecallWave { Always, } +/// How a personality's waves start (M6). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WaveMode { + /// Every wave is a siege wave (M5b). + Siege, + /// While no siege unit is assembled at the rally, the bot sends raids + /// (no siege, no Supply Wain) every `raid_every_ds`; siege waves once + /// it has one. + RaidUntilSiege, +} + +/// When a siege wave needs a Supply Wain to launch (M6). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WainRule { + /// Every wave takes one (M5b). + Always, + /// Only once the enemy has a Harrying level (attrition exists only + /// then); Wains are trained from then on too. + IfEnemyHarrying, +} + /// One entry of a personality's army mix. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -323,6 +669,95 @@ pub struct Personality { /// An assaulting wave is recalled when the threatened Town is at or /// below this share of its hit points. pub recall_wave_town_hp_permille: u32, + /// In the pool the `random` personality choice draws from (M6): rush, + /// boom and tower; the Balanced `standard` row is an explicit choice. + pub random: bool, + // M6 (docs/design/ai.md, "Personalities"): every field below is + // explicit in every row, and none of them is scaled by a difficulty + // (`resolve` leaves them alone, but caps the raid sizes). + /// `Siege` or `RaidUntilSiege`. + pub wave_mode: WaveMode, + /// When a siege wave needs a Supply Wain. + pub wain_rule: WainRule, + /// No wave or raid launches before this tech is researched (`None`: no + /// gate); the deadline raid ignores it. + pub wave_requires_tech: Option, + /// A wave skips the enemy Seat while the enemy holds another complete, + /// uncontested Town. + pub seat_last: bool, + /// Home armed units a raid needs (`RaidUntilSiege`). + pub raid_min_armed: u32, + /// Most armed units in a raid (`raid_min_armed..=MAX_WAVE_UNITS`). + pub raid_max_armed: u32, + /// Time between two raids. + pub raid_every_ds: u32, + /// A raid comes home after this long. + pub raid_max_ds: u32, + /// A raid comes home after this long with no enemy unit within + /// `raid_idle_tiles` of any of its units. + pub raid_idle_ds: u32, + /// See `raid_idle_ds`. + pub raid_idle_tiles: u32, + /// Raid target score per worker at the target building. + pub raid_worker_weight: u32, + /// Raid target score lost per tile from the rally, per mille. + pub raid_distance_permille: u32, + /// A wave retreats when its own strength times 1000 is below the + /// enemy army's near it times this (and `retreat_loss_permille` holds); + /// 0 never retreats. + pub retreat_ratio_permille: u32, + /// ... and it has lost at least this share of the armed units it + /// launched with. + pub retreat_loss_permille: u32, + /// No retreat within this long of the assault order (a raid's + /// launch). + pub retreat_commit_ds: u32, + /// After a retreat the next launch waits at least this long. + pub retreat_cooldown_ds: u32, + /// No retreat while the wave's target Town is at or below this share + /// of its hit points. + pub retreat_commit_town_hp_permille: u32, + /// Influence map cell side. + pub influence_cell_tiles: u32, + /// Cells around a point an influence query sums (a block of `2r + 1` + /// cells a side). + pub influence_radius_cells: u32, + /// Siege target score per unit of `isqrt` of the enemy strength at the + /// target (units and shooters), against 1000 per tile of distance. + pub target_strength_weight: u32, + /// How strongly counters bend the army mix, per mille (0: off). + pub counter_weight_permille: u32, + /// The smallest counter factor, per mille (`..=1000`). + pub counter_floor_permille: u32, + /// The largest counter factor, per mille (`1000..=MAX_COUNTER_PERMILLE`). + pub counter_cap_permille: u32, + /// A `counter_kinds` entry joins the mix while its factor reaches this + /// (`1000..=counter_cap_permille`). + pub counter_join_permille: u32, + /// Armed kinds outside `army_mix` that join it, with their weight, + /// while they counter what the enemy shows. + pub counter_kinds: Vec, + /// Enemy military near an own worked gathering building is a threat + /// too. + pub defend_economy: bool, + /// See `defend_economy`. + pub defend_economy_tiles: u32, + /// The guard attacks enemy Supply Wains among the threats first. + pub defend_wains_first: bool, + /// After a released defence, the bot adds one Watchtower near the + /// defended Town, up to this many in the match. + pub reactive_towers: u32, + /// The wave interval is jittered by up to this share, per mille. + pub noise_wave_interval_permille: u32, + /// Each wave needs up to this many more armed units (upward only). + pub noise_wave_units: u32, + /// The first wave waits up to this much longer. + pub noise_first_wave_ds: u32, + /// Each army-mix weight is jittered by up to this share, per mille. + pub noise_mix_permille: u32, + /// `Expansion` and `BorderEdge` sites are picked among the first this + /// many candidates that pass the cheap tests (1: always the best). + pub noise_site_top_k: u32, } impl Personality { @@ -345,13 +780,10 @@ pub fn worker_kind(rules: &Rules) -> Option<(u16, &crate::UnitKind)> { .map(|(i, u)| (u16::try_from(i).expect("validated len"), u)) } -/// Load and validate `dir/ai/` against the rest of `rules`: every build -/// order under `ai/build_orders/` (non-`.ron` files are ignored, files are -/// read in path order), then `ai/personalities.ron`. -pub(crate) fn load(dir: &Path, rules: &Rules) -> Result { - let orders_dir = dir.join("ai").join("build_orders"); - let entries = std::fs::read_dir(&orders_dir).map_err(|source| Error::Io { - path: orders_dir.clone(), +/// The `.ron` files of `dir`, in path order (other files are ignored). +fn ron_files(dir: &Path) -> Result, Error> { + let entries = std::fs::read_dir(dir).map_err(|source| Error::Io { + path: dir.to_path_buf(), source, })?; let mut paths: Vec = entries @@ -360,34 +792,273 @@ pub(crate) fn load(dir: &Path, rules: &Rules) -> Result { .filter(|p| p.extension().is_some_and(|e| e == "ron")) .collect(); paths.sort(); + Ok(paths) +} + +/// The file stem of `path`, which a keyed file's `id` must equal. +fn stem_of(path: &Path) -> String { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string() +} + +/// `id` must equal the file stem of `path`. +fn check_stem(path: &Path, id: &str) -> Check { + let stem = stem_of(path); + if id != stem { + return Err(Error::invalid( + path, + "id", + format!("{id:?} must equal the file stem {stem:?}"), + )); + } + Ok(()) +} + +/// Load and validate `dir/ai/` against the rest of `rules`: every build +/// order under `ai/build_orders/` (non-`.ron` files are ignored, files are +/// read in path order), then `ai/personalities.ron`, then (M6) every +/// difficulty level under `ai/difficulty/`, which must include +/// `standard`. +pub(crate) fn load(dir: &Path, rules: &Rules) -> Result { let mut build_orders = BTreeMap::new(); - for path in paths { + for path in ron_files(&dir.join("ai").join("build_orders"))? { let order: BuildOrder = load_ron(&path)?; - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default() - .to_string(); - if order.id != stem { - return Err(Error::invalid( - &path, - "id", - format!("{:?} must equal the file stem {stem:?}", order.id), - )); - } + check_stem(&path, &order.id)?; check_build_order(&path, &order, rules)?; - build_orders.insert(stem, order); + build_orders.insert(stem_of(&path), order); } let personalities_path = dir.join("ai").join("personalities.ron"); let file: Personalities = load_ron(&personalities_path)?; - let ai = AiRules { + let mut ai = AiRules { personalities: file.personalities, build_orders, + difficulties: BTreeMap::new(), }; check_personalities(&personalities_path, &ai, rules)?; + let difficulty_dir = dir.join("ai").join("difficulty"); + for path in ron_files(&difficulty_dir)? { + let level: Difficulty = load_ron(&path)?; + check_stem(&path, &level.id)?; + check_difficulty(&path, &level, rules)?; + ai.difficulties.insert(stem_of(&path), level); + } + if !ai.difficulties.contains_key(STANDARD_DIFFICULTY) { + return Err(Error::invalid( + &difficulty_dir.join(format!("{STANDARD_DIFFICULTY}.ron")), + "id", + format!( + "no {STANDARD_DIFFICULTY:?} difficulty (found: {}); every tool defaults to it", + ai.difficulties + .keys() + .cloned() + .collect::>() + .join(", ") + ), + )); + } Ok(ai) } +/// One difficulty level: identity, the bonus bundle and its schedule, the +/// multipliers, the age gate and the first raid (M6). +fn check_difficulty(path: &Path, d: &Difficulty, rules: &Rules) -> Check { + if d.id == PASSIVE_ID { + return Err(Error::invalid( + path, + "id", + format!("{PASSIVE_ID:?} is reserved for the bot that never acts"), + )); + } + if d.name.is_empty() { + return Err(Error::invalid(path, "name", "must not be empty")); + } + for (resource, amount) in &d.income { + let at = format!("income.{resource}"); + if rules.resource_index(resource).is_none() { + return Err(Error::invalid( + path, + &at, + format!("{resource:?} is not a resource id in resources.ron"), + )); + } + in_range(path, &at, *amount, 0, MAX_BONUS_AMOUNT)?; + } + if d.income_interval_ds != 0 { + in_range( + path, + "income_interval_ds", + d.income_interval_ds, + MIN_INCOME_INTERVAL_DS, + MAX_AI_DS, + )?; + if d.income_interval_ticks(rules) <= rules.cmd_delay_ticks { + return Err(Error::invalid( + path, + "income_interval_ds", + format!( + "{} ticks must be more than cmd_delay_ticks ({}) in rules.ron", + d.income_interval_ticks(rules), + rules.cmd_delay_ticks + ), + )); + } + } + let below_cap = |field: &str, ds: u32| -> Check { + if ds >= rules.match_cap_ds { + return Err(Error::invalid( + path, + field, + format!( + "{ds} is not below match_cap_ds ({}) in rules.ron", + rules.match_cap_ds + ), + )); + } + Ok(()) + }; + below_cap("income_from_ds", d.income_from_ds)?; + for (name, v) in [ + ("wave_size_permille", d.wave_size_permille), + ("wave_interval_permille", d.wave_interval_permille), + ] { + in_range( + path, + name, + v, + MIN_DIFFICULTY_PERMILLE, + MAX_DIFFICULTY_PERMILLE, + )?; + } + if let Some(n) = d.max_wave_units { + in_range(path, "max_wave_units", n, 1, MAX_WAVE_UNITS)?; + } + in_range( + path, + "worker_target_permille", + d.worker_target_permille, + MIN_WORKER_TARGET_PERMILLE, + 1000, + )?; + if d.extra_research.len() > MAX_EXTRA_RESEARCH { + return Err(Error::invalid( + path, + "extra_research", + format!( + "{} techs; a level adds at most {MAX_EXTRA_RESEARCH}", + d.extra_research.len() + ), + )); + } + for (i, tech) in d.extra_research.iter().enumerate() { + let at = format!("extra_research[{i}]"); + if rules.tech_by_id(tech).is_none() { + return Err(Error::invalid( + path, + &at, + format!("{tech:?} is not a tech id in techs.ron"), + )); + } + if d.extra_research[..i].contains(tech) { + return Err(Error::invalid( + path, + &at, + format!("{tech:?} is listed twice"), + )); + } + } + let pr = &d.pressure; + in_range( + path, + "pressure.reinforce_min_units", + pr.reinforce_min_units, + 0, + MAX_WAVE_UNITS, + )?; + in_range(path, "pressure.commit_ds", pr.commit_ds, 0, MAX_AI_DS)?; + in_range( + path, + "pressure.retreat_ratio_permille", + pr.retreat_ratio_permille, + 0, + MAX_DIFFICULTY_PERMILLE, + )?; + in_range( + path, + "pressure.retreat_cooldown_permille", + pr.retreat_cooldown_permille, + MIN_DIFFICULTY_PERMILLE, + MAX_DIFFICULTY_PERMILLE, + )?; + in_range( + path, + "pressure.home_guard_units", + pr.home_guard_units, + 0, + MAX_WAVE_UNITS, + )?; + in_range(path, "pressure.lure_ds", pr.lure_ds, 0, MAX_AI_DS)?; + in_range( + path, + "pressure.cohesion_tiles", + pr.cohesion_tiles, + 0, + MAX_AI_TILES, + )?; + in_range( + path, + "pressure.launch_ratio_permille", + pr.launch_ratio_permille, + 0, + MAX_AI_RATIO_PERMILLE, + )?; + in_range( + path, + "pressure.assault_ratio_permille", + pr.assault_ratio_permille, + 0, + MAX_AI_RATIO_PERMILLE, + )?; + if let Some(ds) = d.all_in_after_ds { + in_range(path, "all_in_after_ds", ds, 0, MAX_AI_DS)?; + } + if let WaveAgeGate::Age(age) = &d.wave_age_gate + && rules.age_index(age).is_none() + { + return Err(Error::invalid( + path, + "wave_age_gate", + format!("{age:?} is not an age id in ages.ron"), + )); + } + if let Some(raid) = &d.first_raid { + in_range( + path, + "first_raid.min_armed", + raid.min_armed, + 1, + MAX_WAVE_UNITS, + )?; + in_range( + path, + "first_raid.max_armed", + raid.max_armed, + raid.min_armed, + MAX_WAVE_UNITS, + )?; + below_cap("first_raid.by_ds", raid.by_ds)?; + if raid.prep_ds >= raid.by_ds { + return Err(Error::invalid( + path, + "first_raid.prep_ds", + format!("{} must be less than by_ds ({})", raid.prep_ds, raid.by_ds), + )); + } + } + Ok(()) +} + type Check = Result<(), Error>; fn in_range(path: &Path, field: &str, v: u32, lo: u32, hi: u32) -> Check { @@ -424,6 +1095,7 @@ fn check_build_order(path: &Path, order: &BuildOrder, rules: &Rules) -> Check { let mut built: BTreeSet<&str> = BTreeSet::from([town.id.as_str()]); let mut researched: BTreeSet<&str> = BTreeSet::new(); let mut towns: u32 = 1; + let mut advanced = false; for (i, step) in order.steps.iter().enumerate() { let field = |name: &str| format!("steps[{i}].{name}"); let unreachable = |name: &str, why: String| { @@ -668,6 +1340,17 @@ fn check_build_order(path: &Path, order: &BuildOrder, rules: &Rules) -> Check { ), )); } + // M6: a difficulty's deadline raid trains armed units before + // the first Advance, so a trainer of an armed kind of the + // start age must stand by then. + if !advanced && !armed_trainer_built(rules, &built, &researched, age) { + return Err(Error::invalid( + path, + &field("age"), + "no earlier non-optional step builds a trainer of an armed kind (such as a Muster Hall); every build order builds one before its first Advance", + )); + } + advanced = true; age = index; } Step::Military => {} @@ -679,6 +1362,26 @@ fn check_build_order(path: &Path, order: &BuildOrder, rules: &Rules) -> Check { Ok(()) } +/// Whether a building kind in `built` trains an armed, non-civilian unit +/// kind unlocked by `age` whose `requires_tech`, if any, is `researched`. +fn armed_trainer_built( + rules: &Rules, + built: &BTreeSet<&str>, + researched: &BTreeSet<&str>, + age: u8, +) -> bool { + rules.units.iter().enumerate().any(|(i, u)| { + let index = u16::try_from(i).expect("validated len"); + u.class != UnitClass::Civilian + && u.attack.is_some() + && built.contains(u.trainer.as_str()) + && rules.age_of_unit(index) <= age + && u.requires_tech + .as_ref() + .is_none_or(|t| researched.contains(t.as_str())) + }) +} + /// The personality rows: ids, build orders, ranges and cross-checks. fn check_personalities(path: &Path, ai: &AiRules, rules: &Rules) -> Check { let mut ids = BTreeSet::new(); @@ -911,10 +1614,193 @@ fn check_personalities(path: &Path, ai: &AiRules, rules: &Rules) -> Check { format!("{age:?} is not an age id in ages.ron"), )); } + check_m6_knobs(path, &f, p, rules)?; } Ok(()) } +/// The M6 knobs of one personality row (`f` names its fields). +fn check_m6_knobs( + path: &Path, + f: &dyn Fn(&str) -> String, + p: &Personality, + rules: &Rules, +) -> Check { + if let Some(tech) = &p.wave_requires_tech + && rules.tech_by_id(tech).is_none() + { + return Err(Error::invalid( + path, + &f("wave_requires_tech"), + format!("{tech:?} is not a tech id in techs.ron"), + )); + } + in_range( + path, + &f("raid_min_armed"), + p.raid_min_armed, + 1, + MAX_WAVE_UNITS, + )?; + in_range( + path, + &f("raid_max_armed"), + p.raid_max_armed, + p.raid_min_armed, + MAX_WAVE_UNITS, + )?; + for (name, v, lo) in [ + ("raid_every_ds", p.raid_every_ds, 1), + ("raid_max_ds", p.raid_max_ds, 1), + ("raid_idle_ds", p.raid_idle_ds, 1), + ("retreat_commit_ds", p.retreat_commit_ds, 0), + ("retreat_cooldown_ds", p.retreat_cooldown_ds, 0), + ("noise_first_wave_ds", p.noise_first_wave_ds, 0), + ] { + in_range(path, &f(name), v, lo, MAX_AI_DS)?; + } + for (name, v) in [ + ("raid_idle_tiles", p.raid_idle_tiles), + ("defend_economy_tiles", p.defend_economy_tiles), + ] { + in_range(path, &f(name), v, 1, MAX_AI_TILES)?; + } + in_range( + path, + &f("raid_worker_weight"), + p.raid_worker_weight, + 0, + MAX_AI_WEIGHT, + )?; + in_range( + path, + &f("target_strength_weight"), + p.target_strength_weight, + 0, + MAX_AI_WEIGHT, + )?; + for (name, v) in [ + ("raid_distance_permille", p.raid_distance_permille), + ("retreat_ratio_permille", p.retreat_ratio_permille), + ] { + in_range(path, &f(name), v, 0, MAX_AI_RATIO_PERMILLE)?; + } + for (name, v) in [ + ("retreat_loss_permille", p.retreat_loss_permille), + ( + "retreat_commit_town_hp_permille", + p.retreat_commit_town_hp_permille, + ), + ] { + in_range(path, &f(name), v, 0, 1000)?; + } + in_range( + path, + &f("influence_cell_tiles"), + p.influence_cell_tiles, + 1, + MAX_INFLUENCE_CELL_TILES, + )?; + in_range( + path, + &f("influence_radius_cells"), + p.influence_radius_cells, + 0, + MAX_INFLUENCE_RADIUS_CELLS, + )?; + in_range( + path, + &f("counter_weight_permille"), + p.counter_weight_permille, + 0, + MAX_COUNTER_PERMILLE, + )?; + in_range( + path, + &f("counter_floor_permille"), + p.counter_floor_permille, + 0, + 1000, + )?; + in_range( + path, + &f("counter_cap_permille"), + p.counter_cap_permille, + 1000, + MAX_COUNTER_PERMILLE, + )?; + in_range( + path, + &f("counter_join_permille"), + p.counter_join_permille, + 1000, + p.counter_cap_permille, + )?; + let mut kinds = BTreeSet::new(); + for (j, e) in p.counter_kinds.iter().enumerate() { + let at = f(&format!("counter_kinds[{j}].kind")); + let Some(def) = rules.unit_kind_by_id(&e.kind) else { + return Err(Error::invalid( + path, + &at, + format!("{:?} is not a unit kind id in units.ron", e.kind), + )); + }; + if def.class == UnitClass::Civilian || def.attack.is_none() { + return Err(Error::invalid( + path, + &at, + format!("{:?} is not an armed, non-civilian kind", e.kind), + )); + } + if !kinds.insert(e.kind.as_str()) { + return Err(Error::invalid( + path, + &at, + format!("{:?} is listed twice", e.kind), + )); + } + in_range( + path, + &f(&format!("counter_kinds[{j}].weight")), + e.weight, + 1, + MAX_AI_WEIGHT, + )?; + } + in_range( + path, + &f("reactive_towers"), + p.reactive_towers, + 0, + MAX_AI_CAP, + )?; + for (name, v) in [ + ( + "noise_wave_interval_permille", + p.noise_wave_interval_permille, + ), + ("noise_mix_permille", p.noise_mix_permille), + ] { + in_range(path, &f(name), v, 0, MAX_NOISE_PERMILLE)?; + } + in_range( + path, + &f("noise_wave_units"), + p.noise_wave_units, + 0, + MAX_WAVE_UNITS, + )?; + in_range( + path, + &f("noise_site_top_k"), + p.noise_site_top_k, + 1, + MAX_SITE_TOP_K, + )?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -956,19 +1842,53 @@ mod tests { const BO: &str = "ai/build_orders/standard.ron"; const PE: &str = "ai/personalities.ron"; + const EASY: &str = "ai/difficulty/easy.ron"; + const HARD: &str = "ai/difficulty/hard.ron"; + /// M6: the four personalities (standard first, then the `random` + /// pool rush, boom and tower) and their build orders; the M5b + /// `standard` row and build order are as they were. #[test] - fn repo_ai_data_loads_one_personality_and_one_build_order() { + fn repo_ai_data_loads_four_personalities_and_build_orders() { let rules = Rules::load(data_dir()).unwrap(); - assert_eq!(rules.ai.personalities.len(), 1); - assert_eq!(rules.ai.build_orders.len(), 1); + assert_eq!( + rules + .ai + .personalities + .iter() + .map(|p| (p.id.as_str(), p.build_order.as_str(), p.random)) + .collect::>(), + [ + ("standard", "standard", false), + ("rush", "rush", true), + ("boom", "boom", true), + ("tower", "tower", true) + ] + ); + assert_eq!( + rules + .ai + .build_orders + .keys() + .map(String::as_str) + .collect::>(), + ["boom", "rush", "standard", "tower"] + ); + let rush = rules.ai.personality("rush").unwrap(); + assert_eq!( + (rush.wave_mode, rush.wain_rule, rush.seat_last), + (WaveMode::RaidUntilSiege, WainRule::IfEnemyHarrying, false) + ); + let tower = rules.ai.personality("tower").unwrap(); + assert_eq!(tower.wave_requires_tech.as_deref(), Some("harrying_1")); + assert!(tower.defend_wains_first && tower.reactive_towers > 0); let p = rules.ai.personality("standard").unwrap(); assert_eq!(p.build_order, "standard"); assert_eq!(p.think_every_ticks(&rules), 10); assert_eq!(p.recall_wave, RecallWave::WhileStaging); assert_eq!(p.wave_requires_age.as_deref(), Some("masonry")); let order = rules.ai.build_order("standard").unwrap(); - assert_eq!(order.steps.len(), 37); + assert_eq!(order.steps.len(), 38); assert_eq!( order.steps[0], Step::Workers { @@ -982,10 +1902,16 @@ mod tests { worker_kind(&rules).map(|(i, u)| (i, u.id.as_str())), Some((0, "yeoman")) ); - assert!(rules.ai.personality("rush").is_none()); + let standard = rules.ai.personality("standard").unwrap(); + assert_eq!( + (standard.wave_mode, standard.wain_rule, standard.seat_last), + (WaveMode::Siege, WainRule::Always, true) + ); } - /// `data/ai` is in `rules_hash`: a knob edit changes the hash. + /// `data/ai` is in `rules_hash`: a knob edit changes the hash, and so + /// (M6) does a difficulty edit, the line-4 flip's one-line edit of + /// `hard.ron` included. #[test] fn ai_data_is_in_the_rules_hash() { let repo = Rules::load(data_dir()).unwrap(); @@ -994,6 +1920,320 @@ mod tests { let mut cleared = repo.clone(); cleared.ai = AiRules::default(); assert_ne!(repo.rules_hash(), cleared.rules_hash()); + let flipped = load_edited( + "hash-flip", + &[(HARD, "income_interval_ds: 200,", "income_interval_ds: 300,")], + ) + .unwrap(); + assert_ne!(repo.rules_hash(), flipped.rules_hash()); + assert_eq!( + flipped.ai.difficulty("hard").unwrap().income_interval_ds, + 300 + ); + let mut no_levels = repo.clone(); + no_levels.ai.difficulties.remove("easy"); + assert_ne!(repo.rules_hash(), no_levels.rules_hash()); + } + + /// M6: the three shipped levels, their schedules and the knobs they + /// scale. The bundle is the same at every level, so the interval alone + /// sizes the cheat. + #[test] + fn repo_difficulties_are_easy_standard_hard() { + let rules = Rules::load(data_dir()).unwrap(); + assert_eq!( + rules + .ai + .difficulties + .keys() + .map(String::as_str) + .collect::>(), + ["easy", "hard", "standard"] + ); + let level = |id: &str| rules.ai.difficulty(id).unwrap(); + let (easy, standard, hard) = (level("easy"), level("standard"), level("hard")); + assert_eq!( + [easy, standard, hard].map(|d| d.income_interval_ticks(&rules)), + [600, 500, 400] + ); + // From 3:00; the first payment completes one interval later. + for d in [easy, standard, hard] { + assert_eq!(d.income_from_ticks(&rules), 3600); + assert!(d.pays()); + assert_eq!(d.bundle(&rules), standard.bundle(&rules)); + } + let bundle = standard.bundle(&rules); + let grain = rules.resource_index("grain").unwrap(); + let lore = rules.resource_index("lore").unwrap(); + assert_eq!(bundle[grain], 20); + assert_eq!(bundle[lore], 0); + assert_eq!(easy.wave_age_gate, WaveAgeGate::Age("masonry".into())); + assert_eq!(standard.wave_age_gate, WaveAgeGate::Personality); + assert_eq!(hard.wave_age_gate, WaveAgeGate::Off); + assert!(easy.largest_wave_only && easy.first_raid.is_none()); + assert_eq!(hard.first_raid.map(|r| r.by_ds), Some(6000)); + assert_eq!(standard.first_raid.map(|r| r.by_ds), Some(9000)); + assert!(rules.ai.difficulty("passive").is_none()); + } + + /// M6: `resolve` scales the wave sizes once (rounded up, at least 1, + /// capped), the interval (rounded to nearest), sets the age gate and + /// the all-in time, and `largest_wave_only` flattens the rows; it + /// leaves every other knob alone. Standard resolves to the + /// personality with its all-in at 36:00 (M6 owner decision 12); it + /// retreats as the personality does, and Hard never retreats. + #[test] + fn resolve_scales_sizes_and_intervals_once_and_caps_largest_only() { + let rules = Rules::load(data_dir()).unwrap(); + let p = rules.ai.personality("standard").unwrap(); + let level = |id: &str| rules.ai.difficulty(id).unwrap(); + let mut want = p.clone(); + want.all_in_after_ds = 21600; + assert_eq!(resolve(p, level("standard")), want); + assert_eq!(resolve(p, level("hard")).retreat_ratio_permille, 0); + let sizes = |k: &Personality| k.wave_sizes.iter().map(|w| w.armed).collect::>(); + let easy = resolve(p, level("easy")); + assert_eq!(sizes(&easy), [14, 14, 14, 14]); + assert_eq!(easy.max_wave_units, 14); + assert_eq!(easy.wave_interval_ds, 2700); + assert_eq!(easy.wave_requires_age.as_deref(), Some("masonry")); + assert_eq!(easy.all_in_after_ds, 19800); + let hard = resolve(p, level("hard")); + assert_eq!(sizes(&hard), sizes(p)); + assert_eq!(hard.wave_interval_ds, 1440); + assert_eq!(hard.wave_requires_age, None); + assert_eq!(hard.all_in_after_ds, p.all_in_after_ds); + // Rounding: sizes up, at least 1, under the cap; the interval to + // nearest, half up (the level's all-in and pressure off, so nothing + // else moves). + let mut d = level("standard").clone(); + d.all_in_after_ds = None; + d.pressure = Pressure::OFF; + d.wave_size_permille = 250; + d.wave_interval_permille = 333; + d.max_wave_units = Some(3); + let mut q = p.clone(); + q.wave_sizes[0].armed = 1; + q.wave_interval_ds = 1500; + q.min_siege = 5; + let r = resolve(&q, &d); + assert_eq!( + sizes(&r), + [1, 3, 3, 3], + "1 -> 1, 12 -> 3, 16 -> 4 capped, 24 -> 6 capped" + ); + assert_eq!(r.wave_interval_ds, 500, "1500 x 333 / 1000 = 499.5 -> 500"); + assert_eq!((r.max_wave_units, r.min_siege), (3, 3)); + // M6: the raid sizes are capped too. + assert_eq!((r.raid_min_armed, r.raid_max_armed), (3, 3)); + // Nothing else moves. + let mut same = r.clone(); + same.wave_sizes = q.wave_sizes.clone(); + same.wave_interval_ds = q.wave_interval_ds; + same.max_wave_units = q.max_wave_units; + same.min_siege = q.min_siege; + same.raid_min_armed = q.raid_min_armed; + same.raid_max_armed = q.raid_max_armed; + assert_eq!(same, q); + } + + /// M6: a disclosed Yeomen handicap (M6 owner decision 2's fallback, + /// `worker_target_permille` 850) scales every `Workers` count of the + /// shipped Standard build order, rounded up (15% fewer Yeomen), and + /// leaves the splits and every other step alone; at 1000 (every shipped + /// level since the M6 retune: line 2 passes without the fallback) the + /// order's steps are unchanged, and a count never drops below 1. + #[test] + fn resolve_order_scales_worker_counts_by_the_easy_handicap() { + let rules = Rules::load(data_dir()).unwrap(); + let order = rules.ai.build_order("standard").unwrap(); + let level = |id: &str| rules.ai.difficulty(id).unwrap(); + let counts = |o: &BuildOrder| { + o.steps + .iter() + .filter_map(|s| match s { + Step::Workers { count, .. } => Some(*count), + _ => None, + }) + .collect::>() + }; + assert_eq!(counts(order), [7, 9, 11, 14, 17, 20, 23]); + let mut handicap = level("easy").clone(); + handicap.worker_target_permille = 850; + handicap.extra_research.clear(); + let easy = resolve_order(order, &handicap); + assert_eq!(counts(&easy), [6, 8, 10, 12, 15, 17, 20]); + assert_eq!(easy.id, order.id); + assert_eq!(easy.steps.len(), order.steps.len()); + for (got, was) in easy.steps.iter().zip(&order.steps) { + match (got, was) { + (Step::Workers { split: a, .. }, Step::Workers { split: b, .. }) => { + assert_eq!(a, b, "the split weights stay"); + } + _ => assert_eq!(got, was, "a non-Workers step stays"), + } + } + for id in ["standard", "hard"] { + assert_eq!(level(id).worker_target_permille, 1000, "{id}"); + assert_eq!(&resolve_order(order, level(id)), order, "{id}"); + } + // Shipped Easy: no Yeomen handicap; its Statecraft II is appended. + assert_eq!(level("easy").worker_target_permille, 1000); + let shipped_easy = resolve_order(order, level("easy")); + assert_eq!(counts(&shipped_easy), counts(order)); + assert_eq!(&shipped_easy.steps[..order.steps.len()], &order.steps[..]); + let one = BuildOrder { + id: "one".into(), + steps: vec![Step::Workers { + count: 1, + split: BTreeMap::from([("grain".to_string(), 1)]), + }], + }; + let mut floor = level("easy").clone(); + floor.worker_target_permille = MIN_WORKER_TARGET_PERMILLE; + assert_eq!(counts(&resolve_order(&one, &floor)), [1]); + } + + /// M6 owner decision 12: a level's pressure scales the personality's + /// retreat ratio and cooldown (rounded to nearest, half up; 0 never + /// retreats) and touches no other knob. + #[test] + fn pressure_scales_the_retreat_knobs_only() { + let rules = Rules::load(data_dir()).unwrap(); + let p = rules.ai.personality("standard").unwrap(); + let mut d = rules.ai.difficulty("standard").unwrap().clone(); + d.wave_age_gate = WaveAgeGate::Personality; + d.largest_wave_only = false; + d.max_wave_units = None; + d.wave_size_permille = 1000; + d.wave_interval_permille = 1000; + d.all_in_after_ds = None; + d.pressure = Pressure::OFF; + assert_eq!(&resolve(p, &d), p, "no pressure: the personality itself"); + d.pressure.retreat_ratio_permille = 500; + d.pressure.retreat_cooldown_permille = 333; + let r = resolve(p, &d); + assert_eq!( + r.retreat_ratio_permille, + (p.retreat_ratio_permille * 500 + 500) / 1000 + ); + assert_eq!( + r.retreat_cooldown_ds, + (p.retreat_cooldown_ds * 333 + 500) / 1000 + ); + let mut same = r.clone(); + same.retreat_ratio_permille = p.retreat_ratio_permille; + same.retreat_cooldown_ds = p.retreat_cooldown_ds; + assert_eq!(&same, p, "nothing else moves"); + d.pressure.retreat_ratio_permille = 0; + assert_eq!(resolve(p, &d).retreat_ratio_permille, 0, "never retreats"); + } + + /// M6 owner decision 12 (mirrored sites must not make cap games exact + /// copies): the research a level or a build order adds is what keeps + /// two seats from building mirror images, so it is pinned here. Easy's + /// resolved `standard` order differs from Standard's and Hard's (its + /// Statecraft II), and the `tower` order researches Statecraft II + /// itself (as `boom` does); Standard and Hard resolve the `standard` + /// order identically, + /// the known limit (an untouched Standard-vs-Hard or Standard mirror cap + /// game is still decided by site noise; docs/design/ai.md, "Difficulty + /// table (as retuned)"). + #[test] + fn the_research_that_breaks_mirrored_cap_games_is_pinned() { + let rules = Rules::load(data_dir()).unwrap(); + let order = rules.ai.build_order("standard").unwrap(); + let resolved = |id: &str| resolve_order(order, rules.ai.difficulty(id).unwrap()); + let statecraft_2 = |o: &BuildOrder| { + o.steps + .iter() + .any(|s| matches!(s, Step::Research { tech, .. } if tech == "statecraft_2")) + }; + let (easy, standard, hard) = (resolved("easy"), resolved("standard"), resolved("hard")); + assert!(statecraft_2(&easy), "Easy researches Statecraft II"); + assert!(!statecraft_2(&standard) && !statecraft_2(&hard)); + assert_ne!(easy, standard, "Easy vs Standard"); + assert_ne!(easy, hard, "Hard vs Easy (the flip)"); + assert_eq!( + standard, hard, + "Standard vs Hard: the same order (the known limit)" + ); + // Since the retune `tower` researches Statecraft II too (boom has + // since slice 2), so a tower seat's untouched cap game is not a + // mirror of a rush or Balanced seat's. + let has = |id: &str| statecraft_2(rules.ai.build_order(id).unwrap()); + assert!(has("tower"), "tower researches Statecraft II"); + assert!(has("boom")); + assert!(!has("standard") && !has("rush")); + } + + /// M6 owner decision 12: `extra_research` appends one optional + /// `Research` step per tech the build order does not research yet, in + /// the level's order, and leaves every other step alone. + #[test] + fn extra_research_appends_optional_steps_once() { + let rules = Rules::load(data_dir()).unwrap(); + let order = rules.ai.build_order("standard").unwrap(); + let mut d = rules.ai.difficulty("standard").unwrap().clone(); + d.worker_target_permille = 1000; + d.extra_research = Vec::new(); + assert_eq!(&resolve_order(order, &d), order); + d.extra_research = vec!["statecraft_2".into(), "arms_1".into(), "letters_2".into()]; + let o = resolve_order(order, &d); + let n = order.steps.len(); + assert_eq!(o.steps[..n], order.steps[..], "the order itself stays"); + assert_eq!( + o.steps[n..], + [ + Step::Research { + tech: "statecraft_2".into(), + optional: true + }, + Step::Research { + tech: "letters_2".into(), + optional: true + }, + ], + "arms_1 is researched already" + ); + } + + /// M6: `passive` is reserved for the bot that never acts, a `standard` + /// level must exist, and non-`.ron` files under `ai/difficulty/` are + /// ignored. + #[test] + fn difficulty_files_reserve_passive_and_require_standard() { + let dir = std::env::temp_dir().join(format!( + "eonmark-ai-rules-difficulty-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + copy_dir(&data_dir(), &dir); + let levels = dir.join("ai").join("difficulty"); + std::fs::write(levels.join("NOTES.md"), "not a level").unwrap(); + assert_eq!(Rules::load(&dir).unwrap().ai.difficulties.len(), 3); + let hard = std::fs::read_to_string(levels.join("hard.ron")).unwrap(); + std::fs::write( + levels.join("passive.ron"), + hard.replacen("id: \"hard\"", "id: \"passive\"", 1), + ) + .unwrap(); + let err = Rules::load(&dir).unwrap_err().to_string(); + assert!( + err.contains("passive.ron") && err.contains("reserved"), + "{err}" + ); + std::fs::remove_file(levels.join("passive.ron")).unwrap(); + std::fs::remove_file(levels.join("standard.ron")).unwrap(); + let err = Rules::load(&dir).unwrap_err().to_string(); + assert!( + err.contains("standard.ron") && err.contains("no \"standard\" difficulty"), + "{err}" + ); + std::fs::remove_dir_all(&levels).unwrap(); + let err = Rules::load(&dir).unwrap_err().to_string(); + assert!(err.contains("difficulty"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); } /// Non-`.ron` files under `ai/build_orders/` are ignored, and a second @@ -1019,7 +2259,7 @@ mod tests { .keys() .map(String::as_str) .collect::>(), - ["standard", "tiny"] + ["boom", "rush", "standard", "tiny", "tower"] ); std::fs::write(orders.join("empty.ron"), "(id: \"empty\", steps: [])").unwrap(); let err = Rules::load(&dir).unwrap_err().to_string(); @@ -1115,7 +2355,7 @@ mod tests { ( "age", &[(BO, "Advance(age: \"masonry\")", "Advance(age: \"bronze\")")], - &["steps[18].age", "age id"], + &["steps[19].age", "age id"], ), ( "wait", @@ -1175,12 +2415,12 @@ mod tests { (BO, "Advance(age: \"masonry\"),", "Wait(until_ds: 0),"), (BO, "Advance(age: \"charter\"),", "Wait(until_ds: 0),"), ], - &["steps[19].kind", "\"engine_yard\"", "masonry", "optional"], + &["steps[21].kind", "\"engine_yard\"", "masonry", "optional"], ), ( "advance-techs", &[(BO, "Research(tech: \"trade_1\"),", "Wait(until_ds: 0),")], - &["steps[18].age", "needs 2 techs", "research 1"], + &["steps[19].age", "needs 2 techs", "research 1"], ), ( "trainer", @@ -1214,7 +2454,7 @@ mod tests { "Research(tech: \"letters_1\")", "Research(tech: \"letters_2\")", )], - &["steps[27].tech", "needs \"letters_1\" first"], + &["steps[29].tech", "needs \"letters_1\" first"], ), ( "per-town", @@ -1223,12 +2463,12 @@ mod tests { "Build(kind: \"croft\", count: 8, at: NearTown, optional: true)", "Build(kind: \"croft\", count: 8, at: NearTown)", )], - &["steps[25].count", "max_per_town"], + &["steps[27].count", "max_per_town"], ), ( "ages-go-up", &[(BO, "Advance(age: \"charter\")", "Advance(age: \"masonry\")")], - &["steps[30].age", "must go up"], + &["steps[32].age", "must go up"], ), // Personalities. ( @@ -1238,8 +2478,8 @@ mod tests { ), ( "p-build-order", - &[(PE, "build_order: \"standard\"", "build_order: \"rush\"")], - &["personalities[0].build_order", "\"rush\""], + &[(PE, "build_order: \"standard\"", "build_order: \"zerg\"")], + &["personalities[0].build_order", "\"zerg\""], ), ( "p-cadence", @@ -1467,6 +2707,373 @@ mod tests { &[(PE, "recall_wave: WhileStaging", "recall_wave: Sometimes")], &["personalities.ron", "Sometimes"], ), + // M6: the knobs of slice 2. + ( + "p-tech", + &[( + PE, + "wave_requires_tech: None,", + "wave_requires_tech: Some(\"arms_9\"),", + )], + &["personalities[0].wave_requires_tech", "tech id"], + ), + ( + "p-raid-min", + &[(PE, "raid_min_armed: 6,", "raid_min_armed: 0,")], + &["personalities[0].raid_min_armed", "1..=64"], + ), + ( + "p-raid-max", + &[(PE, "raid_max_armed: 10,", "raid_max_armed: 5,")], + &["personalities[0].raid_max_armed", "6..=64"], + ), + ( + "p-raid-every", + &[(PE, "raid_every_ds: 1200,", "raid_every_ds: 0,")], + &["personalities[0].raid_every_ds", "1..=72000"], + ), + ( + "p-raid-tiles", + &[(PE, "raid_idle_tiles: 10,", "raid_idle_tiles: 65,")], + &["personalities[0].raid_idle_tiles", "1..=64"], + ), + ( + "p-raid-weight", + &[(PE, "raid_worker_weight: 100,", "raid_worker_weight: 1001,")], + &["personalities[0].raid_worker_weight", "0..=1000"], + ), + ( + "p-retreat-ratio", + &[( + PE, + "retreat_ratio_permille: 650,", + "retreat_ratio_permille: 10001,", + )], + &["personalities[0].retreat_ratio_permille", "0..=10000"], + ), + ( + "p-retreat-loss", + &[( + PE, + "retreat_loss_permille: 300,", + "retreat_loss_permille: 1001,", + )], + &["personalities[0].retreat_loss_permille", "0..=1000"], + ), + ( + "p-cell", + &[(PE, "influence_cell_tiles: 4,", "influence_cell_tiles: 0,")], + &["personalities[0].influence_cell_tiles", "1..=16"], + ), + ( + "p-cells", + &[( + PE, + "influence_radius_cells: 1,", + "influence_radius_cells: 9,", + )], + &["personalities[0].influence_radius_cells", "0..=8"], + ), + ( + "p-counter-floor", + &[( + PE, + "counter_floor_permille: 250,", + "counter_floor_permille: 1001,", + )], + &["personalities[0].counter_floor_permille", "0..=1000"], + ), + ( + "p-counter-cap", + &[( + PE, + "counter_cap_permille: 4000,", + "counter_cap_permille: 999,", + )], + &["personalities[0].counter_cap_permille", "1000..=8000"], + ), + ( + "p-counter-join", + &[( + PE, + "counter_join_permille: 1500,", + "counter_join_permille: 4001,", + )], + &["personalities[0].counter_join_permille", "1000..=4000"], + ), + ( + "p-counter-kind", + &[( + PE, + "(kind: \"outrider\", weight: 2)", + "(kind: \"supply_wain\", weight: 2)", + )], + &[ + "personalities[0].counter_kinds[0].kind", + "armed, non-civilian", + ], + ), + ( + "p-counter-weight", + &[( + PE, + "(kind: \"outrider\", weight: 2)", + "(kind: \"outrider\", weight: 0)", + )], + &["personalities[0].counter_kinds[0].weight", "1..=1000"], + ), + ( + "p-economy-tiles", + &[(PE, "defend_economy_tiles: 8,", "defend_economy_tiles: 0,")], + &["personalities[0].defend_economy_tiles", "1..=64"], + ), + ( + "p-towers", + &[(PE, "reactive_towers: 0,", "reactive_towers: 65,")], + &["personalities[0].reactive_towers", "0..=64"], + ), + ( + "p-noise", + &[(PE, "noise_mix_permille: 200,", "noise_mix_permille: 501,")], + &["personalities[0].noise_mix_permille", "0..=500"], + ), + ( + "p-top-k", + &[(PE, "noise_site_top_k: 2,", "noise_site_top_k: 0,")], + &["personalities[0].noise_site_top_k", "1..=8"], + ), + ( + "p-mode", + &[(PE, "wave_mode: Siege,", "wave_mode: Sometimes,")], + &["personalities.ron", "Sometimes"], + ), + ( + "p-random-missing", + &[(PE, " random: false,\n", "")], + &["personalities.ron", "random"], + ), + ( + "d-workers", + &[( + EASY, + "worker_target_permille: ", + "worker_target_permille: 1", + )], + &["easy.ron", "worker_target_permille", "500..=1000"], + ), + ( + "d-research-id", + &[(EASY, "extra_research: [", "extra_research: [\"nope\", ")], + &["easy.ron", "extra_research[0]", "nope"], + ), + ( + "d-research-twice", + &[( + EASY, + "extra_research: [", + "extra_research: [\"arms_1\", \"arms_1\", ", + )], + &["easy.ron", "extra_research[1]", "twice"], + ), + // M6 owner decision 12: the pressure block's ranges (the + // trailing `//` comments out the shipped value). + ( + "d-reinforce", + &[(EASY, "reinforce_min_units: ", "reinforce_min_units: 65, //")], + &["easy.ron", "pressure.reinforce_min_units", "0..=64"], + ), + ( + "d-commit", + &[(EASY, "commit_ds: ", "commit_ds: 72001, //")], + &["easy.ron", "pressure.commit_ds", "0..=72000"], + ), + ( + "d-retreat", + &[( + HARD, + "retreat_ratio_permille: ", + "retreat_ratio_permille: 4001, //", + )], + &["hard.ron", "pressure.retreat_ratio_permille", "0..=4000"], + ), + ( + "d-cooldown", + &[( + HARD, + "retreat_cooldown_permille: ", + "retreat_cooldown_permille: 249, //", + )], + &[ + "hard.ron", + "pressure.retreat_cooldown_permille", + "250..=4000", + ], + ), + ( + "d-guard", + &[(EASY, "home_guard_units: ", "home_guard_units: 65, //")], + &["easy.ron", "pressure.home_guard_units", "0..=64"], + ), + ( + "d-lure", + &[(HARD, "lure_ds: ", "lure_ds: 72001, //")], + &["hard.ron", "pressure.lure_ds", "0..=72000"], + ), + ( + "d-cohesion", + &[(HARD, "cohesion_tiles: ", "cohesion_tiles: 65, //")], + &["hard.ron", "pressure.cohesion_tiles", "0..=64"], + ), + ( + "d-launch", + &[( + HARD, + "launch_ratio_permille: ", + "launch_ratio_permille: 10001, //", + )], + &["hard.ron", "pressure.launch_ratio_permille", "0..=10000"], + ), + ( + "d-assault", + &[( + HARD, + "assault_ratio_permille: ", + "assault_ratio_permille: 10001, //", + )], + &["hard.ron", "pressure.assault_ratio_permille", "0..=10000"], + ), + // M6: a trainer of an armed kind before the first Advance. + ( + "armed-trainer", + &[ + ( + BO, + "Build(kind: \"muster_hall\", count: 1, at: NearTown)", + "Build(kind: \"muster_hall\", count: 1, at: NearTown, optional: true)", + ), + ( + BO, + "Build(kind: \"muster_hall\", count: 2, at: NearTown)", + "Build(kind: \"muster_hall\", count: 2, at: NearTown, optional: true)", + ), + ], + &["standard.ron", "steps[19].age", "trainer of an armed kind"], + ), + // M6: difficulty levels. + ( + "d-id", + &[(HARD, "id: \"hard\"", "id: \"harder\"")], + &["hard.ron", "id", "file stem"], + ), + ( + "d-name", + &[(HARD, "name: \"Hard\"", "name: \"\"")], + &["hard.ron", "name", "empty"], + ), + ( + "d-income-id", + &[(HARD, "\"ore\": 10", "\"iron\": 10")], + &["hard.ron", "income.iron", "resource id"], + ), + ( + "d-income-max", + &[(HARD, "\"grain\": 20", "\"grain\": 501")], + &["hard.ron", "income.grain", "0..=500"], + ), + ( + "d-interval-short", + &[(HARD, "income_interval_ds: 200,", "income_interval_ds: 49,")], + &["hard.ron", "income_interval_ds", "50..=72000"], + ), + ( + "d-interval-long", + &[( + HARD, + "income_interval_ds: 200,", + "income_interval_ds: 72001,", + )], + &["hard.ron", "income_interval_ds", "50..=72000"], + ), + ( + "d-from", + &[(HARD, "income_from_ds: 1800,", "income_from_ds: 27000,")], + &["hard.ron", "income_from_ds", "match_cap_ds"], + ), + ( + "d-size-permille", + &[( + HARD, + "wave_size_permille: 1000,", + "wave_size_permille: 249,", + )], + &["hard.ron", "wave_size_permille", "250..=4000"], + ), + ( + "d-interval-permille", + &[( + HARD, + "wave_interval_permille: 800,", + "wave_interval_permille: 4001,", + )], + &["hard.ron", "wave_interval_permille", "250..=4000"], + ), + ( + "d-max-units", + &[( + EASY, + "max_wave_units: Some(14),", + "max_wave_units: Some(65),", + )], + &["easy.ron", "max_wave_units", "1..=64"], + ), + ( + "d-all-in", + &[( + EASY, + "all_in_after_ds: Some(19800),", + "all_in_after_ds: Some(72001),", + )], + &["easy.ron", "all_in_after_ds", "0..=72000"], + ), + ( + "d-age", + &[(EASY, "Age(\"masonry\")", "Age(\"bronze\")")], + &["easy.ron", "wave_age_gate", "age id"], + ), + ( + "d-gate", + &[(HARD, "wave_age_gate: Off,", "wave_age_gate: Never,")], + &["hard.ron", "Never"], + ), + ( + "d-raid-min", + &[(HARD, "min_armed: 6,", "min_armed: 0,")], + &["hard.ron", "first_raid.min_armed", "1..=64"], + ), + ( + "d-raid-max", + &[(HARD, "max_armed: 10)", "max_armed: 5)")], + &["hard.ron", "first_raid.max_armed", "6..=64"], + ), + ( + "d-raid-by", + &[(HARD, "by_ds: 6000,", "by_ds: 27000,")], + &["hard.ron", "first_raid.by_ds", "match_cap_ds"], + ), + ( + "d-raid-prep", + &[(HARD, "prep_ds: 1500,", "prep_ds: 6000,")], + &["hard.ron", "first_raid.prep_ds", "less than by_ds"], + ), + ( + "d-unknown-field", + &[( + HARD, + "largest_wave_only: false,", + "largest_wave_only: false, cheat: true,", + )], + &["hard.ron", "cheat"], + ), ]; for (name, edits, words) in cases { let err = load_edited(name, edits).expect_err(name).to_string(); diff --git a/crates/rules/src/lib.rs b/crates/rules/src/lib.rs index 4024610..52bd3a1 100644 --- a/crates/rules/src/lib.rs +++ b/crates/rules/src/lib.rs @@ -48,7 +48,10 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; pub use ages::{Age, Ages, Unlocks}; -pub use ai::{AiRules, BuildOrder, MixEntry, Personality, Placement, RecallWave, Step, WaveSize}; +pub use ai::{ + AiRules, BuildOrder, Difficulty, FirstRaid, MixEntry, Personality, Placement, Pressure, + RecallWave, Step, WainRule, WaveAgeGate, WaveMode, WaveSize, +}; pub use attack::{Attack, Prefer}; pub use buildings::{BuildingKind, Buildings, GatherSpec, Slots}; pub use factions::{Faction, Factions}; @@ -3377,14 +3380,11 @@ mod tests { msg.contains("rules/ages.ron: ages[2].techs_required: ") && msg.contains("unlock 8"), "{msg}" ); - // The shipped build order researches 6 counting techs before its - // Charter Advance, so a 9-tech gate makes that Advance unreachable - // (M5b's build-order check); this copy ends the order before it. - let charter = ( - "ai/build_orders/standard.ron", - " Advance(age: \"charter\"), // 6 counting techs before it\n Research(tech: \"arms_3\"),\n", - "", - ); + // The shipped build orders research 6 or 7 counting techs before + // their Charter Advance, so a 9-tech gate makes that Advance + // unreachable (M5b's build-order check); these copies drop the + // Advance and the Charter-only steps right after it. + let charter = |order: &'static str, after: &'static str| (order, after, ""); let r = loads_after_edits( "h-gate-9-counting", &[ @@ -3394,7 +3394,22 @@ mod tests { " counts_toward_age: false,", " counts_toward_age: true,", ), - charter, + charter( + "ai/build_orders/standard.ron", + " Advance(age: \"charter\"), // 6 counting techs before it\n Research(tech: \"arms_3\"),\n", + ), + charter( + "ai/build_orders/rush.ron", + " Advance(age: \"charter\"), // 6 counting techs before it\n Research(tech: \"arms_3\"),\n", + ), + charter( + "ai/build_orders/boom.ron", + " Advance(age: \"charter\"),\n Research(tech: \"arms_3\"),\n", + ), + charter( + "ai/build_orders/tower.ron", + " Advance(age: \"charter\"), // 6 counting techs before it\n Research(tech: \"harrying_2\", optional: true),\n Research(tech: \"arms_3\"),\n", + ), ], ); assert_eq!(r.ages[2].techs_required, 9); diff --git a/crates/sim-cli/src/main.rs b/crates/sim-cli/src/main.rs index 5429ec1..49a1bd1 100644 --- a/crates/sim-cli/src/main.rs +++ b/crates/sim-cli/src/main.rs @@ -3,12 +3,15 @@ //! Runs on any OS without a GPU. M0 implemented `selftest`, `data-check` and //! the scripted `hash-dump`; M1 adds `verify`, `bench`, `fuzz`, `record` and //! the replay form of `hash-dump`; M5b adds `play-bots` and builds every -//! replay's bots from its header (`ai::controller_for`). +//! replay's bots from its header (`ai::controller_for`); M6 adds +//! `match-log`, the difficulty ids and the personality pairing of +//! `play-bots`, and `bench --bots --bot/--personality`. //! //! Wall-clock timing (`std::time::Instant`) is allowed here and banned in //! `sim`, `rules` and `ai`: this crate measures the sim, it is not part of it. #![forbid(unsafe_code)] +mod match_log; mod play_bots; mod scenarios; @@ -117,6 +120,12 @@ enum Cmd { /// against a Passive control run. #[arg(long, conflicts_with_all = ["astar", "territory", "combat"])] bots: bool, + /// With --bots (M6): the bot id of both seats (a difficulty id). + #[arg(long, requires = "bots", default_value = ai::BOT_STANDARD)] + bot: String, + /// With --bots (M6): the personality of both seats. + #[arg(long, requires = "bots", default_value = "standard")] + personality: String, /// Border sources placed before the timed edits (--territory). #[arg(long, default_value_t = 16)] sources: u32, @@ -183,6 +192,20 @@ enum Cmd { /// game (M5b): exit 1 when a game ends without a winner (unless /// --allow-undecided) or panics, 2 on usage, rules or bot-id errors. PlayBots(play_bots::PlayBotsArgs), + /// Re-simulate a replay with its header's bots (verifying every + /// recorded hash) and print its docs/PLAYTEST.md match-log row, a + /// statistics table and the verify line (M6). Exit codes as `verify`. + MatchLog { + /// Replay file (.eonreplay). + replay: PathBuf, + /// The Date cell (`just match-log` passes today's date). + #[arg(long, default_value = "")] + date: String, + /// The Commit cell (`just match-log` passes `git rev-parse --short + /// HEAD`; the header's `git_sha` is empty). + #[arg(long, default_value = "")] + commit: String, + }, } fn main() -> ExitCode { @@ -223,8 +246,10 @@ fn main() -> ExitCode { units, ticks, seed, + bot, + personality, .. - } => bench_bots(&cli.data, units, ticks, seed), + } => bench_bots(&cli.data, units, ticks, seed, &bot, &personality), Cmd::Bench { territory: true, sources, @@ -270,6 +295,41 @@ fn main() -> ExitCode { slow_ms, ), Cmd::PlayBots(args) => play_bots::run(&cli.data, &args), + Cmd::MatchLog { + replay, + date, + commit, + } => match_log_cmd(&cli.data, &replay, &date, &commit), + } +} + +/// `sim-cli match-log` (M6): the row, the table and the verify line; exit +/// 0 on OK, verify's codes otherwise. +fn match_log_cmd(data: &Path, replay: &Path, date: &str, commit: &str) -> ExitCode { + warn_if_truncated(replay); + match match_log::collect(replay, data) { + Ok(Ok(log)) => { + let name = replay + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + println!("{}", log.row(date, commit, &name)); + println!(); + for line in log.table() { + println!("{line}"); + } + println!(); + println!("{}", log.outcome); + ExitCode::from(log.outcome.exit_code()) + } + Ok(Err(outcome)) => { + println!("{outcome}"); + ExitCode::from(outcome.exit_code()) + } + Err(e) => { + eprintln!("error: {e}"); + ExitCode::from(EXIT_ERROR) + } } } @@ -357,7 +417,7 @@ fn data_check(dir: &Path, assets: Option<&Path>) -> ExitCode { match loaded { Ok((r, models)) => { println!( - "OK rules_version={} rules_hash=0x{:016x} resources={} units={} buildings={} ages={} techs={} side_techs={} factions={} personalities={} build_orders={} maps={} strings=en visuals=units:{} buildings:{} terrain:{} models={models}{}", + "OK rules_version={} rules_hash=0x{:016x} resources={} units={} buildings={} ages={} techs={} side_techs={} factions={} personalities={} build_orders={} difficulties={} maps={} strings=en visuals=units:{} buildings:{} terrain:{} models={models}{}", r.rules_version, r.rules_hash(), r.resources.resources.len(), @@ -369,6 +429,7 @@ fn data_check(dir: &Path, assets: Option<&Path>) -> ExitCode { r.factions.len(), r.ai.personalities.len(), r.ai.build_orders.len(), + r.ai.difficulties.len(), r.maps.keys().cloned().collect::>().join(","), r.visuals.units.len(), r.visuals.buildings.len(), @@ -1294,14 +1355,16 @@ struct ThinkCall { ns: u64, /// Commands returned. commands: usize, + /// Whether the plan built the influence maps (M6). + influence: bool, } -/// A controller that times every `think` of the controller it wraps (the -/// header's bots) and records each call (wall-clock timing is allowed in -/// sim-cli only). +/// A controller that times every `think` of the header's Standard bots +/// (built exactly as `ai::controller_for` builds them) and records each +/// call with the plan's stats (wall-clock timing is allowed in sim-cli +/// only). struct ThinkTimer { - inner: Box, - every: u32, + bots: std::collections::BTreeMap, calls: std::sync::Arc>>, } @@ -1312,18 +1375,21 @@ impl sim::AiController for ThinkTimer { view: &SimView<'_>, memory: &mut sim::AiMemory, ) -> Vec { + let Some(bot) = self.bots.get(&p) else { + return Vec::new(); + }; let start = Instant::now(); - let out = self.inner.think(p, view, memory); + let (out, stats) = bot.think_with_stats(p, view, memory); let ns = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX); - let plan = view.tick % self.every == ai::standard::phase(p, self.every, view.setup()); self.calls .lock() .expect("the bench's call log") .push(ThinkCall { tick: view.tick, - plan, + plan: stats.is_some(), ns, commands: out.len(), + influence: stats.is_some_and(|s| s.influence), }); out } @@ -1333,15 +1399,30 @@ impl sim::AiController for ThinkTimer { const BOTS_MIN_PLANS: usize = 200; /// ... or launched fewer waves than this (both seats together) ... const BOTS_MIN_WAVES: u32 = 2; -/// ... or neither seat defended (a defence released or running). +/// ... or neither seat defended (a defence released or running) ... const BOTS_MIN_DEFENDS: u32 = 1; +/// ... or (M6), once any wave went out, fewer plans than this built the +/// influence maps (a siege target's score reads them at once, and every +/// wave's retreat checks read them once it is past `retreat_commit_ds` of +/// its assault order or raid launch, so the new path must run). The maps +/// are built only on plans that read them: 32 of the 240 plans of the +/// default bench at `rules_version` 28. The plan asked for 200, which no +/// bench can reach. Hard rush's two raids leave in the last 20 s of a +/// 1200-tick bench, still inside their commit window, so that bench builds +/// none and fails this gate: run it at `--ticks 1800`, where the raids' +/// retreat checks build the maps (49 plans). +const BOTS_MIN_INFLUENCE_PLANS: usize = 20; /// What `bench --bots` measured. +#[derive(Default)] struct BotsBench { /// Milliseconds inside the bots' `think` per tick (both seats). ai_ms: Vec, /// Plans (on-cadence calls) made. plans: usize, + /// Plans that built the influence maps (M6: retreat checks, siege and + /// raid targets). + influence_plans: usize, /// The slowest single plan, in milliseconds. plan_ms_max: f64, /// Commands the bots returned. @@ -1356,6 +1437,10 @@ struct BotsBench { defends: u32, /// Seats defending at the end. engaged: u32, + /// Raids launched, both seats (M6). + raids: u32, + /// Waves called home by the retreat rule, both seats (M6). + retreats: u32, /// Commands rejected, both seats. rejected: u32, /// The most outstanding path requests after any step. @@ -1364,27 +1449,47 @@ struct BotsBench { final_hash: u64, } -/// Run [`scenarios::bots_bench`] for `ticks` ticks with the Standard bots, -/// timing every `think` and every step, then the same stream with Passive -/// seats for the control step time. -fn run_bots_bench(rules: &Rules, units: u16, ticks: u32, seed: u64) -> BotsBench { - let (setup, stream) = scenarios::bots_bench(rules, units, seed, true); - let every = rules +/// Run [`scenarios::bots_bench`] for `ticks` ticks with the Standard bots +/// at `bot` (a difficulty id) with `personality` in both seats, timing +/// every `think` and every step, then the same stream with Passive seats +/// for the control step time. +fn run_bots_bench( + rules: &Rules, + units: u16, + ticks: u32, + seed: u64, + bot: &str, + personality: &str, +) -> BotsBench { + let (setup, stream) = scenarios::bots_bench(rules, units, seed, Some((bot, personality))); + let knobs = rules + .ai + .personality(personality) + .expect("bench_bots checked the personality"); + let level = rules .ai - .personality("standard") - .expect("the standard personality") - .think_every_ticks(rules) - .max(1); + .difficulty(bot) + .expect("bench_bots checked the bot"); + let bots = setup + .ai_seats + .iter() + .map(|seat| { + ( + seat.player, + ai::standard::StandardBot::new(rules, &setup, seat.player, knobs, level), + ) + }) + .collect(); let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let controller = Box::new(ThinkTimer { - inner: ai::controller_for(&setup, rules).expect("the bench's bots are known"), - every, + bots, calls: calls.clone(), }); let mut sim = Sim::new(setup, rules.clone(), controller); let mut b = BotsBench { ai_ms: Vec::new(), plans: 0, + influence_plans: 0, plan_ms_max: 0.0, commands: 0, step_ms: Vec::with_capacity(ticks as usize), @@ -1392,6 +1497,8 @@ fn run_bots_bench(rules: &Rules, units: u16, ticks: u32, seed: u64) -> BotsBench waves_launched: 0, defends: 0, engaged: 0, + raids: 0, + retreats: 0, rejected: 0, path_queue_max: 0, final_hash: 0, @@ -1417,6 +1524,7 @@ fn run_bots_bench(rules: &Rules, units: u16, ticks: u32, seed: u64) -> BotsBench b.commands += c.commands; if c.plan { b.plans += 1; + b.influence_plans += usize::from(c.influence); b.plan_ms_max = b.plan_ms_max.max(c.ns as f64 / 1e6); } } @@ -1427,9 +1535,11 @@ fn run_bots_bench(rules: &Rules, units: u16, ticks: u32, seed: u64) -> BotsBench b.waves_launched += u32::from(s.waves_launched); b.defends += u32::from(s.defends); b.engaged += u32::from(s.defending); + b.raids += u32::from(s.raids); + b.retreats += u32::from(s.retreats); } b.final_hash = sim.hash(); - let (setup, stream) = scenarios::bots_bench(rules, units, seed, false); + let (setup, stream) = scenarios::bots_bench(rules, units, seed, None); let mut control = scenarios::build(&setup, rules); for t in 0..ticks { let cmds = sim::scenarios::commands_at(&stream, t); @@ -1443,21 +1553,37 @@ fn run_bots_bench(rules: &Rules, units: u16, ticks: u32, seed: u64) -> BotsBench } /// The `bench --bots` report, one line per entry. -fn bots_bench_report(b: &BotsBench, units: u16, ticks: u32, seed: u64) -> Vec { +fn bots_bench_report( + b: &BotsBench, + units: u16, + ticks: u32, + seed: u64, + bot: &str, + personality: &str, +) -> Vec { let (ai_mean, ai_p95, ai_max) = stats_ms(&b.ai_ms); let (mean, p95, max) = stats_ms(&b.step_ms); let (control, _, _) = stats_ms(&b.control_step_ms); vec![ - format!("bench bots units={units} ticks={ticks} seed={seed}"), format!( - "ai_ms mean={ai_mean:.4} p95={ai_p95:.4} max={ai_max:.4} plans={} plan_ms_max={:.4} commands={}", - b.plans, b.plan_ms_max, b.commands + "bench bots units={units} ticks={ticks} seed={seed} bot={bot} personality={personality}" + ), + format!( + "ai_ms mean={ai_mean:.4} p95={ai_p95:.4} max={ai_max:.4} plans={} plan_ms_max={:.4} commands={} influence_plans={}", + b.plans, b.plan_ms_max, b.commands, b.influence_plans ), format!("step_ms mean={mean:.3} p95={p95:.3} max={max:.3}"), format!("control_step_ms mean={control:.3}"), format!( - "waves_launched={} defends={} engaged={} rejected={} path_queue_max={} final_hash=0x{:016x}", - b.waves_launched, b.defends, b.engaged, b.rejected, b.path_queue_max, b.final_hash + "waves_launched={} defends={} engaged={} raids={} retreats={} rejected={} path_queue_max={} final_hash=0x{:016x}", + b.waves_launched, + b.defends, + b.engaged, + b.raids, + b.retreats, + b.rejected, + b.path_queue_max, + b.final_hash ), ] } @@ -1467,31 +1593,91 @@ fn bots_bench_report(b: &BotsBench, units: u16, ticks: u32, seed: u64) -> Vec ExitCode { +/// [`BOTS_MIN_PLANS`] plans, [`BOTS_MIN_WAVES`] waves, +/// [`BOTS_MIN_DEFENDS`] defences or, M6, once any wave went out, +/// [`BOTS_MIN_INFLUENCE_PLANS`] plans that built the influence maps), never +/// on the timing budget, like the other benches. Since M6 it zeroes the +/// personalities' `noise_first_wave_ds`, so the first waves go out within +/// the bench's minute as in M5b, and (M6 owner decision 12) sets a level's +/// nonzero launch and assault strength gates to 1 per mille, so they are +/// checked (and timed) but never hold a wave in the mirrored world, and +/// gives a level that never retreats the personality's retreat, so the +/// influence-map retreat checks are timed. +fn bench_bots( + data: &Path, + units: u16, + ticks: u32, + seed: u64, + bot: &str, + personality: &str, +) -> ExitCode { let rules = match load_rules(data) { Ok(r) => r, Err(code) => return code, }; - let b = run_bots_bench(&rules, units, ticks, seed); - for line in bots_bench_report(&b, units, ticks, seed) { + let seats = MatchSetup::bots(&rules, seed, (bot, personality), (bot, personality)); + if bot == ai::BOT_PASSIVE { + eprintln!("bench bots: --bot must be a difficulty id, not passive"); + return ExitCode::from(EXIT_ERROR); + } + if let Err(e) = ai::controller_for(&seats, &rules) { + eprintln!("bench bots: {e}"); + return ExitCode::from(EXIT_ERROR); + } + // M6: the first wave waits up to `noise_first_wave_ds` more once it is + // ready, which pushes the waves of this one-minute bench past its end; + // the bench zeroes that one knob so its waves go out on M5b's timeline + // and every other behaviour runs as shipped. + let mut rules = rules; + for p in &mut rules.ai.personalities { + p.noise_first_wave_ds = 0; + } + // M6 owner decision 12: a level's launch and assault strength gates + // compare a wave with the enemy army at home, and in this mirrored + // world of 200 units a side no wave ever outweighs it, so no wave would + // go out. The bench keeps each gate on but at 1 per mille, so every + // check still runs (its cost is measured) and always passes; and a + // level that never retreats (`retreat_ratio_permille` 0) gets the + // personality's retreat back, so the influence-map retreat checks the + // bench exists to time still run. + for d in rules.ai.difficulties.values_mut() { + if d.pressure.retreat_ratio_permille == 0 { + d.pressure.retreat_ratio_permille = 1000; + } + if d.pressure.launch_ratio_permille > 0 { + d.pressure.launch_ratio_permille = 1; + } + if d.pressure.assault_ratio_permille > 0 { + d.pressure.assault_ratio_permille = 1; + } + } + let b = run_bots_bench(&rules, units, ticks, seed, bot, personality); + for line in bots_bench_report(&b, units, ticks, seed, bot, personality) { println!("{line}"); } + if let Err(e) = bots_exercised(&b) { + eprintln!("bench bots: {e}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +/// The `bench --bots` exit gate: whether the bots were exercised. +fn bots_exercised(b: &BotsBench) -> Result<(), String> { if b.plans < BOTS_MIN_PLANS || b.waves_launched < BOTS_MIN_WAVES || b.defends + b.engaged < BOTS_MIN_DEFENDS + || (b.waves_launched > 0 && b.influence_plans < BOTS_MIN_INFLUENCE_PLANS) { - eprintln!( - "bench bots: plans={} waves_launched={} defends+engaged={}: the bots were not exercised (need {BOTS_MIN_PLANS} plans, {BOTS_MIN_WAVES} waves, {BOTS_MIN_DEFENDS} defence)", + return Err(format!( + "plans={} waves_launched={} defends+engaged={} influence_plans={}: the bots were not exercised (need {BOTS_MIN_PLANS} plans, {BOTS_MIN_WAVES} waves, {BOTS_MIN_DEFENDS} defence, {BOTS_MIN_INFLUENCE_PLANS} influence plans once a wave went out)", b.plans, b.waves_launched, - b.defends + b.engaged - ); - return ExitCode::FAILURE; + b.defends + b.engaged, + b.influence_plans + )); } - ExitCode::SUCCESS + Ok(()) } /// What one fuzz case observed. @@ -1593,6 +1779,33 @@ mod tests { dir.join(name) } + /// The `bench --bots` gate (M6): once any wave went out, raid or + /// siege, the influence maps must have been built on at least + /// [`BOTS_MIN_INFLUENCE_PLANS`] plans (a raid's retreat checks read + /// them past its commit window, a siege target's score at once), so a + /// raids-only bench that never ran the influence path fails. + #[test] + fn bots_gate_needs_the_influence_path_once_any_wave_went_out() { + let bench = |waves: u32, raids: u32, influence_plans: usize| BotsBench { + plans: BOTS_MIN_PLANS, + waves_launched: waves, + raids, + defends: 1, + influence_plans, + ..BotsBench::default() + }; + let enough = BOTS_MIN_INFLUENCE_PLANS; + assert_eq!(bots_exercised(&bench(2, 0, enough)), Ok(()), "sieges"); + assert_eq!(bots_exercised(&bench(2, 2, enough)), Ok(()), "raids"); + let err = bots_exercised(&bench(2, 2, 0)).unwrap_err(); + assert!(err.contains("influence_plans=0"), "raids, no maps: {err}"); + assert!( + bots_exercised(&bench(2, 0, enough - 1)).is_err(), + "sieges, too few" + ); + assert!(bots_exercised(&bench(0, 0, enough)).is_err(), "no waves"); + } + #[test] fn scripted_run_is_repeatable() { let a = run_scripted(&data(), 200, 42).unwrap(); @@ -2133,6 +2346,46 @@ mod tests { for other in ["--astar", "--territory", "--combat"] { assert!(Cli::try_parse_from(["sim-cli", "bench", "--bots", other]).is_err()); } + // M6: --bot and --personality default to M5b's standard/standard + // and need --bots. + let cli = Cli::try_parse_from([ + "sim-cli", + "bench", + "--bots", + "--bot", + "hard", + "--personality", + "rush", + ]) + .unwrap(); + match cli.cmd { + Cmd::Bench { + bot, personality, .. + } => assert_eq!((bot.as_str(), personality.as_str()), ("hard", "rush")), + _ => panic!("bench"), + } + let cli = Cli::try_parse_from(["sim-cli", "bench", "--bots"]).unwrap(); + match cli.cmd { + Cmd::Bench { + bot, personality, .. + } => assert_eq!( + (bot.as_str(), personality.as_str()), + ("standard", "standard") + ), + _ => panic!("bench"), + } + assert!(Cli::try_parse_from(["sim-cli", "bench", "--bot", "hard"]).is_err()); + let cli = Cli::try_parse_from([ + "sim-cli", + "match-log", + "x.eonreplay", + "--date", + "2026-10-28", + "--commit", + "abc", + ]) + .unwrap(); + assert!(matches!(cli.cmd, Cmd::MatchLog { .. })); } /// `bench --bots` at its acceptance size for 60 ticks: 200 units a @@ -2144,7 +2397,7 @@ mod tests { #[test] fn bench_bots_reports_ai_time_and_exercises_waves() { let rules = load_rules(&data()).unwrap(); - let (setup, stream) = scenarios::bots_bench(&rules, 400, 1, true); + let (setup, stream) = scenarios::bots_bench(&rules, 400, 1, Some(("standard", "standard"))); let mut sim = scenarios::build(&setup, &rules); sim.step(sim::scenarios::commands_at(&stream, 0)); for _ in 0..setup.cmd_delay { @@ -2154,16 +2407,19 @@ mod tests { for p in [PlayerId(0), PlayerId(1)] { assert_eq!(view.units().filter(|u| u.owner == p).count(), 200, "{p:?}"); } - let b = run_bots_bench(&rules, 400, 60, 1); + let b = run_bots_bench(&rules, 400, 60, 1, "standard", "standard"); assert_eq!((b.step_ms.len(), b.control_step_ms.len()), (60, 60)); assert_eq!(b.ai_ms.len(), 60); assert_eq!(b.plans, 12, "every 10 ticks, two seats"); assert!(b.commands > 0); assert_eq!((b.waves_launched, b.engaged), (0, 2)); - assert_eq!(run_bots_bench(&rules, 400, 60, 1).final_hash, b.final_hash); - let report = bots_bench_report(&b, 400, 60, 1); + assert_eq!( + run_bots_bench(&rules, 400, 60, 1, "standard", "standard").final_hash, + b.final_hash + ); + let report = bots_bench_report(&b, 400, 60, 1, "standard", "standard"); let prefixes = [ - "bench bots units=400 ticks=60 seed=1", + "bench bots units=400 ticks=60 seed=1 bot=standard personality=standard", "ai_ms mean=", "step_ms mean=", "control_step_ms mean=", @@ -2175,7 +2431,19 @@ mod tests { } assert!(report[1].contains(" plans=12 ") && report[1].contains(" plan_ms_max=")); assert!(report[4].contains(" path_queue_max=") && report[4].contains(" final_hash=0x")); - assert_eq!(bench_bots(&data(), 400, 60, 1), ExitCode::FAILURE); + assert!(report[4].contains(" raids=0 retreats=0 ")); + assert_eq!( + bench_bots(&data(), 400, 60, 1, "standard", "standard"), + ExitCode::FAILURE + ); + // M6: an unknown bot or personality, or passive, is a usage error. + for (bot, personality) in [("nightmare", "standard"), ("hard", "zerg"), ("passive", "")] { + assert_eq!( + bench_bots(&data(), 400, 60, 1, bot, personality), + ExitCode::from(EXIT_ERROR), + "{bot} {personality}" + ); + } } #[test] diff --git a/crates/sim-cli/src/match_log.rs b/crates/sim-cli/src/match_log.rs new file mode 100644 index 0000000..9a8f425 --- /dev/null +++ b/crates/sim-cli/src/match_log.rs @@ -0,0 +1,485 @@ +//! `sim-cli match-log ` (M6): re-simulate a replay with the bots +//! its header names (exactly as `verify` does, every recorded hash +//! checked), feed `sim::MatchStats` after every step, and print the +//! `docs/PLAYTEST.md` match-log row, a statistics table and the verify +//! line. A logged match can so be re-derived from its replay at any time. +//! +//! "You" is the first human slot of the header (P0 when every slot is a +//! bot, as in `m5_first_match`); the opponent is the other player. The +//! header carries no date and an empty `git_sha`, so `--date` and +//! `--commit` fill those cells (`just match-log` passes today's date and +//! `git rev-parse --short HEAD`). + +use ai::standard::BotStats; +use sim::{ + MatchResult, MatchSetup, MatchStats, Outcome, PlayerId, ReplayError, Rules, SimEvent, SimView, + VerifyOutcome, +}; +use std::path::Path; + +/// What one re-simulation of a replay gathered. +pub struct MatchLog { + /// The replay header. + pub setup: MatchSetup, + /// The rules it ran under. + pub rules: Rules, + /// The statistics, fed after every step. + pub stats: MatchStats, + /// Each player's bot statistics at the end (`None` for a human slot + /// or a Passive seat). + pub bots: Vec>, + /// The match result at the end, if decided. + pub result: Option, + /// The last tick re-simulated. + pub last_tick: u32, + /// The verify outcome. + pub outcome: VerifyOutcome, +} + +/// Re-simulate `replay` against `data` through `sim::verify_observed`. +/// `Ok(Err(outcome))` is a version or rules mismatch, where nothing was +/// simulated. +pub fn collect(replay: &Path, data: &Path) -> Result, ReplayError> { + let mut stats: Option = None; + let mut first: Option<(MatchSetup, Rules)> = None; + let mut bots: Vec> = Vec::new(); + let mut result = None; + let mut last_tick = 0; + let mut observe = |view: &SimView<'_>, events: &[SimEvent]| { + if first.is_none() { + first = Some((view.setup().clone(), view.rules().clone())); + } + stats + .get_or_insert_with(|| MatchStats::new(view.setup(), view.rules())) + .observe(view, events); + bots = view + .players() + .iter() + .map(|p| ai::bot_stats(view, p.id)) + .collect(); + result = view.result(); + last_tick = view.tick; + }; + let outcome = sim::verify_observed( + replay, + data, + Box::new(|setup: &MatchSetup, rules: &Rules| { + ai::controller_for(setup, rules).map_err(|e| e.to_string()) + }), + &mut observe, + )?; + let (Some((setup, rules)), Some(stats)) = (first, stats) else { + return Ok(Err(outcome)); + }; + Ok(Ok(MatchLog { + setup, + rules, + stats, + bots, + result, + last_tick, + outcome, + })) +} + +/// `m:ss` of match time at `tick`. +pub fn clock(tick: u32, rate_hz: u32) -> String { + let secs = tick / rate_hz.max(1); + format!("{}:{:02}", secs / 60, secs % 60) +} + +/// Tenths, as `"27.4"`, of `num / den`, rounded down. +fn tenths(num: u64, den: u64) -> String { + let t = num * 10 / den.max(1); + format!("{}.{}", t / 10, t % 10) +} + +impl MatchLog { + /// "You": the first human slot, else P0. + pub fn me(&self) -> PlayerId { + self.setup + .players + .iter() + .find(|s| !s.is_ai) + .map_or(PlayerId(0), |s| s.id) + } + + /// The opponent: the first other player. + pub fn opponent(&self) -> PlayerId { + let me = self.me(); + self.setup + .players + .iter() + .map(|s| s.id) + .find(|p| *p != me) + .unwrap_or(PlayerId(1)) + } + + fn rate(&self) -> u32 { + self.rules.tick_rate_hz + } + + /// The opponent's `(bot, personality)` from the header (`human` for a + /// human slot). + fn opponent_seat(&self) -> (String, String) { + let them = self.opponent(); + self.setup + .ai_seats + .iter() + .find(|s| s.player == them) + .map_or_else( + || ("human".to_string(), String::new()), + |s| (s.bot.clone(), s.personality.clone()), + ) + } + + /// The difficulty's display name, else the bot id. + fn difficulty_name(&self, bot: &str) -> String { + self.rules + .ai + .difficulty(bot) + .map_or_else(|| bot.to_string(), |d| d.name.clone()) + } + + /// The deciding tick, else the last tick re-simulated. + pub fn end_tick(&self) -> u32 { + self.result.map_or(self.last_tick, |r| r.tick) + } + + /// The outcome cell from "your" side: `win`, `loss` or `draw`, then + /// `decisive` or `tiebreak` (`undecided` before an outcome). + pub fn outcome_cell(&self) -> String { + let Some(r) = self.result else { + return "undecided".to_string(); + }; + match r.outcome { + Outcome::Draw => "draw tiebreak".to_string(), + Outcome::Victory { player, decisive } => format!( + "{} {}", + if player == self.me() { "win" } else { "loss" }, + if decisive { "decisive" } else { "tiebreak" } + ), + } + } + + /// The `docs/PLAYTEST.md` match-log row (M6): `| Date | Commit | Seed | + /// Difficulty | Style | rules_version | Duration (min) | Outcome | AI + /// reached my borders | Town below 50% HP | Cap readout amber (min) | + /// Fun (1-5) | Notes | Replay | Verify |`, Fun and Notes left for the + /// owner. + pub fn row(&self, date: &str, commit: &str, replay_name: &str) -> String { + let rate = self.rate(); + let me = self.stats.player(self.me()); + let them = self.stats.player(self.opponent()); + let (bot, style) = self.opponent_seat(); + let duration = tenths(u64::from(self.end_tick()), u64::from(rate) * 60); + let borders = them + .first_on_land_tick + .map_or_else(|| "n".to_string(), |t| format!("y {}", clock(t, rate))); + let town = if me.towns_lost > 0 && me.min_own_town_hp_permille == 0 { + "y annexed".to_string() + } else { + let pct = me.min_own_town_hp_permille / 10; + let below = me.min_own_town_hp_permille < 500; + format!("{} {pct}%", if below { "y" } else { "n" }) + }; + let amber = if me.cap_seconds > 0 { + format!("y {}", tenths(u64::from(me.cap_seconds), 60)) + } else { + "n".to_string() + }; + let verify = match &self.outcome { + VerifyOutcome::Ok { final_hash, .. } => format!("OK 0x{final_hash:016x}"), + other => other.to_string(), + }; + format!( + "| {date} | {commit} | {} | {} | {} | {} | {duration} | {} | {borders} | {town} | {amber} | | | {replay_name} | {verify} |", + self.setup.seed, + self.difficulty_name(&bot), + if style.is_empty() { "-" } else { &style }, + self.setup.rules_version, + self.outcome_cell(), + ) + } + + /// One player's column of the statistics table. + fn column(&self, p: PlayerId) -> Vec { + let rate = self.rate(); + let s = self.stats.player(p); + let kinds = |m: &std::collections::BTreeMap| { + let total: u32 = m.values().sum(); + let by: Vec = m + .iter() + .map(|(k, n)| { + let id = self + .rules + .unit_kind(k.0) + .map_or_else(|| format!("kind {}", k.0), |u| u.id.clone()); + format!("{id} {n}") + }) + .collect(); + if by.is_empty() { + total.to_string() + } else { + format!("{total} ({})", by.join(", ")) + } + }; + let ages: Vec = s + .ages + .iter() + .map(|(age, t)| { + let id = self + .rules + .ages + .get(usize::from(*age)) + .map_or("?", |a| a.id.as_str()); + format!("{id} {}", clock(*t, rate)) + }) + .collect(); + let bot = self + .bots + .get(usize::from(p.0)) + .copied() + .flatten() + .map_or_else( + || "-".to_string(), + |b| { + format!( + "{} / {} / {} / {}", + b.waves_launched, b.raids, b.retreats, b.wiped + ) + }, + ); + let town = if s.towns_lost > 0 && s.min_own_town_hp_permille == 0 { + "annexed".to_string() + } else { + format!("{}%", s.min_own_town_hp_permille / 10) + }; + vec![ + kinds(&s.units_trained), + kinds(&s.units_lost), + format!("{} / {}", s.towns_taken, s.towns_lost), + if ages.is_empty() { + "-".to_string() + } else { + ages.join(", ") + }, + s.peak_army.to_string(), + self.stats.income_total(p).to_string(), + format!( + "{} ({}%)", + s.bonus_total(), + self.stats.bonus_share_permille(p) / 10 + ), + clock(s.cap_seconds * rate, rate), + s.first_on_land_tick + .map_or_else(|| "-".to_string(), |t| clock(t, rate)), + town, + bot, + ] + } + + /// The statistics table: one row per statistic, "you" then the + /// opponent, then the whole-match facts. + pub fn table(&self) -> Vec { + let rate = self.rate(); + let (me, them) = (self.me(), self.opponent()); + let (bot, style) = self.opponent_seat(); + let names = [ + "Units trained", + "Units lost", + "Towns taken / lost", + "Ages", + "Peak army", + "Income", + "Bonus income (share)", + "Cap amber", + "First on enemy land", + "Lowest own Town", + "Waves / raids / retreats / wiped", + ]; + let mut out = vec![ + format!( + "| Stat | You (P{}) | {} {} (P{}) |", + me.0, + self.difficulty_name(&bot), + style, + them.0 + ), + "|---|---|---|".to_string(), + ]; + for ((name, a), b) in names.iter().zip(self.column(me)).zip(self.column(them)) { + out.push(format!("| {name} | {a} | {b} |")); + } + let opt = |t: Option| t.map_or_else(|| "-".to_string(), |t| clock(t, rate)); + out.push(format!( + "| First contact | {} | |", + opt(self.stats.first_contact_tick()) + )); + out.push(format!( + "| Longest quiet | {} | |", + opt(self.stats.max_quiet_ticks()) + )); + out.push(format!( + "| Duration | {} (tick {}) | |", + clock(self.end_tick(), rate), + self.end_tick() + )); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ai::harness::BotMatch; + use std::path::PathBuf; + + fn data() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data") + } + + #[test] + fn clock_and_tenths() { + assert_eq!(clock(0, 20), "0:00"); + assert_eq!(clock(14_040, 20), "11:42"); + assert_eq!(clock(54_000, 20), "45:00"); + assert_eq!(tenths(32_880, 1200), "27.4"); + assert_eq!(tenths(390, 60), "6.5"); + } + + /// `match-log` re-derives exactly the statistics the play-bots + /// harness observed for the same match (4400 ticks of Hard against + /// Easy, written as a replay with no commands and one final hash), and + /// verifies the replay. + #[test] + fn match_log_stats_equal_the_harness() { + let rules = Rules::load(data()).unwrap(); + let setup = MatchSetup::bots(&rules, 3, ("hard", "standard"), ("easy", "standard")); + let mut m = BotMatch::with_bots(setup.clone(), rules.clone()).unwrap(); + m.run(4400); + let dir = std::env::temp_dir().join(format!("eonmark-match-log-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hard_easy.eonreplay"); + let mut w = sim::ReplayWriter::create(&path, &setup).unwrap(); + w.hash(m.sim.tick(), m.sim.hash(), &m.sim.sub_hashes()) + .unwrap(); + w.finish().unwrap(); + let log = collect(&path, &data()).unwrap().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!( + log.outcome, + VerifyOutcome::Ok { + final_hash: m.sim.hash(), + ticks: 4400 + } + ); + assert_eq!(&log.stats, m.observer.stats()); + assert!(log.stats.player(PlayerId(0)).bonus_total() > 0); + assert_eq!(log.me(), PlayerId(0), "no human slot: P0"); + let row = log.row("2026-10-28", "abc1234", "hard_easy.eonreplay"); + assert!( + row.starts_with("| 2026-10-28 | abc1234 | 3 | Easy | standard | "), + "{row}" + ); + assert!(row.contains(" | 3.6 | undecided | n | n 100% | "), "{row}"); + assert!( + row.ends_with(&format!( + " | | | hard_easy.eonreplay | OK 0x{:016x} |", + m.sim.hash() + )), + "{row}" + ); + assert_eq!(row.matches('|').count(), 16); + let table = log.table(); + assert!(table[0].starts_with("| Stat | You (P0) | Easy standard (P1) |")); + assert_eq!(table.len(), 2 + 11 + 3); + } + + /// The replay of a human-against-bot match carries the human's + /// commands as `Tick` records, and verify steps those ticks through its + /// own branch. `match-log` must observe those steps too: the statistics + /// it re-derives equal a `MatchStats` fed after every step of the + /// recorded run, past first contact and the human's first losses, with + /// commands on ticks whose next step is a 20-tick sample. + #[test] + fn match_log_stats_equal_the_recorded_run_with_human_commands() { + let rules = Rules::load(data()).unwrap(); + let setup = MatchSetup::vs_bot(&rules, 5, "hard", "standard"); + let (worker, _) = rules::ai::worker_kind(&rules).unwrap(); + let mut sim = sim::Sim::new( + setup.clone(), + rules.clone(), + ai::controller_for(&setup, &rules).unwrap(), + ); + let p0 = PlayerId(0); + let seat = sim::BuildingId(1); + let enemy_seat = { + let view = sim.view(); + let tile = view + .buildings() + .find(|b| b.owner == PlayerId(1)) + .map(|b| b.tile) + .unwrap(); + view.map().center_of(tile) + }; + let dir = std::env::temp_dir().join(format!("eonmark-match-log-h-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("human_hard.eonreplay"); + let mut w = sim::ReplayWriter::create(&path, &setup).unwrap(); + let mut stats = MatchStats::new(&setup, &rules); + stats.observe(&sim.view(), &[]); + let mut seq = 0u32; + let mut command_ticks = Vec::new(); + for _ in 0..4000 { + let t = sim.tick(); + let mut cmds = Vec::new(); + if t == 300 { + // Two Yeomen walk to the enemy Seat: its shots are first + // contact, and they die. + cmds.push(sim::Command::Move { + units: vec![sim::UnitId(4), sim::UnitId(5)], + target: enemy_seat, + queue: false, + }); + } + if t % 1000 == 19 || t == 0 { + cmds.push(sim::Command::Train { + building: seat, + kind: sim::UnitKindId(worker), + }); + } + let cmds: Vec = cmds + .into_iter() + .map(|c| { + seq += 1; + sim::PlayerCommand::new(p0, seq, c) + }) + .collect(); + if !cmds.is_empty() { + command_ticks.push(t); + } + w.tick(t, &cmds).unwrap(); + sim.step(&cmds); + let events = sim.drain_events(); + stats.observe(&sim.view(), &events); + } + w.hash(sim.tick(), sim.hash(), &sim.sub_hashes()).unwrap(); + w.finish().unwrap(); + let log = collect(&path, &data()).unwrap().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!( + log.outcome, + VerifyOutcome::Ok { + final_hash: sim.hash(), + ticks: 4000 + } + ); + assert!(command_ticks.iter().filter(|t| *t % 20 == 19).count() >= 3); + assert!(stats.first_contact_tick().is_some(), "contact"); + assert!(stats.player(p0).units_lost_total() > 0, "the Yeomen fell"); + assert!(stats.player(p0).units_trained_total() > 0); + assert_eq!(log.me(), p0); + assert_eq!(log.stats, stats); + } +} diff --git a/crates/sim-cli/src/play_bots.rs b/crates/sim-cli/src/play_bots.rs index b187331..429d12f 100644 --- a/crates/sim-cli/src/play_bots.rs +++ b/crates/sim-cli/src/play_bots.rs @@ -7,8 +7,13 @@ //! games go on), and is stepped by `ai::harness::BotMatch` until the match //! is decided or `--max-ticks`. The CSV holds no wall time, so two runs of //! the same seeds print the same bytes; timings go to stderr. +//! +//! M6: `--a`/`--b` take `passive` or any difficulty id; when both +//! personality lists have more than one entry the games walk the ordered +//! distinct pairs (`--mirrors` adds the same-id pairs), and `random` +//! resolves per seed and seat ([`ai::resolve_personality`]). -use ai::harness::{BotMatch, CSV_HEADER, GameRow, Winner, exit_code}; +use ai::harness::{BotMatch, CSV_HEADER, GameRow, Winner, exit_code, seats_of}; use clap::Args; use rules::Rules; use sim::{AiController, AiMemory, Command, MatchSetup, Outcome, PlayerId, SimView}; @@ -69,19 +74,26 @@ pub struct PlayBotsArgs { /// Seeds to play: `A..B` (inclusive) or a single `N`. #[arg(long, value_parser = parse_seeds)] pub seeds: Seeds, - /// Bot of player A (`PlayerId(0)`): `passive` or `standard`. + /// Bot of player A (`PlayerId(0)`): `passive` or a difficulty id + /// (`easy`, `standard`, `hard`). #[arg(long, default_value = ai::BOT_STANDARD)] pub a: String, - /// Bot of player B (`PlayerId(1)`): `passive` or `standard`. + /// Bot of player B (`PlayerId(1)`), as for A. #[arg(long, default_value = ai::BOT_STANDARD)] pub b: String, - /// Personalities of A, comma-separated, cycled over the seeds in order - /// (default `standard`; none for `passive`). + /// Personalities of A, comma-separated (default `standard`; none for + /// `passive`; `random` draws per seed). A single list cycles over the + /// seeds; when both lists have several entries the games walk the + /// ordered distinct pairs. #[arg(long, value_delimiter = ',')] pub personality_a: Vec, /// Personalities of B, as for A. #[arg(long, value_delimiter = ',')] pub personality_b: Vec, + /// With two lists of several entries, also play the same-id pairs + /// (mirrors). + #[arg(long)] + pub mirrors: bool, /// Stop a game here if it is still undecided (default: the 45-minute /// cap, which decides every game; a game stopped earlier is `none`). #[arg(long)] @@ -212,7 +224,10 @@ pub fn play_all( let played = catch_unwind(AssertUnwindSafe(|| play(setup, rules, max_ticks, factory))) .unwrap_or_else(|payload| Played { - row: GameRow::panicked(setup.seed), + row: GameRow { + seats: seats_of(setup), + ..GameRow::panicked(setup.seed) + }, ticks: 0, wall_s: 0.0, step_ms_mean: 0.0, @@ -283,21 +298,72 @@ fn personalities(side: &str, bot: &str, given: &[String]) -> Result, Ok(given.to_vec()) } +/// The `(a, b)` personality pairs a run walks, repeated over the seeds in +/// order (M6). When both lists have more than one entry: the ordered +/// pairs with B outer and A inner, in list order, skipping same-id pairs +/// unless `mirrors` (so `rush,boom,tower` on both sides walks (boom,rush), +/// (tower,rush), (rush,boom), (tower,boom), (rush,tower), (boom,tower), and +/// every pairing appears from both seats). `None` otherwise: each list +/// then cycles on its own over the seeds, as in M5b ([`pair_at`]). +pub fn pairs(pa: &[String], pb: &[String], mirrors: bool) -> Option> { + if pa.len() < 2 || pb.len() < 2 { + return None; + } + let mut out = Vec::new(); + for b in pb { + for a in pa { + if mirrors || a != b { + out.push((a.clone(), b.clone())); + } + } + } + Some(out) +} + +/// The personalities of game `i` (0-based, in seed order): the `i`-th of +/// the walked pairs, or each list cycled on its own. +fn pair_at( + i: usize, + pa: &[String], + pb: &[String], + walked: Option<&[(String, String)]>, +) -> (String, String) { + match walked { + Some(walk) => walk[i % walk.len()].clone(), + None => (pa[i % pa.len()].clone(), pb[i % pb.len()].clone()), + } +} + +/// `random` resolved for `player` at `seed` ([`ai::resolve_personality`]); +/// any other id is itself. +fn resolve(rules: &Rules, id: &str, seed: u64, player: PlayerId) -> Result { + if id == ai::RANDOM_PERSONALITY { + return ai::resolve_personality(rules, seed, player); + } + Ok(id.to_string()) +} + /// The setups of a run: `MatchSetup::bots` per seed, each side's -/// personalities cycled over the seeds in order. Every setup's bots are -/// built once here, so an unknown bot or personality is a usage error -/// before any game runs. +/// personality from [`pairs`] (or each list cycled), `random` resolved +/// into the header. Every setup's bots are built once here, so an unknown +/// bot or personality, or a `random` with no pool, is a usage error before +/// any game runs. pub fn setups(args: &PlayBotsArgs, rules: &Rules) -> Result, String> { let pa = personalities("a", &args.a, &args.personality_a)?; let pb = personalities("b", &args.b, &args.personality_b)?; + let walked = pairs(&pa, &pb, args.mirrors); + if walked.as_ref().is_some_and(Vec::is_empty) { + return Err( + "--personality-a and --personality-b name no distinct pair; pass --mirrors to play mirrors" + .to_string(), + ); + } let mut out = Vec::new(); for (i, seed) in args.seeds.list().into_iter().enumerate() { - let setup = MatchSetup::bots( - rules, - seed, - (&args.a, &pa[i % pa.len()]), - (&args.b, &pb[i % pb.len()]), - ); + let (a, b) = pair_at(i, &pa, &pb, walked.as_deref()); + let a = resolve(rules, &a, seed, PlayerId(0))?; + let b = resolve(rules, &b, seed, PlayerId(1))?; + let setup = MatchSetup::bots(rules, seed, (&args.a, &a), (&args.b, &b)); ai::controller_for(&setup, rules).map_err(|e| e.to_string())?; out.push(setup); } @@ -375,10 +441,170 @@ mod tests { fn csv_header_is_exact() { assert_eq!( CSV_HEADER, - "seed,winner,outcome,tick,final_hash,age_reached_tick,first_attack_tick,reason,first_attack_units,first_attack_wains,second_town_tick,tower_tick,bo_done_tick_a,age_reached_tick_b,first_attack_tick_b,first_attack_units_b,first_attack_wains_b,territory_a,territory_b,units_lost_a,units_lost_b,min_enemy_town_hp_permille_a,min_enemy_town_hp_permille_b,rejected_a,rejected_b,second_town_tick_b,tower_tick_b,bo_done_tick_b" + "seed,winner,outcome,tick,final_hash,age_reached_tick,first_attack_tick,reason,first_attack_units,first_attack_wains,second_town_tick,tower_tick,bo_done_tick_a,age_reached_tick_b,first_attack_tick_b,first_attack_units_b,first_attack_wains_b,territory_a,territory_b,units_lost_a,units_lost_b,min_enemy_town_hp_permille_a,min_enemy_town_hp_permille_b,rejected_a,rejected_b,second_town_tick_b,tower_tick_b,bo_done_tick_b,bot_a,personality_a,bot_b,personality_b,waves_a,waves_b,raids_a,raids_b,retreats_a,retreats_b,wiped_a,wiped_b,towns_taken_a,towns_taken_b,bonus_a,bonus_b,income_a,income_b,cap_s_a,cap_s_b,first_contact_tick,max_quiet_ticks,first_on_land_tick_a,first_on_land_tick_b,min_own_town_hp_permille_a,min_own_town_hp_permille_b" ); } + fn list(ids: &str) -> Vec { + ids.split(',').map(str::to_string).collect() + } + + fn pair_list(p: &[(String, String)]) -> Vec<(&str, &str)> { + p.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect() + } + + /// M6: two lists of several entries walk the ordered distinct pairs, B + /// outer and A inner, so 20 seeds give the first two pairs 4 games and + /// the rest 3, and every pairing is played from both seats. + #[test] + fn distinct_pairs_walk_a_fastest_and_skip_mirrors() { + let three = list("rush,boom,tower"); + let walk = pairs(&three, &three, false).unwrap(); + assert_eq!( + pair_list(&walk), + [ + ("boom", "rush"), + ("tower", "rush"), + ("rush", "boom"), + ("tower", "boom"), + ("rush", "tower"), + ("boom", "tower") + ] + ); + let games: Vec<(String, String)> = (0..20) + .map(|i| pair_at(i, &three, &three, Some(&walk))) + .collect(); + for (k, pair) in walk.iter().enumerate() { + let n = games.iter().filter(|g| *g == pair).count(); + assert_eq!(n, if k < 2 { 4 } else { 3 }, "{pair:?}"); + let swapped = (pair.1.clone(), pair.0.clone()); + assert!(games.contains(&swapped), "{pair:?} from the other seat"); + } + assert!(games.iter().all(|(a, b)| a != b), "no mirror"); + // Through the flags: seeds 1..6 play the six pairs in order. + let rules = Rules::load(data()).unwrap(); + let mut rules_three = rules.clone(); + for id in ["rush", "boom", "tower"] { + let mut p = rules.ai.personality("standard").unwrap().clone(); + p.id = id.to_string(); + rules_three.ai.personalities.push(p); + } + let s = setups( + &args("--seeds 1..6 --personality-a rush,boom,tower --personality-b rush,boom,tower"), + &rules_three, + ) + .unwrap(); + let got: Vec<(&str, &str)> = s + .iter() + .map(|x| { + ( + x.ai_seats[0].personality.as_str(), + x.ai_seats[1].personality.as_str(), + ) + }) + .collect(); + assert_eq!(got, pair_list(&walk)); + // Two lists naming one id only have no distinct pair. + let err = setups( + &args("--seeds 1 --personality-a standard,standard --personality-b standard,standard"), + &rules, + ) + .unwrap_err(); + assert!(err.contains("--mirrors"), "{err}"); + } + + /// M6: `--mirrors` walks all nine ordered pairs, A inner. + #[test] + fn mirrors_flag_walks_all_nine() { + let three = list("rush,boom,tower"); + let walk = pairs(&three, &three, true).unwrap(); + assert_eq!(walk.len(), 9); + assert_eq!( + pair_list(&walk[..4]), + [ + ("rush", "rush"), + ("boom", "rush"), + ("tower", "rush"), + ("rush", "boom") + ] + ); + assert!(args("--seeds 1 --mirrors").mirrors); + let rules = Rules::load(data()).unwrap(); + let s = setups( + &args("--seeds 1..2 --personality-a standard,standard --personality-b standard,standard --mirrors"), + &rules, + ) + .unwrap(); + assert_eq!(s.len(), 2); + } + + /// M6: when either list has one entry the lists cycle on their own, as + /// in M5b, `--mirrors` or not. + #[test] + fn single_lists_cycle_as_before() { + let one = list("standard"); + let two = list("x,y"); + assert_eq!(pairs(&one, &two, false), None); + assert_eq!(pairs(&two, &one, true), None); + let games: Vec<(String, String)> = (0..4).map(|i| pair_at(i, &two, &one, None)).collect(); + assert_eq!( + pair_list(&games), + [ + ("x", "standard"), + ("y", "standard"), + ("x", "standard"), + ("y", "standard") + ] + ); + let rules = Rules::load(data()).unwrap(); + let s = setups(&args("--seeds 1..3 --a passive --b hard"), &rules).unwrap(); + assert!(s.iter().all(|x| x.ai_seats[0].bot == "passive" + && x.ai_seats[0].personality.is_empty() + && x.ai_seats[1].bot == "hard" + && x.ai_seats[1].personality == "standard")); + } + + /// M6: `random` resolves per seed and seat into the header; with no + /// personality in the pool it is a usage error. + #[test] + fn random_resolves_into_the_header_or_is_a_usage_error() { + let rules = Rules::load(data()).unwrap(); + // The shipped pool (M6 slice 2): rush, boom and tower. + let s = setups(&args("--seeds 1..12 --personality-b random"), &rules).unwrap(); + let shipped: Vec<&str> = s + .iter() + .map(|x| x.ai_seats[1].personality.as_str()) + .collect(); + for id in ["rush", "boom", "tower"] { + assert!(shipped.contains(&id), "{shipped:?}"); + } + let mut empty = rules.clone(); + for p in &mut empty.ai.personalities { + p.random = false; + } + let err = setups(&args("--seeds 1 --personality-b random"), &empty).unwrap_err(); + assert!(err.contains("random: true"), "{err}"); + let mut pooled = empty.clone(); + for id in ["p", "q"] { + let mut p = rules.ai.personality("standard").unwrap().clone(); + p.id = id.to_string(); + p.random = true; + pooled.ai.personalities.push(p); + } + let s = setups(&args("--seeds 1..12 --personality-b random"), &pooled).unwrap(); + let picks: Vec<&str> = s + .iter() + .map(|x| x.ai_seats[1].personality.as_str()) + .collect(); + assert!(picks.contains(&"p") && picks.contains(&"q"), "{picks:?}"); + for (x, pick) in s.iter().zip(&picks) { + assert_eq!( + ai::resolve_personality(&pooled, x.seed, PlayerId(1)).unwrap(), + *pick + ); + } + } + #[test] fn seed_ranges_parse() { assert_eq!(parse_seeds("1..5"), Ok(Seeds { first: 1, last: 5 })); @@ -413,8 +639,12 @@ mod tests { fn unknown_bots_and_personalities_are_usage_errors() { let rules = Rules::load(data()).unwrap(); let err = |line: &str| setups(&args(line), &rules).unwrap_err(); - assert!(err("--seeds 1 --a hard").contains("unknown bot \"hard\"")); - assert!(err("--seeds 1 --personality-b rush").contains("no personality \"rush\"")); + assert!( + err("--seeds 1 --a nightmare") + .contains("unknown bot \"nightmare\" (known: passive, easy, hard, standard)") + ); + assert!(err("--seeds 1 --personality-b zerg").contains("no personality \"zerg\"")); + assert!(err("--seeds 1 --b hard --personality-b zerg").contains("no personality \"zerg\"")); assert!(err("--seeds 1 --a passive --personality-a standard").contains("takes none")); let ok = setups( &args("--seeds 1..4 --personality-a standard,standard"), diff --git a/crates/sim-cli/src/scenarios.rs b/crates/sim-cli/src/scenarios.rs index 16a39a3..aba5684 100644 --- a/crates/sim-cli/src/scenarios.rs +++ b/crates/sim-cli/src/scenarios.rs @@ -212,7 +212,9 @@ pub const BOTS_BENCH_KINDS: [&str; 4] = ["skirmisher", "bowman", "shieldbearer", pub const BOTS_BENCH_FORWARD_TILES: i32 = 12; /// The `sim-cli bench --bots` match (M5b, ROADMAP acceptance 3): the -/// Standard bot in both seats (`passive` seats for the control run) from +/// Standard bot in both seats as `bot = (difficulty, personality)`, M5b's +/// `standard`/`standard` by default (`None`: `passive` seats for the +/// control run) from /// the standard start with debug commands on, and a tick-0 stream issued /// by P0 with sequence numbers from [`BOTS_BENCH_SEQ`]. For each player: /// `DebugAdvanceAge` (Masonry, so waves may launch), a `DebugGrant` of @@ -227,12 +229,13 @@ pub const BOTS_BENCH_FORWARD_TILES: i32 = 12; /// seat launches a wave of the army assembled, sieged and supplied at its /// rally; meanwhile the build order runs on the grants and production /// meets a full pop. -pub fn bots_bench(rules: &Rules, units: u16, seed: u64, standard: bool) -> (MatchSetup, Stream) { - let seat = if standard { - (ai::BOT_STANDARD, "standard") - } else { - (ai::BOT_PASSIVE, "") - }; +pub fn bots_bench( + rules: &Rules, + units: u16, + seed: u64, + bot: Option<(&str, &str)>, +) -> (MatchSetup, Stream) { + let seat = bot.unwrap_or((ai::BOT_PASSIVE, "")); let mut setup = MatchSetup::bots(rules, seed, seat, seat); setup.debug_commands = true; let kind = |id: &str| { @@ -245,8 +248,8 @@ pub fn bots_bench(rules: &Rules, units: u16, seed: u64, standard: bool) -> (Matc }; let knobs = rules .ai - .personality("standard") - .expect("the standard personality is in data/ai"); + .personality(bot.map_or("standard", |(_, p)| p)) + .expect("the bench's personality is in data/ai"); let def = rules .map(&setup.map) .expect("the setup's map is in the rules"); @@ -680,14 +683,14 @@ mod tests { #[test] fn bots_bench_stream_and_seats() { let rules = rules(); - let (setup, stream) = bots_bench(&rules, 400, 1, true); + let (setup, stream) = bots_bench(&rules, 400, 1, Some(("standard", "standard"))); assert!(setup.debug_commands); assert!(setup.ai_seats.iter().all(|s| s.bot == "standard")); assert_eq!(stream.len(), 1); assert_eq!(stream[0].0, 0); assert!(stream[0].1.iter().all(|c| c.player == PlayerId(0))); assert!(stream[0].1.iter().all(|c| c.seq >= BOTS_BENCH_SEQ)); - let (control, control_stream) = bots_bench(&rules, 400, 1, false); + let (control, control_stream) = bots_bench(&rules, 400, 1, None); assert!(control.ai_seats.iter().all(|s| s.bot == "passive")); assert_eq!(control_stream, stream); } diff --git a/crates/sim-cli/tests/data_check.rs b/crates/sim-cli/tests/data_check.rs index ea026d7..d670a89 100644 --- a/crates/sim-cli/tests/data_check.rs +++ b/crates/sim-cli/tests/data_check.rs @@ -78,9 +78,11 @@ fn repo_data_passes_with_three_ages_eight_kinds_and_twelve_techs() { stdout.contains(" techs=12 side_techs=2 factions=1 "), "{stdout}" ); - // M5b: the bot data under data/ai. + // M5b: the bot data under data/ai; M6: its three difficulty levels. assert!( - stdout.contains(" factions=1 personalities=1 build_orders=1 maps=plains_1v1 strings=en"), + stdout.contains( + " factions=1 personalities=4 build_orders=4 difficulties=3 maps=plains_1v1 strings=en" + ), "{stdout}" ); // Art pass: the visuals counts and the Scene files found under assets/. diff --git a/crates/sim/src/ai_hook.rs b/crates/sim/src/ai_hook.rs index 2c8b597..863d12e 100644 --- a/crates/sim/src/ai_hook.rs +++ b/crates/sim/src/ai_hook.rs @@ -461,11 +461,23 @@ impl<'a> SimView<'a> { self.rules } - /// Match seed (informational; the bot must not reseed anything from it). + /// Match seed. The bot must not seed a generator of its own from it; + /// its only randomness is [`SimView::ai_noise`] (M6). pub fn seed(&self) -> u64 { self.state.seed } + /// The bot's noise (M6): a pure hash of the hashed seed, `player` and + /// `salt`, `xxh3_64("eonmark-ai-noise" ‖ seed ‖ player ‖ salt)` over + /// little-endian bytes. It is tick-free, so a decision salted with a + /// wave count or a site index is stable across plans with nothing + /// remembered, and it never draws from the sim's `Pcg32`, so a bot's + /// noise cannot shift combat's tie draws (docs/DETERMINISM.md, rule + /// 5). + pub fn ai_noise(&self, player: PlayerId, salt: u64) -> u64 { + ai_noise(self.state.seed, player, salt) + } + /// All players in slot order. pub fn players(&self) -> &'a [Player] { &self.state.players @@ -512,6 +524,22 @@ impl<'a> SimView<'a> { } } +/// The domain tag of [`SimView::ai_noise`]. +const AI_NOISE_TAG: &[u8] = b"eonmark-ai-noise"; + +/// [`SimView::ai_noise`] for a seed (a pure function, for tests and tools). +pub fn ai_noise(seed: u64, player: PlayerId, salt: u64) -> u64 { + let mut bytes = [0u8; AI_NOISE_TAG.len() + 8 + 1 + 8]; + let (tag, rest) = bytes.split_at_mut(AI_NOISE_TAG.len()); + tag.copy_from_slice(AI_NOISE_TAG); + let (s, rest) = rest.split_at_mut(8); + s.copy_from_slice(&seed.to_le_bytes()); + let (p, rest) = rest.split_at_mut(1); + p[0] = player.0; + rest.copy_from_slice(&salt.to_le_bytes()); + xxhash_rust::xxh3::xxh3_64(&bytes) +} + /// A scripted opponent that runs *inside* [`crate::Sim::step`] so bot-vs-bot /// replays and future lockstep peers agree on every decision. /// @@ -639,6 +667,49 @@ mod tests { assert!(hp < cap, "the guard hit the tower: {hp} / {cap}"); } + /// M6: `ai_noise` is a pure function of the seed, the player and the + /// salt (equal to the free `ai_noise`, the same at every tick and on + /// every call), it differs by seed, player and salt, and a thousand + /// calls leave the sim's `Pcg32` (the `rng` sub-hash) and the whole + /// hash untouched. + #[test] + fn ai_noise_is_pure_and_leaves_the_rng_alone() { + use std::collections::BTreeSet; + let rules = Rules::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")).unwrap(); + let mut sim = Sim::new( + MatchSetup::skirmish(&rules, 7), + rules.clone(), + Box::new(NoAi), + ); + let (hash, rng) = (sim.hash(), sim.sub_hashes().rng); + let view = sim.view(); + let who = |salt: u64| PlayerId(u8::try_from(salt % 2).unwrap()); + let first: Vec = (0..1000u64) + .map(|salt| view.ai_noise(who(salt), salt)) + .collect(); + assert_eq!(sim.hash(), hash); + assert_eq!(sim.sub_hashes().rng, rng, "never draws from the Pcg32"); + for (salt, n) in (0..1000u64).zip(&first) { + assert_eq!(*n, super::ai_noise(7, who(salt), salt)); + } + assert_eq!(first.iter().collect::>().len(), 1000); + for _ in 0..30 { + sim.step(&[]); + } + let later = sim.view(); + assert_eq!(later.ai_noise(PlayerId(0), 4), first[4], "tick-free"); + assert_ne!( + later.ai_noise(PlayerId(1), 4), + first[4], + "the player matters" + ); + assert_ne!( + super::ai_noise(8, PlayerId(0), 4), + first[4], + "the seed matters" + ); + } + /// M5b: `check_gather` and `check_rally` answer as `Gather` and /// `SetRally` would be applied: who takes a slot, and every rejection /// the issuing player's checks or the validator make. diff --git a/crates/sim/src/economy.rs b/crates/sim/src/economy.rs index 4e8707a..a8bb8be 100644 --- a/crates/sim/src/economy.rs +++ b/crates/sim/src/economy.rs @@ -20,7 +20,7 @@ use crate::construction::{at_work, building_rect}; use crate::gather; use crate::ids::{BuildingKindId, PlayerId}; -use crate::state::{Construction, Job, State}; +use crate::state::{Construction, Job, MatchSetup, SimEvent, State}; use rules::Rules; use serde::{Deserialize, Serialize}; @@ -79,6 +79,119 @@ pub struct RateInfo { pub credited: u32, } +impl RateInfo { + /// `true` while production of this resource is discarded at the Yield + /// Cap (`raw > credited`): the HUD's amber readout (M3b) and + /// `MatchStats`' cap seconds (M6) share this one rule. + pub fn at_cap(&self) -> bool { + self.raw > self.credited + } +} + +/// One bot seat's disclosed bonus schedule (M6, step 6d'): `bundle` is +/// added to the seat's stockpile on the step that completes tick `n` for +/// every `n > from_ticks` with `(n - from_ticks) % every_ticks == 0`. +/// Derived from the header and the rules ([`bonus_table`]), never hashed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BonusSeat { + /// The bot seat. + pub player: PlayerId, + /// The match time the schedule counts from, in ticks. + pub from_ticks: u32, + /// The interval in ticks (at least 1). + pub every_ticks: u32, + /// The bundle per resource, `resources.ron` order. + pub bundle: Vec, +} + +/// The bonus schedule of a match (M6): one [`BonusSeat`] per AI seat, in +/// id order, whose `bot` names a difficulty level (`rules.ai`) that pays +/// (a non-zero interval and a non-empty bundle). Human slots and `passive` +/// seats get nothing. A pure function of the header and the +/// `rules_hash`-covered data, so a replay or a restore derives the same +/// table. +pub fn bonus_table(setup: &MatchSetup, rules: &Rules) -> Vec { + setup + .ai_seats + .iter() + .filter_map(|seat| { + let level = rules.ai.difficulty(&seat.bot)?; + if !level.pays() { + return None; + } + Some(BonusSeat { + player: seat.player, + from_ticks: level.income_from_ticks(rules), + every_ticks: level.income_interval_ticks(rules).max(1), + bundle: level.bundle(rules), + }) + }) + .collect() +} + +/// Step 6d' (M6): pay every bonus seat whose schedule falls on the tick this +/// step completes (`tick + 1`), in id order, skipping a surrendered seat: +/// the bundle is added to the stockpile, then each resource is clamped to +/// its `stockpile_cap` (Lore). Emits `BonusIncome` with what was actually +/// credited. Integer arithmetic on the hashed stockpiles on a fixed tick +/// schedule, so replays re-derive it with nothing recorded. +pub(crate) fn bonus_step( + state: &mut State, + rules: &Rules, + bonus: &[BonusSeat], + tick: u32, + events: &mut Vec, +) { + let paying: Vec = state + .players + .iter() + .filter(|p| !p.surrendered) + .map(|p| p.id) + .collect(); + pay_bonus(&mut state.economy, &paying, rules, bonus, tick, events); +} + +/// Whether `seat`'s schedule pays on the step that completes tick +/// `completes`. +pub fn bonus_due(seat: &BonusSeat, completes: u32) -> bool { + completes > seat.from_ticks + && (completes - seat.from_ticks).is_multiple_of(seat.every_ticks.max(1)) +} + +/// [`bonus_step`] over a bare economy: `paying` lists the players that may +/// receive a bonus (those that have not surrendered). +fn pay_bonus( + economy: &mut Economy, + paying: &[PlayerId], + rules: &Rules, + bonus: &[BonusSeat], + tick: u32, + events: &mut Vec, +) { + let completes = tick.saturating_add(1); + for seat in bonus { + if !bonus_due(seat, completes) || !paying.contains(&seat.player) { + continue; + } + let eco = economy.player_mut(seat.player); + let before = eco.stock.clone(); + for (s, a) in eco.stock.iter_mut().zip(&seat.bundle) { + *s = s.checked_add(*a).expect("stockpile fits i64"); + } + clamp_to_caps(rules, &mut eco.stock); + let amounts: Vec = eco + .stock + .iter() + .zip(&before) + .map(|(after, b)| after.saturating_sub(*b).max(0)) + .collect(); + events.push(SimEvent::BonusIncome { + player: seat.player, + amounts, + }); + } +} + /// The player's income per resource this tick, `resources.ron` order. pub(crate) fn rates(state: &State, rules: &Rules, p: PlayerId) -> Vec { let n = rules.resources.resources.len(); @@ -226,6 +339,96 @@ pub(crate) fn building_cost_for( #[cfg(test)] mod tests { use super::*; + use std::path::Path; + + fn rules() -> Rules { + Rules::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")).unwrap() + } + + /// M6: the HUD's amber rule and `MatchStats`' cap seconds are one + /// function: production above the Yield Cap, `raw > credited`. + #[test] + fn rate_info_at_cap_matches_the_hud_rule() { + let rate = |raw, cap: Option| RateInfo { + raw, + cap, + credited: cap.map_or(raw, |c| raw.min(c)), + }; + assert!(rate(80, Some(70)).at_cap()); + assert!( + !rate(70, Some(70)).at_cap(), + "exactly at the cap loses nothing" + ); + assert!(!rate(10, Some(70)).at_cap()); + assert!(!rate(500, None).at_cap(), "uncapped Lore is never amber"); + for (raw, cap) in [ + (0, Some(0)), + (1, Some(0)), + (99, Some(100)), + (101, Some(100)), + ] { + let r = rate(raw, cap); + assert_eq!(r.at_cap(), r.raw > r.credited, "{r:?}"); + } + } + + /// M6 step 6d': a hand-built schedule on a bare economy pays its + /// bundle on the steps that complete `from + k x every` (k >= 1) and + /// on no other, only to its seat, and reports what it paid. + #[test] + fn bonus_step_pays_on_schedule_from_income_from() { + let rules = rules(); + let n = rules.resources.resources.len(); + let mut eco = Economy::new(2, n); + let mut bundle = vec![0i64; n]; + bundle[0] = 20; + bundle[1] = 7; + let table = [BonusSeat { + player: PlayerId(1), + from_ticks: 100, + every_ticks: 10, + bundle: bundle.clone(), + }]; + let mut events = Vec::new(); + let mut paid_at = Vec::new(); + for tick in 0..200u32 { + let before = events.len(); + pay_bonus( + &mut eco, + &[PlayerId(0), PlayerId(1)], + &rules, + &table, + tick, + &mut events, + ); + if events.len() > before { + paid_at.push(tick + 1); + } + } + assert_eq!(paid_at, (110..=200).step_by(10).collect::>()); + assert_eq!(eco.players[1].stock[0], 20 * 10); + assert_eq!(eco.players[1].stock[1], 7 * 10); + assert!( + eco.players[0].stock.iter().all(|s| *s == 0), + "seat 0 has no bonus" + ); + assert!( + eco.players[1].acc.iter().all(|a| *a == 0), + "the bonus bypasses the accumulator" + ); + assert!(events.iter().all(|e| *e + == SimEvent::BonusIncome { + player: PlayerId(1), + amounts: bundle.clone() + })); + // A seat left out of `paying` (surrendered) gets nothing. + let mut eco = Economy::new(2, n); + pay_bonus(&mut eco, &[PlayerId(0)], &rules, &table, 109, &mut events); + assert!(eco.players[1].stock.iter().all(|s| *s == 0)); + assert!( + bonus_due(&table[0], 110) && !bonus_due(&table[0], 100) && !bonus_due(&table[0], 105) + ); + } #[test] fn affordability_names_the_first_short_resource() { diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index 733f145..8a294d0 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -22,7 +22,7 @@ //! | [`replay`] | `.eonreplay` writer, reader, `verify` | //! | [`scenarios`] | scripted command streams shared by tests, benches, `record` and fixtures (the M4a counter duels and `combat_skirmish`, the M4b `enemy_outpost` pair among them) | //! | [`territory`] | `TerritoryField`: integer disc kernels per owner layer, tie-to-neutral ownership | -//! | [`economy`] | `Economy` stockpiles and accumulators, `RateInfo`, exact income, jobs step, cost helpers | +//! | [`economy`] | `Economy` stockpiles and accumulators, `RateInfo` (with `at_cap`), exact income, jobs step, cost helpers, the bot seats' disclosed bonus schedule and step 6d' (M6) | //! | [`construction`] | footprints, `Build` validation, sites, construction, site cancel | //! | [`gather`] | `Gather`, slots and stand tiles, worked-slot income, idle workers seeking work | //! | [`town`] | standard start, town count and limit, attribution to the nearest Town, tier and radius refresh | @@ -32,7 +32,8 @@ //! | [`annexation`] | the annexation timer of Towns at 0 HP (attackers, defenders, entry, reset), the flip and its transfers, step 5d (M5a) | //! | [`victory`] | the match outcome: Surrender, Seat captured, all Towns lost and the territory cap, latched in step 6e (M5a) | //! | [`combat`] | unit combat stats from the Modifier rows, explicit `Attack` (hold, direct chase, path chase, give up), target acquisition by guards and attack-movers, Watchtower and Town shooters, simultaneous hits, death and building destruction | -//! | [`ai_hook`] | `AiController`, `SimView` (read-only quotes and checks; `building_hp` from M4b; Harrying, supply, exposure, annexation timers and the outcome from M5a) | +//! | [`ai_hook`] | `AiController`, `SimView` (read-only quotes and checks; `building_hp` from M4b; Harrying, supply, exposure, annexation timers and the outcome from M5a; the bot's tick-free `ai_noise` from M6) | +//! | [`stats`] | `MatchStats` (M6): engine-free, unhashed match statistics from the view and the events, for play-bots, `match-log` and the game-over screen | //! | [`hash`] | xxh3 over postcard | #![forbid(unsafe_code)] #![warn(missing_docs)] @@ -54,18 +55,19 @@ pub mod pathing; pub mod replay; pub mod scenarios; pub mod state; +pub mod stats; pub mod tech; pub mod territory; pub mod town; pub mod training; pub mod victory; -pub use ai_hook::{AI_MEMORY_GUIDE_BYTES, AiController, AiMemory, NoAi, SimView}; +pub use ai_hook::{AI_MEMORY_GUIDE_BYTES, AiController, AiMemory, NoAi, SimView, ai_noise}; pub use annexation::AnnexView; pub use attrition::Exposure; pub use combat::{AttackStats, Engage, UnitStats}; pub use command::{Command, PlayerCommand, sort_commands}; -pub use economy::{Economy, PlayerEconomy, RateInfo}; +pub use economy::{BonusSeat, Economy, PlayerEconomy, RateInfo, bonus_table}; pub use fx::{Fx, FxVec2}; pub use ids::{ BuildingId, BuildingKindId, IdGen, PlayerId, TargetId, TechId, Tile, UnitId, UnitKindId, @@ -74,7 +76,8 @@ pub use map::Map; pub use movement::SpatialGrid; pub use pathing::{AStarSearch, PathRequest, Pathing, SearchStatus}; pub use replay::{ - ReplayError, ReplayFile, ReplayReader, ReplayWriter, VerifyOutcome, verify, verify_with, + ReplayError, ReplayFile, ReplayReader, ReplayWriter, VerifyOutcome, verify, verify_observed, + verify_with, }; pub use rules::Rules; pub use state::{ @@ -82,5 +85,6 @@ pub use state::{ OutcomeReason, Player, PlayerSlot, Post, QueueEntry, QueueItem, RejectReason, SIM_VERSION, Sim, SimEvent, SnapshotError, StartKind, SubHashes, TownState, Unit, }; +pub use stats::{MatchStats, PlayerStats}; pub use tech::{PlayerTech, TechState}; pub use territory::{InvalidTerritoryField, Source, SourceOwner, TerritoryField}; diff --git a/crates/sim/src/replay.rs b/crates/sim/src/replay.rs index 3f552ab..54dd15e 100644 --- a/crates/sim/src/replay.rs +++ b/crates/sim/src/replay.rs @@ -48,9 +48,9 @@ //! [`SubHashes`] names the subsystem. The result is exactly one //! [`VerifyOutcome`], whose `Display` is the string the CLI prints. -use crate::ai_hook::AiController; +use crate::ai_hook::{AiController, SimView}; use crate::command::PlayerCommand; -use crate::state::{MatchSetup, SIM_VERSION, Sim, SubHashes}; +use crate::state::{MatchSetup, SIM_VERSION, Sim, SimEvent, SubHashes}; use rules::Rules; use serde::{Deserialize, Serialize}; use std::fs::File; @@ -539,6 +539,47 @@ pub fn verify_with( path: &Path, data_dir: &Path, factory: Box>, +) -> Result { + run_verify(path, data_dir, factory, None) +} + +/// What [`verify_observed`] hands every step: the view after it and the +/// events it emitted. +pub type StepObserver<'a> = dyn FnMut(&SimView<'_>, &[SimEvent]) + 'a; + +/// [`verify_with`] that also hands the re-simulation to `observe` (M6): +/// once at tick 0 with no events, then after every step with the events +/// that step emitted (drained). `sim-cli match-log` feeds `MatchStats` +/// from it. The outcome is the one [`verify_with`] returns; nothing is +/// observed after a version or rules mismatch. +pub fn verify_observed( + path: &Path, + data_dir: &Path, + factory: Box>, + observe: &mut StepObserver<'_>, +) -> Result { + run_verify(path, data_dir, factory, Some(observe)) +} + +/// Step `sim` with `cmds`, then hand the step to `observe` with the drained +/// events, if there is an observer. +fn step_observed( + sim: &mut Sim, + cmds: &[PlayerCommand], + observe: &mut Option<&mut StepObserver<'_>>, +) { + sim.step(cmds); + if let Some(observe) = observe { + let events = sim.drain_events(); + observe(&sim.view(), &events); + } +} + +fn run_verify( + path: &Path, + data_dir: &Path, + factory: Box>, + mut observe: Option<&mut StepObserver<'_>>, ) -> Result { let ReplayFile { setup, records, .. } = match open_for_check(path)? { Ok(file) => file, @@ -548,12 +589,24 @@ pub fn verify_with( Ok(sim) => sim, Err(outcome) => return Ok(outcome), }; + if let Some(observe) = &mut observe { + observe(&sim.view(), &[]); + } let mut last_hash: Option<(u32, u64)> = None; for record in &records { let tick = record_tick(record); - advance_to(&mut sim, tick, path)?; + if tick < sim.tick() { + return Err(ReplayError::OutOfOrder { + path: path.to_path_buf(), + tick, + reached: sim.tick(), + }); + } + while sim.tick() < tick { + step_observed(&mut sim, &[], &mut observe); + } match record { - Record::Tick(batch) => sim.step(&batch.cmds), + Record::Tick(batch) => step_observed(&mut sim, &batch.cmds, &mut observe), Record::Hash(rec) => { if sim.hash() != rec.hash { let subsystem = sim @@ -715,21 +768,6 @@ fn record_tick(record: &Record) -> u32 { } } -/// Step with empty command lists until `sim.tick() == tick`. -fn advance_to(sim: &mut Sim, tick: u32, path: &Path) -> Result<(), ReplayError> { - if tick < sim.tick() { - return Err(ReplayError::OutOfOrder { - path: path.to_path_buf(), - tick, - reached: sim.tick(), - }); - } - while sim.tick() < tick { - sim.step(&[]); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/sim/src/state.rs b/crates/sim/src/state.rs index 722d781..305563d 100644 --- a/crates/sim/src/state.rs +++ b/crates/sim/src/state.rs @@ -207,7 +207,22 @@ use std::collections::{BTreeMap, BTreeSet}; /// memory and every other bot's is hidden; `Command::DebugAdvanceAge`. /// From 17 on, a `crates/ai` change that can alter a bot decision bumps /// this number (and, through the fixture policy, `rules_version`). -pub const SIM_VERSION: u32 = 17; +/// +/// 18: M6 difficulty levels (PR 1, slices 1 and 2). Step 6d' pays every +/// bot seat whose `AiSeat.bot` names a difficulty level +/// (`data/ai/difficulty/.ron`) its disclosed bonus bundle every +/// `income_interval_ds` from `income_from_ds`, clamped to the stockpile +/// caps, with the appended `SimEvent::BonusIncome`; the schedule is +/// derived from the header and the rules and rebuilt in `rebuild_derived`. +/// `SimView::ai_noise` (a pure hash, never the `Pcg32`). In `crates/ai` +/// the Standard bot plays the knobs `rules::ai::resolve` derives from its +/// personality and difficulty; slice 2's bot behaviours and the M6 retune +/// (`rules_version` 29 to 31: the difficulty files' `pressure` block and +/// `extra_research`, the `Wave` target-hit-points memory in the hashed +/// `BotMemory`, rally-group reinforcement, commit, the lure, cohesion and +/// the launch and assault gates; since 30 every shipped level, `standard` +/// included, moves the all-in time) ship under the same number. +pub const SIM_VERSION: u32 = 18; /// Who drives a player slot. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -942,6 +957,15 @@ pub enum SimEvent { /// The result, as `Sim::result` returns it from now on. result: MatchResult, }, + /// A bot seat received its difficulty's disclosed bonus bundle (step + /// 6d', M6). + BonusIncome { + /// The bot seat. + player: PlayerId, + /// What reached the stockpile per resource, `resources.ron` order + /// (the bundle, less any stockpile cap clamp). + amounts: Vec, + }, } /// Per-subsystem hashes for localising a divergence. Field order is the @@ -1078,6 +1102,9 @@ pub struct Sim { grid: SpatialGrid, /// Derived from the rules alone: built here and in `rebuild_derived`. combat_tables: combat::CombatTables, + /// The bonus schedule of the bot seats (M6): derived from the header + /// and the rules, built here and in `rebuild_derived`, unhashed. + bonus: Vec, /// Whether the (derived) path cache is in use; survives restore. path_cache_enabled: bool, /// Not hashed; drained by the caller. @@ -1156,6 +1183,7 @@ impl Sim { } let grid = SpatialGrid::new(&state.map); let combat_tables = combat::CombatTables::new(&rules); + let bonus = economy::bonus_table(&setup, &rules); Self { setup, rules, @@ -1163,6 +1191,7 @@ impl Sim { state, grid, combat_tables, + bonus, path_cache_enabled: true, events: Vec::new(), } @@ -1336,11 +1365,19 @@ impl Sim { let flips = annexation::step(&mut self.state, &self.rules, &mut self.events); // 6. Economy and Towns: construction, training, jobs and idle - // seeking, income; then the match outcome. + // seeking, income, the bot seats' disclosed bonus (6d', M6); + // then the match outcome. construction::step(&mut self.state, &self.rules, tick, &mut self.events); training::step(&mut self.state, &self.rules, tick, &mut self.events); economy::jobs_step(&mut self.state, &self.rules, tick); economy::income_step(&mut self.state, &self.rules); + economy::bonus_step( + &mut self.state, + &self.rules, + &self.bonus, + tick, + &mut self.events, + ); victory::step(&mut self.state, &self.rules, tick, &flips, &mut self.events); self.state.tick += 1; @@ -1911,14 +1948,15 @@ impl Sim { } /// Rebuild every unhashed derived structure from `state` and the rules: - /// map components, spatial grid, the combat tables and the path cache - /// (empty, with its capacity and the sim's enabled flag re-applied since - /// `serde(skip)` reset it). + /// map components, spatial grid, the combat tables, the bonus schedule + /// (M6, from the header) and the path cache (empty, with its capacity + /// and the sim's enabled flag re-applied since `serde(skip)` reset it). fn rebuild_derived(&mut self) { self.state.map.rebuild_components(); self.grid = SpatialGrid::new(&self.state.map); self.grid.rebuild(&self.state.units); self.combat_tables = combat::CombatTables::new(&self.rules); + self.bonus = economy::bonus_table(&self.setup, &self.rules); self.state.pathing.cache = crate::pathing::PathCache::new(self.rules.path_cache_entries, self.path_cache_enabled); self.events.clear(); @@ -2388,4 +2426,147 @@ mod tests { assert_eq!(rejections(&mut sim), [(seq, RejectReason::FinalAge)]); assert_eq!(sim.view().age(PlayerId(0)), last); } + + /// M6 rules for the bonus tests: `hard` pays from tick 0 every 10 + /// ticks (`income_from_ds` 0, `income_interval_ds` 5), in memory. + fn bonus_rules(lore: Option) -> Rules { + let mut r = Rules::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")).unwrap(); + let hard = r.ai.difficulties.get_mut("hard").unwrap(); + hard.income_from_ds = 0; + hard.income_interval_ds = 5; + if let Some(lore) = lore { + hard.income.insert("lore".into(), lore); + } + assert_eq!( + r.ai.difficulty("hard").unwrap().income_interval_ticks(&r), + 10 + ); + r + } + + fn bonus_events(events: &[SimEvent]) -> Vec<(PlayerId, Vec)> { + events + .iter() + .filter_map(|e| match e { + SimEvent::BonusIncome { player, amounts } => Some((*player, amounts.clone())), + _ => None, + }) + .collect() + } + + /// M6: only a seat whose `bot` names a paying difficulty receives the + /// bonus; a `passive` seat (and every human slot) gets nothing, and the + /// paid seat's stockpile is exactly the control's plus the bundles. + #[test] + fn bonus_pays_named_seats_only() { + let r = bonus_rules(None); + let bundle = r.ai.difficulty("hard").unwrap().bundle(&r); + let paid = MatchSetup::bots(&r, 1, ("hard", "standard"), ("passive", "")); + let control = MatchSetup::bots(&r, 1, ("passive", ""), ("passive", "")); + let mut a = Sim::new(paid, r.clone(), Box::new(NoAi)); + let mut b = Sim::new(control, r.clone(), Box::new(NoAi)); + assert_eq!(a.bonus.len(), 1); + assert!(b.bonus.is_empty()); + let mut events = Vec::new(); + for _ in 0..25 { + a.step(&[]); + b.step(&[]); + events.extend(a.drain_events()); + } + let paid = bonus_events(&events); + assert_eq!( + paid, + [(PlayerId(0), bundle.clone()), (PlayerId(0), bundle.clone())], + "ticks 10 and 20" + ); + let (sa, sb) = ( + a.view().economy(PlayerId(0)).stock.clone(), + b.view().economy(PlayerId(0)).stock.clone(), + ); + for ((x, y), g) in sa.iter().zip(&sb).zip(&bundle) { + assert_eq!(*x, y + 2 * g); + } + assert_eq!(a.view().economy(PlayerId(1)), b.view().economy(PlayerId(1))); + let vs_human = MatchSetup::vs_bot(&r, 1, "hard", "standard"); + assert_eq!( + economy::bonus_table(&vs_human, &r).len(), + 1, + "the bot slot only" + ); + assert_eq!(economy::bonus_table(&vs_human, &r)[0].player, PlayerId(1)); + let mut silent = r.clone(); + silent + .ai + .difficulties + .get_mut("hard") + .unwrap() + .income_interval_ds = 0; + assert!( + economy::bonus_table(&vs_human, &silent).is_empty(), + "interval 0 pays nothing" + ); + } + + /// M6: a surrendered seat is skipped, and Lore is clamped at its + /// stockpile cap with `BonusIncome` reporting what was credited. + #[test] + fn bonus_skips_a_surrendered_seat_and_clamps_lore() { + let r = bonus_rules(Some(500)); + let lore = r.resource_index("lore").unwrap(); + let cap = i64::from(r.resources.resources[lore].stockpile_cap.unwrap()); + let setup = MatchSetup::bots(&r, 2, ("hard", "standard"), ("hard", "standard")); + let mut sim = Sim::new(setup, r, Box::new(NoAi)); + let lore_at_start = sim.view().economy(PlayerId(0)).stock[lore]; + sim.step(&[PlayerCommand::new(PlayerId(1), 0, Command::Surrender)]); + let mut events = sim.drain_events(); + for _ in 1..25 { + sim.step(&[]); + events.extend(sim.drain_events()); + } + assert!(sim.view().player(PlayerId(1)).unwrap().surrendered); + let paid = bonus_events(&events); + assert!(paid.iter().all(|(p, _)| *p == PlayerId(0)), "{paid:?}"); + assert_eq!(paid.len(), 2); + assert_eq!(paid[0].1[lore], 500.min(cap - lore_at_start)); + assert!(paid[1].1[lore] < 500, "the second bundle is clamped"); + assert_eq!(sim.view().economy(PlayerId(0)).stock[lore], cap); + let credited: i64 = paid.iter().map(|(_, a)| a[lore]).sum(); + assert!(credited <= cap - lore_at_start); + } + + /// M6: the schedule comes from the header, so a sim restored mid + /// interval pays on the same ticks as the uninterrupted run. + #[test] + fn bonus_schedule_survives_restore_mid_interval() { + let r = bonus_rules(None); + let setup = MatchSetup::bots(&r, 3, ("hard", "standard"), ("easy", "standard")); + let mut a = Sim::new(setup.clone(), r.clone(), Box::new(NoAi)); + for _ in 0..15 { + a.step(&[]); + } + a.drain_events(); + let snap = a.snapshot(); + let mut b = Sim::new(setup, r, Box::new(NoAi)); + b.restore(&snap).unwrap(); + assert_eq!(a.bonus, b.bonus); + let (mut ea, mut eb) = (Vec::new(), Vec::new()); + for _ in 15..40 { + a.step(&[]); + b.step(&[]); + assert_eq!(a.hash(), b.hash()); + ea.extend(a.drain_events()); + eb.extend(b.drain_events()); + } + let pa = bonus_events(&ea); + assert_eq!(pa, bonus_events(&eb)); + assert_eq!( + pa.iter().filter(|(p, _)| *p == PlayerId(0)).count(), + 3, + "hard at ticks 20, 30 and 40" + ); + assert!( + pa.iter().all(|(p, _)| *p == PlayerId(0)), + "easy (every 600 ticks from 3600) pays nothing yet" + ); + } } diff --git a/crates/sim/src/stats.rs b/crates/sim/src/stats.rs new file mode 100644 index 0000000..baaecf9 --- /dev/null +++ b/crates/sim/src/stats.rs @@ -0,0 +1,613 @@ +//! [`MatchStats`] (M6): what a match is summed up by, for the play-bots +//! CSV, `sim-cli match-log` and the game-over screen. +//! +//! Engine-free, unhashed and not part of the sim's state: the stats are a +//! pure function of the view after every step and the events that step +//! emitted, so the harness, the game (`SimHandle::drain_events`) and +//! `match-log` (a replay's re-simulation) all derive the same numbers. The +//! sim never reads them. Rates, cap time and army size are sampled once a +//! second of match time (`tick % tick_rate_hz == 0`); everything else is +//! read from events, or from the view on every observed tick (Town hit +//! points, the first unit on enemy land), so observing a step twice at the +//! same tick changes nothing. + +use crate::ai_hook::SimView; +use crate::ids::{BuildingId, BuildingKindId, PlayerId, TargetId, UnitKindId}; +use crate::state::{Construction, MatchSetup, SimEvent}; +use rules::{Rules, UnitClass}; +use std::collections::{BTreeMap, BTreeSet}; + +/// One player's statistics. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PlayerStats { + /// Units that entered play by kind (`UnitSpawned`: training, and + /// `DebugSpawn` in scenarios); the start units are not counted. + pub units_trained: BTreeMap, + /// Units that died by kind (`UnitDied`). + pub units_lost: BTreeMap, + /// Towns this player annexed (`TownAnnexed` to it). + pub towns_taken: u32, + /// Towns annexed from this player (`TownAnnexed` from it). + pub towns_lost: u32, + /// `(age index, tick)` of every `AgeAdvanced`, in order. + pub ages: Vec<(u8, u32)>, + /// Bonus resources received per resource (`BonusIncome`), + /// `resources.ron` order. + pub bonus: Vec, + /// Credited income per resource in 1/P resource units (P the income + /// period), summed over the once-a-second samples. + income_acc: Vec, + /// Samples (seconds) in which any resource sat at its Yield Cap + /// (`RateInfo::at_cap`, the HUD's amber readout). + pub cap_seconds: u32, + /// The most armed, non-civilian units the player had at a sample. + pub peak_army: u32, + /// The first observed tick at which one of the player's armed units + /// stood on another player's land. + pub first_on_land_tick: Option, + /// The lowest hit points, per mille, of any complete Town the player + /// started with or founded, while it owned it; 0 once one was annexed + /// from it. Towns it annexed never count. 1000 when untouched. + pub min_own_town_hp_permille: u32, +} + +impl PlayerStats { + /// Units that died, every kind. + pub fn units_lost_total(&self) -> u32 { + self.units_lost.values().sum() + } + + /// Units that entered play, every kind. + pub fn units_trained_total(&self) -> u32 { + self.units_trained.values().sum() + } + + /// Bonus resources received, every resource. + pub fn bonus_total(&self) -> i64 { + self.bonus.iter().sum() + } +} + +/// The statistics of one match (M6). Feed it with [`MatchStats::observe`] +/// after every step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchStats { + /// Ticks per sample: one second of match time. + sample_every: u32, + /// The income period P (`Rules::income_period_ticks`). + period: i64, + town_kind: BuildingKindId, + /// Per unit kind: armed (an attack) and not civilian. + armed: Vec, + players: Vec, + /// Per player: the Towns it received by annexation (never its own). + annexed_into: Vec>, + first_contact_tick: Option, + last_contact_tick: Option, + max_gap_ticks: u32, + last_tick: u32, + last_sample_tick: Option, + decided_tick: Option, +} + +impl MatchStats { + /// Empty statistics for a match of `setup` under `rules`. + pub fn new(setup: &MatchSetup, rules: &Rules) -> MatchStats { + let n = setup.players.len(); + let resources = rules.resources.resources.len(); + MatchStats { + sample_every: rules.tick_rate_hz.max(1), + period: i64::from(rules.income_period_ticks().max(1)), + town_kind: BuildingKindId(rules.town_kind()), + armed: rules + .units + .iter() + .map(|u| u.attack.is_some() && u.class != UnitClass::Civilian) + .collect(), + players: (0..n) + .map(|_| PlayerStats { + bonus: vec![0; resources], + income_acc: vec![0; resources], + min_own_town_hp_permille: 1000, + ..PlayerStats::default() + }) + .collect(), + annexed_into: vec![BTreeSet::new(); n], + first_contact_tick: None, + last_contact_tick: None, + max_gap_ticks: 0, + last_tick: 0, + last_sample_tick: None, + decided_tick: None, + } + } + + /// One player's statistics. + /// + /// # Panics + /// + /// If the player is not in the match. + pub fn player(&self, p: PlayerId) -> &PlayerStats { + &self.players[usize::from(p.0)] + } + + /// Every player's statistics, in id order. + pub fn players(&self) -> &[PlayerStats] { + &self.players + } + + /// Whether the unit kind counts toward the army (armed, not civilian). + fn is_armed(&self, kind: UnitKindId) -> bool { + self.armed + .get(usize::from(kind.0)) + .copied() + .unwrap_or(false) + } + + fn stats_mut(&mut self, p: PlayerId) -> Option<&mut PlayerStats> { + self.players.get_mut(usize::from(p.0)) + } + + /// Record one step: `view` is the sim after it, `events` what it + /// emitted. Steps after the deciding one are ignored. + pub fn observe(&mut self, view: &SimView<'_>, events: &[SimEvent]) { + if self.decided_tick.is_some() { + return; + } + let tick = view.tick; + for e in events { + self.event(view, events, e, tick); + } + self.every_tick(view, tick); + if tick > 0 && tick.is_multiple_of(self.sample_every) && self.last_sample_tick != Some(tick) + { + self.sample(view); + self.last_sample_tick = Some(tick); + } + self.last_tick = tick; + } + + fn event(&mut self, view: &SimView<'_>, events: &[SimEvent], e: &SimEvent, tick: u32) { + match e { + SimEvent::UnitSpawned { unit } => { + if let Some(u) = view.unit(*unit) { + let (owner, kind) = (u.owner, u.kind); + if let Some(s) = self.stats_mut(owner) { + *s.units_trained.entry(kind).or_default() += 1; + } + } + } + SimEvent::UnitDied { owner, kind, .. } => { + if let Some(s) = self.stats_mut(*owner) { + *s.units_lost.entry(*kind).or_default() += 1; + } + } + SimEvent::TownAnnexed { town, from, to } => { + let own = self + .annexed_into + .get(usize::from(from.0)) + .is_some_and(|set| !set.contains(town)); + if let Some(s) = self.stats_mut(*from) { + s.towns_lost += 1; + if own { + s.min_own_town_hp_permille = 0; + } + } + if let Some(s) = self.stats_mut(*to) { + s.towns_taken += 1; + } + if let Some(set) = self.annexed_into.get_mut(usize::from(from.0)) { + set.remove(town); + } + if let Some(set) = self.annexed_into.get_mut(usize::from(to.0)) { + set.insert(*town); + } + } + SimEvent::AgeAdvanced { player, age } => { + if let Some(s) = self.stats_mut(*player) { + s.ages.push((*age, tick)); + } + } + SimEvent::BonusIncome { player, amounts } => { + if let Some(s) = self.stats_mut(*player) { + for (total, a) in s.bonus.iter_mut().zip(amounts) { + *total = total.saturating_add(*a); + } + } + } + SimEvent::Hit { + attacker, target, .. + } => { + let a = owner_of(view, events, *attacker); + let t = owner_of(view, events, *target); + let cross = !matches!((a, t), (Some(a), Some(t)) if a == t); + if cross { + self.contact(tick); + } + } + SimEvent::MatchDecided { result } => { + self.decided_tick = Some(result.tick); + } + _ => {} + } + } + + /// A cross-player hit at `tick`: the first contact, and the quiet span + /// since the last one. + fn contact(&mut self, tick: u32) { + if self.first_contact_tick.is_none() { + self.first_contact_tick = Some(tick); + } + if let Some(last) = self.last_contact_tick { + self.max_gap_ticks = self.max_gap_ticks.max(tick.saturating_sub(last)); + } + self.last_contact_tick = Some(tick); + } + + /// The reads of every observed tick: own Town hit points and the first + /// armed unit on another player's land. + fn every_tick(&mut self, view: &SimView<'_>, tick: u32) { + for b in view.buildings() { + if b.kind != self.town_kind || b.construction != Construction::Complete { + continue; + } + let annexed = self + .annexed_into + .get(usize::from(b.owner.0)) + .is_none_or(|set| set.contains(&b.id)); + if annexed { + continue; + } + let Some((hp, max)) = view.building_hp(b.id) else { + continue; + }; + let permille = i64::from(hp.max(0)) * 1000 / i64::from(max.max(1)); + let permille = u32::try_from(permille.clamp(0, 1000)).unwrap_or(0); + if let Some(s) = self.stats_mut(b.owner) { + s.min_own_town_hp_permille = s.min_own_town_hp_permille.min(permille); + } + } + if self.players.iter().all(|s| s.first_on_land_tick.is_some()) { + return; + } + let territory = view.territory(); + let map = view.map(); + let mut found: Vec = Vec::new(); + for u in view.units() { + if !self.is_armed(u.kind) + || self + .players + .get(usize::from(u.owner.0)) + .is_none_or(|s| s.first_on_land_tick.is_some()) + || found.contains(&u.owner) + { + continue; + } + let land = territory.owner_at(map.tile_of(u.pos)); + if land.is_some_and(|q| q != u.owner) { + found.push(u.owner); + } + } + for p in found { + if let Some(s) = self.stats_mut(p) { + s.first_on_land_tick = Some(tick); + } + } + } + + /// The once-a-second sample: credited income, cap time and army size. + fn sample(&mut self, view: &SimView<'_>) { + let every = i64::from(self.sample_every); + let mut army = vec![0u32; self.players.len()]; + for u in view.units() { + if self.is_armed(u.kind) + && let Some(n) = army.get_mut(usize::from(u.owner.0)) + { + *n += 1; + } + } + for (i, s) in self.players.iter_mut().enumerate() { + let p = PlayerId(u8::try_from(i).expect("at most 255 players")); + let rates = view.income(p); + for (acc, r) in s.income_acc.iter_mut().zip(&rates) { + *acc = acc.saturating_add(i64::from(r.credited).saturating_mul(every)); + } + if rates.iter().any(|r| r.at_cap()) { + s.cap_seconds += 1; + } + s.peak_army = s.peak_army.max(army[i]); + } + } + + /// Total credited income (gathering and base yields, not the bonus) in + /// whole resources, every resource, from the samples. + pub fn income_total(&self, p: PlayerId) -> i64 { + self.player(p).income_acc.iter().sum::() / self.period + } + + /// The bonus's share of everything the player received, + /// `bonus / (bonus + income)` in per mille (0 with no income at all). + pub fn bonus_share_permille(&self, p: PlayerId) -> u32 { + let bonus = self.player(p).bonus_total().max(0); + let total = bonus.saturating_add(self.income_total(p).max(0)); + if total == 0 { + return 0; + } + u32::try_from(bonus.saturating_mul(1000) / total).unwrap_or(1000) + } + + /// The first tick a hit landed between two players' entities. + pub fn first_contact_tick(&self) -> Option { + self.first_contact_tick + } + + /// The longest span with no cross-player hit after the first contact, + /// up to the outcome (or the last observed tick); `None` before any + /// contact. + pub fn max_quiet_ticks(&self) -> Option { + let last = self.last_contact_tick?; + let end = self.decided_tick.unwrap_or(self.last_tick); + Some(self.max_gap_ticks.max(end.saturating_sub(last))) + } + + /// The deciding tick, once a `MatchDecided` was observed. + pub fn decided_tick(&self) -> Option { + self.decided_tick + } +} + +/// The owner of a hit's attacker or target: from the view after the step, +/// or, for an entity removed in that step, from its `UnitDied` or +/// `BuildingDestroyed` event. +fn owner_of(view: &SimView<'_>, events: &[SimEvent], id: TargetId) -> Option { + match id { + TargetId::Unit(u) => view.unit(u).map(|x| x.owner).or_else(|| { + events.iter().find_map(|e| match e { + SimEvent::UnitDied { unit, owner, .. } if *unit == u => Some(*owner), + _ => None, + }) + }), + TargetId::Building(b) => view.building(b).map(|x| x.owner).or_else(|| { + events.iter().find_map(|e| match e { + SimEvent::BuildingDestroyed { + building, owner, .. + } if *building == b => Some(*owner), + _ => None, + }) + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_hook::NoAi; + use crate::command::{Command, PlayerCommand}; + use crate::fx::FxVec2; + use crate::ids::{Tile, UnitId}; + use crate::state::{MatchResult, Outcome, OutcomeReason, Sim, StartKind}; + use std::path::Path; + + const P0: PlayerId = PlayerId(0); + const P1: PlayerId = PlayerId(1); + + fn rules() -> Rules { + Rules::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")).unwrap() + } + + fn unit_kind(rules: &Rules, id: &str) -> UnitKindId { + UnitKindId(u16::try_from(rules.units.iter().position(|u| u.id == id).unwrap()).unwrap()) + } + + fn step(sim: &mut Sim, stats: &mut MatchStats, cmds: &[PlayerCommand]) { + sim.step(cmds); + let events = sim.drain_events(); + stats.observe(&sim.view(), &events); + } + + /// M6: deaths by owner and kind, annexations both ways, ages, the bonus + /// and cross-player contact come from events; cap seconds from the + /// once-a-second samples (a Yield Cap of 0 puts every capped resource + /// at the cap from the first second). Observing a tick twice changes + /// nothing, and nothing is counted after the deciding step. + #[test] + fn match_stats_count_deaths_annexations_ages_and_cap_seconds() { + let mut r = rules(); + r.yield_cap_base = 0; + let mut setup = MatchSetup::scenario(&r, 1); + setup.start = StartKind::Standard; + let mut sim = Sim::new(setup.clone(), r.clone(), Box::new(NoAi)); + let mut stats = MatchStats::new(&setup, &r); + for _ in 0..40 { + step(&mut sim, &mut stats, &[]); + } + stats.observe(&sim.view(), &[]); + for p in [P0, P1] { + assert_eq!(stats.player(p).cap_seconds, 2, "samples at ticks 20 and 40"); + assert_eq!(stats.player(p).peak_army, 0, "Yeomen are civilians"); + assert_eq!(stats.player(p).units_trained_total(), 0, "start units"); + } + let yeoman = unit_kind(&r, "yeoman"); + let skirmisher = unit_kind(&r, "skirmisher"); + let n = r.resources.resources.len(); + let mut bonus = vec![0i64; n]; + bonus[0] = 20; + bonus[2] = 10; + let seat0 = TargetId::Building(BuildingId(1)); + let synthetic = [ + SimEvent::UnitDied { + unit: UnitId(900), + owner: P1, + kind: yeoman, + }, + SimEvent::UnitDied { + unit: UnitId(901), + owner: P1, + kind: yeoman, + }, + SimEvent::UnitDied { + unit: UnitId(902), + owner: P0, + kind: skirmisher, + }, + SimEvent::TownAnnexed { + town: BuildingId(2), + from: P1, + to: P0, + }, + SimEvent::AgeAdvanced { player: P0, age: 1 }, + SimEvent::BonusIncome { + player: P0, + amounts: bonus.clone(), + }, + // P0's own Yeomen: not contact. + SimEvent::Hit { + attacker: TargetId::Unit(UnitId(1)), + target: TargetId::Unit(UnitId(2)), + damage: 1, + }, + ]; + stats.observe(&sim.view(), &synthetic); + assert_eq!(stats.first_contact_tick(), None); + assert_eq!(stats.max_quiet_ticks(), None); + // A P1 Yeoman (UnitId(6)) hits P0's Seat at tick 40. + let hit = SimEvent::Hit { + attacker: TargetId::Unit(UnitId(6)), + target: seat0, + damage: 1, + }; + stats.observe(&sim.view(), std::slice::from_ref(&hit)); + assert_eq!(stats.first_contact_tick(), Some(40)); + for _ in 0..30 { + step(&mut sim, &mut stats, &[]); + } + assert_eq!(stats.max_quiet_ticks(), Some(30)); + stats.observe(&sim.view(), std::slice::from_ref(&hit)); + for _ in 0..10 { + step(&mut sim, &mut stats, &[]); + } + assert_eq!(stats.max_quiet_ticks(), Some(30), "30 ticks, then 10"); + let p0 = stats.player(P0); + let p1 = stats.player(P1); + assert_eq!(p1.units_lost.get(&yeoman), Some(&2)); + assert_eq!((p0.units_lost_total(), p1.units_lost_total()), (1, 2)); + assert_eq!((p0.towns_taken, p0.towns_lost), (1, 0)); + assert_eq!((p1.towns_taken, p1.towns_lost), (0, 1)); + assert_eq!(p0.ages, [(1, 40)]); + assert_eq!(p0.bonus, bonus); + assert_eq!(p0.bonus_total(), 30); + assert_eq!(p1.bonus_total(), 0); + assert_eq!(p1.min_own_town_hp_permille, 0, "its Seat was annexed"); + assert_eq!(p0.min_own_town_hp_permille, 1000); + assert_eq!(p0.cap_seconds, 4); + let income = stats.income_total(P0); + assert_eq!( + stats.bonus_share_permille(P0), + u32::try_from(30 * 1000 / (30 + income)).unwrap() + ); + // The deciding step closes the books. + let decided = SimEvent::MatchDecided { + result: MatchResult { + outcome: Outcome::Victory { + player: P0, + decisive: true, + }, + reason: OutcomeReason::SeatCaptured, + tick: 80, + }, + }; + stats.observe(&sim.view(), &[decided]); + let before = stats.clone(); + for _ in 0..25 { + step(&mut sim, &mut stats, &[]); + } + stats.observe(&sim.view(), &synthetic); + assert_eq!(stats, before); + assert_eq!(stats.decided_tick(), Some(80)); + assert_eq!(stats.max_quiet_ticks(), Some(30)); + } + + /// M6: a side's lowest Town counts only the Towns it started with or + /// founded. P0's own Town at 60% is its lowest even after it annexes + /// one of P1's Towns, which comes back at 250 per mille; P1's lowest is + /// 0 once its Town is annexed. A real annexation, with a 10-tick timer. + #[test] + fn match_stats_keep_town_hp_by_original_owner() { + let mut r = rules(); + r.annexation_ds = 5; + let town = BuildingKindId(r.building_kind_by_id("town").unwrap().0); + let town_hp = r.building_kind(town.0).unwrap().hp; + let skirmisher = unit_kind(&r, "skirmisher"); + let setup = MatchSetup::scenario(&r, 1); + let mut sim = Sim::new(setup.clone(), r.clone(), Box::new(NoAi)); + let mut stats = MatchStats::new(&setup, &r); + let mut seq = 0; + let mut cmd = |c: Command| { + seq += 1; + PlayerCommand::new(P0, seq, c) + }; + let place = |owner, x, y| Command::DebugPlaceBuilding { + owner, + kind: town, + tile: Tile::new(x, y), + complete: true, + }; + // P1's second Town (BuildingId(3)) keeps the match undecided after + // the annexation, so the samples go on. + let mut tick0 = vec![ + cmd(place(P0, 59, 30)), + cmd(place(P1, 20, 30)), + cmd(place(P1, 40, 30)), + ]; + for x in 20..=22 { + tick0.push(cmd(Command::DebugSpawn { + owner: P0, + kind: skirmisher, + at: FxVec2::from_ints(x, 29), + count: 1, + })); + } + step(&mut sim, &mut stats, &tick0); + let damage = |b: u32, amount: u32| Command::DebugDamage { + target: TargetId::Building(BuildingId(b)), + amount, + }; + let tick1 = [cmd(damage(2, u32::MAX)), cmd(damage(1, town_hp * 2 / 5))]; + step(&mut sim, &mut stats, &tick1); + let mut annexed_at = None; + for _ in 0..80 { + sim.step(&[]); + let events = sim.drain_events(); + if events.iter().any(|e| { + matches!( + e, + SimEvent::TownAnnexed { + from: P1, + to: P0, + .. + } + ) + }) { + annexed_at = Some(sim.tick()); + } + stats.observe(&sim.view(), &events); + } + assert!(annexed_at.is_some(), "P0 annexed P1's Town"); + let flipped = sim.view().building(BuildingId(2)).unwrap(); + assert_eq!(flipped.owner, P0); + let (hp, max) = sim.view().building_hp(BuildingId(2)).unwrap(); + assert_eq!(i64::from(hp) * 1000 / i64::from(max), 250); + assert_eq!(stats.player(P0).min_own_town_hp_permille, 600); + assert_eq!(stats.player(P1).min_own_town_hp_permille, 0); + assert_eq!(stats.player(P0).towns_taken, 1); + assert_eq!(stats.player(P1).towns_lost, 1); + assert_eq!(stats.player(P0).units_trained.get(&skirmisher), Some(&3)); + assert_eq!(stats.player(P0).peak_army, 3); + assert!( + stats.player(P0).first_on_land_tick.is_some(), + "the Skirmishers stand on P1's land" + ); + assert_eq!(stats.player(P1).first_on_land_tick, None); + assert_eq!(sim.view().result(), None, "P1 keeps its second Town"); + } +} diff --git a/crates/sim/tests/fixtures/capture_seat.eonreplay b/crates/sim/tests/fixtures/capture_seat.eonreplay index fb9387c..dd7b702 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 3bf9445..99ed722 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 d7c781b..60560cb 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 28211d2..1d44556 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 25824c4..c1d5150 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 32c03ac..e1acea6 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 befab68..5aa1421 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/m5_first_match.hash b/crates/sim/tests/fixtures/m5_first_match.hash index f6e307b..6c8cca5 100644 --- a/crates/sim/tests/fixtures/m5_first_match.hash +++ b/crates/sim/tests/fixtures/m5_first_match.hash @@ -1 +1 @@ -0x3d2552033d51b4a2 +0xa9d87378122649bf diff --git a/crates/sim/tests/fixtures/move_500.eonreplay b/crates/sim/tests/fixtures/move_500.eonreplay index 93aa435..7c6fa65 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 78a382c..cda6f54 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 5b65198..2013246 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 287cb3a..daef8f4 100644 Binary files a/crates/sim/tests/fixtures/stalemate_45min.eonreplay and b/crates/sim/tests/fixtures/stalemate_45min.eonreplay differ diff --git a/crates/sim/tests/m5a.rs b/crates/sim/tests/m5a.rs index eefc622..51b0081 100644 --- a/crates/sim/tests/m5a.rs +++ b/crates/sim/tests/m5a.rs @@ -2238,18 +2238,29 @@ fn annexation_during_the_timer_research_is_rejected_and_a_research_entry_pauses( } /// The `capture_seat` scenario (`sim::scenarios::capture_seat`) run to the -/// first step with an outcome, at most `CAPTURE_SEAT_MAX_TICKS`. -fn capture_seat_to_outcome(rules: &Rules) -> (Sim, sim::scenarios::Stream, Vec<(u32, SimEvent)>) { +/// first step with an outcome, at most `CAPTURE_SEAT_MAX_TICKS`, with the +/// M6 `MatchStats` fed after every step. +fn capture_seat_to_outcome( + rules: &Rules, +) -> ( + Sim, + sim::scenarios::Stream, + Vec<(u32, SimEvent)>, + sim::MatchStats, +) { let (setup, stream, cap) = sim::scenarios::by_name("capture_seat", rules).unwrap(); + let mut stats = sim::MatchStats::new(&setup, rules); let mut sim = Sim::new(setup, rules.clone(), Box::new(NoAi)); let mut events = Vec::new(); while sim.outcome().is_none() { let t = sim.tick(); assert!(t < cap, "decided within {cap} ticks"); sim.step(sim::scenarios::commands_at(&stream, t)); - events.extend(sim.drain_events().into_iter().map(|e| (t, e))); + let step_events = sim.drain_events(); + stats.observe(&sim.view(), &step_events); + events.extend(step_events.into_iter().map(|e| (t, e))); } - (sim, stream, events) + (sim, stream, events, stats) } /// Acceptance 2, fourth clause: capturing the Seat makes `outcome()` return @@ -2262,7 +2273,7 @@ fn capture_seat_to_outcome(rules: &Rules) -> (Sim, sim::scenarios::Stream, Vec<( #[test] fn annexation_capturing_the_seat_makes_outcome_a_decisive_victory() { let rules = load_rules(); - let (mut sim, stream, events) = capture_seat_to_outcome(&rules); + let (mut sim, stream, events, _) = capture_seat_to_outcome(&rules); let seat = BuildingId(2); let result = sim.result().unwrap(); assert_eq!( @@ -3237,7 +3248,7 @@ fn annexation_view_remaining_ticks_agrees_with_the_flip() { fn annexation_capture_seat_scenario_beats() { use sim::scenarios::{CAPTURE_SEAT_MOVE_TICK, CAPTURE_SEAT_RING}; let rules = load_rules(); - let (sim, _, events) = capture_seat_to_outcome(&rules); + let (sim, _, events, stats) = capture_seat_to_outcome(&rules); let seat = BuildingId(2); let rejected: Vec<_> = events .iter() @@ -3273,6 +3284,20 @@ fn annexation_capture_seat_scenario_beats() { assert_eq!(bled(u), 0, "{u}"); } assert_eq!(CAPTURE_SEAT_RING.len(), 8); + // M6: the same run's `MatchStats`. P0 took P1's Seat, P1 lost its five + // Yeomen (and its Seat: its lowest own Town is 0), and the Seat P0 + // annexed, back at 250 per mille, never counts as P0's own. + let yeoman = UnitKindId( + u16::try_from(rules.units.iter().position(|u| u.id == "yeoman").unwrap()).unwrap(), + ); + let (s0, s1) = (stats.player(P0), stats.player(P1)); + assert_eq!((s0.towns_taken, s0.towns_lost), (1, 0)); + assert_eq!((s1.towns_taken, s1.towns_lost), (0, 1)); + assert_eq!(s1.units_lost.get(&yeoman), Some(&5)); + assert_eq!(s1.min_own_town_hp_permille, 0); + assert_eq!(s0.min_own_town_hp_permille, 1000); + assert_eq!(stats.decided_tick(), Some(sim.tick())); + assert!(stats.first_contact_tick().is_some()); println!( "capture_seat: P1's Yeomen dead at {p1_dead:?}, Seat at 0 HP in step {zero}, timer from step {entry}, decided at tick {} ({:?}), the Bowman took {} pulses", sim.tick(), @@ -3597,7 +3622,7 @@ fn annexation_outcome_tiebreak_goes_to_either_player() { #[test] fn annexation_outcome_surrender_beats_a_same_step_seat_capture() { let rules = load_rules(); - let (sim, _, _) = capture_seat_to_outcome(&rules); + let (sim, _, _, _) = capture_seat_to_outcome(&rules); let decision = sim.result().unwrap().tick; let (setup, stream, _) = sim::scenarios::by_name("capture_seat", &rules).unwrap(); let delay = setup.cmd_delay; diff --git a/data/ai/README.md b/data/ai/README.md index 4c79da7..0e213f0 100644 --- a/data/ai/README.md +++ b/data/ai/README.md @@ -1,7 +1,7 @@ # data/ai/ This directory holds the scripted opponent's data: the personalities and -their build orders (M5b), and later the difficulty table (M6). The schema +their build orders (M5b), and the difficulty levels (M6). The schema lives in `crates/rules/src/ai.rs`; `rules::Rules::load` reads this directory last, after the maps, validates it against everything before it and attaches it as `Rules::ai`, so it is part of `rules_hash`. An edit here without a @@ -10,24 +10,30 @@ recording`; the data-only path is the one in [`../rules/README.md`](../rules/README.md) ("Worked example"): edit, bump `rules_version`, `just rerecord`, run the tests; for a build order or a personality, [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md) ("A build order -or personality") adds the `play-bots` comparison. `sim-cli data-check` prints -the counts as `personalities=1 build_orders=1`. +or personality") adds the `play-bots` comparison, and ("A difficulty") the +difficulty checks. `sim-cli data-check` prints the counts as +`personalities=4 build_orders=4 difficulties=3`. | Path | Contents | Arrives | |------|----------|---------| | `personalities.ron` | One row per personality: its build order and every knob of the bot's rules | M5b (`standard`); M6 adds rush, boom and tower | -| `build_orders/.ron` | One build order per file; the `id` must equal the file stem. Non-`.ron` files here are ignored | M5b (`standard.ron`) | -| `difficulty.ron` | Easy, Standard, Hard: the bot income interval (documented as a cheat) and aggression | M6 | +| `build_orders/.ron` | One build order per file; the `id` must equal the file stem. Non-`.ron` files here are ignored | M5b (`standard.ron`); M6 adds `rush.ron`, `boom.ron` and `tower.ron` | +| `difficulty/.ron` | One difficulty level per file (`easy`, `standard`, `hard`); the `id` must equal the file stem, `standard` must exist and `passive` is reserved. The disclosed bonus bundle the sim pays a bot seat (the income cheat) and the wave knobs the level scales. Non-`.ron` files here are ignored | M6 | The bots themselves are in `crates/ai`: `ai::Passive` (never acts), the data-driven `ai::standard::StandardBot`, and `ai::Scripted` (a fixed command list for `sim-cli selftest`). A match header names each AI slot's bot and personality (`MatchSetup.ai_seats`, for example `standard` with personality -`standard`), and `ai::controller_for` builds the bots from it. +`standard`), and `ai::controller_for` builds the bots from it. Since M6 the +`bot` is `passive` or a difficulty id: every difficulty is the Standard bot +playing its personality's knobs as the level resolves them +(`rules::ai::resolve`), while the sim pays the level's bonus. Rule for every bot: it is a deterministic function of `sim::SimView` and its -own hashed memory (`Player::ai_memory`). No clocks, no floats, no randomness -of its own; `crates/ai/clippy.toml` enforces this. +own hashed memory (`Player::ai_memory`). No clocks, no floats, no generator +of its own; `crates/ai/clippy.toml` enforces this. Its only noise (M6) is +`SimView::ai_noise(player, salt)`, a pure hash of the hashed seed, the +player and a salt that never draws from the sim's `Pcg32`. ## Units @@ -170,6 +176,31 @@ Engine Yard fill the army with Mangonels. | `defend_radius_tiles`, `defend_regoal_tiles`, `defend_release_ds`, `defend_max_block_ds` | Enemy military on own land this close to an own Town is a threat; a guard is re-ordered beyond this distance from the nearest threat; defence ends after this long with no threat; it blocks launches, and calls a staging wave home, for at most this long | 1 to 64; 1 to 64; 1 to 72000; 1 to 72000 | | `defend_guard_pop_ratio` | Past `defend_max_block_ds`, while a launch is due, the home guard is the home armed units nearest the threat (siege last) until their pop reaches this many times the threat's pop, at least one unit; the rest go back to the rally, and those at the rally are left to the wave | 1 to 64 | | `recall_wave`, `recall_wave_town_hp_permille` | `WhileStaging` (a staging wave while the defence still blocks launches, or an assaulting one when any threatened Town, the engaged one or another, is contested or at or below the share of its hit points) or `Always` | 0 to 1000 | +| `random` | In the pool the `random` personality choice draws from (`play-bots --personality-a/-b random`, the game's `--personality random`; `ai::resolve_personality`, a hash of the seed and the seat, never the sim's generator). Required in every row since M6's slice 2; `standard` is `false`, rush, boom and tower are `true`. Adding a personality to the pool remaps which style a seed draws | `true` or `false` | + +M6 adds the knobs below (slice 2). Every row states each of them; no +difficulty scales them, except that `rules::ai::resolve` caps the raid sizes +at the level's `max_wave_units`. + +| Field | Meaning | Range | +|-------|---------|-------| +| `wave_mode` | `Siege` (every wave is a siege wave) or `RaidUntilSiege` (raids while the bot has fewer than `min_siege` siege units, at least one) | | +| `wain_rule` | `Always`: a siege wave takes a Supply Wain; `IfEnemyHarrying`: only once the enemy has researched Harrying | | +| `wave_requires_tech` | No wave or raid before this tech (the level's deadline raid ignores it) | a tech id or `None` | +| `seat_last` | A siege wave skips the enemy Seat while the enemy holds another complete, uncontested Town | `true` or `false` | +| `raid_min_armed`, `raid_max_armed` | Home armed units a raid needs, and the most it takes | 1 to 64, min at most max | +| `raid_every_ds`, `raid_max_ds`, `raid_idle_ds`, `raid_idle_tiles` | Time between raids; a raid comes home after `raid_max_ds`, or after `raid_idle_ds` with no enemy unit within `raid_idle_tiles` | 1 to 72000; 1 to 64 tiles | +| `raid_worker_weight`, `raid_distance_permille` | Raid target score: per worker at the enemy building, less the enemy strength near it, less this share of its distance from the rally | 0 to 1000; 0 to 10000 | +| `retreat_ratio_permille`, `retreat_loss_permille` | A wave retreats when its own strength is below this share of the enemy's around it and it has lost at least `retreat_loss_permille` of the armed units it launched with (0: never) | 0 to 10000; 0 to 1000 | +| `retreat_commit_ds`, `retreat_commit_town_hp_permille`, `retreat_cooldown_ds` | No retreat within `retreat_commit_ds` of the assault order or while the target Town is at or below this share of its hit points; after a retreat the next launch waits `retreat_cooldown_ds` | 0 to 72000; 0 to 1000; 0 to 72000 | +| `influence_cell_tiles`, `influence_radius_cells` | The influence maps' cell side and the cells around a point a query sums | 1 to 16; 0 to 8 | +| `target_strength_weight` | Siege target score per unit of the square root of the enemy strength at a Town, against 1000 per tile of distance | 0 to 1000 | +| `counter_weight_permille`, `counter_floor_permille`, `counter_cap_permille`, `counter_join_permille` | How strongly the visible enemy kinds bend the army mix (0: off), the factor's floor and cap, and the factor at which a `counter_kinds` entry joins | 0 to 8000; 0 to 1000; 1000 to 8000; 1000 to the cap | +| `counter_kinds` | `[(kind, weight)]`: armed kinds outside `army_mix` that join it while they counter what the enemy shows | armed, non-civilian kinds, each once; weights 1 to 1000 | +| `defend_economy`, `defend_economy_tiles` | Enemy military this close to an own worked gathering building is a threat too | `true` or `false`; 1 to 64 | +| `defend_wains_first` | The guard attacks enemy Supply Wains among the threats first | `true` or `false` | +| `reactive_towers` | After a defence the bot adds one Watchtower toward the defended building, up to this many in a match | 0 to the cap | +| `noise_wave_interval_permille`, `noise_wave_units`, `noise_first_wave_ds`, `noise_mix_permille`, `noise_site_top_k` | Bounded jitter from `SimView::ai_noise`: the wave interval (per mille), extra armed units a wave needs (upward only), extra wait before the first wave, each mix weight (per mille), and the `Expansion` and `BorderEdge` candidates a site is picked among (1: always the best) | 0 to 500; 0 to 64; 0 to 72000; 0 to 500; 1 to 8 | Since M5b's waves slice the bot reads every knob. A **defence** (enemy military on own land near an own Town) sends every home armed unit at the @@ -198,3 +229,89 @@ fewer than `recall_below_units`, or older than `wave_max_ds` and not annexing comes home. From `all_in_after_ds` every home armed unit joins the wave out and nothing is recalled. The rules and their order are in docs/design/ai.md and `crates/ai/src/standard/waves.rs`. + +## Difficulty levels (`difficulty/.ron`, M6) + +```ron +( + id: "hard", name: "Hard", + income_interval_ds: 200, // Easy 300, Standard 250; 0 = no bonus + income_from_ds: 1800, // payments complete at 3:00 + n intervals + income: {"grain": 20, "lumber": 20, "ore": 10}, // the same in all three files + wave_age_gate: Off, // Personality | Age("masonry") | Off + largest_wave_only: false, + max_wave_units: None, + wave_size_permille: 1000, + wave_interval_permille: 800, + all_in_after_ds: None, // None keeps the personality's + first_raid: Some((by_ds: 6000, prep_ds: 1500, min_armed: 6, max_armed: 10)), + worker_target_permille: 1000, + extra_research: [], + pressure: ( + reinforce_min_units: 6, + commit_ds: 300, + retreat_ratio_permille: 0, + retreat_cooldown_permille: 1000, + home_guard_units: 0, + lure_ds: 600, + cohesion_tiles: 4, + launch_ratio_permille: 0, + assault_ratio_permille: 1300, + ), +) +``` + +Difficulty is a disclosed cheat plus wave timing and, since the M6 retune +(M6 owner decision 12, `rules_version` 30; 31 after its review), how hard +the level presses its sieges: Easy stays defensive (its largest wave only, +later, no pressure, Statecraft II for wider borders), Standard and Hard +reinforce and commit; Standard retreats as its personality does, Hard +never retreats (a deviation from the plan's equal retreat, pending the +owner: docs/design/ai.md, "Difficulty table (as retuned)"). The sim pays every AI seat whose `bot` names a level the +same `income` bundle on the step that completes tick `from + k x every` +(k >= 1, `from` = `income_from_ds` and `every` = `income_interval_ds` in +ticks): Standard's first payment lands at 3:25 (tick 4100), Hard's at 3:20 +(4000) and Easy's at 3:30 (4200). The bundle is added to the stockpile and +clamped to the stockpile caps (Lore), and the sim emits `BonusIncome` with +what was credited (step 6d', docs/DETERMINISM.md). Human slots and +`passive` seats get nothing, and the bonus never reads the opponent's state +(no rubber band). The bundle is the same in every file, so the interval +alone sizes the cheat: a shorter interval is a larger cheat. + +| Field | Meaning | Range | +|-------|---------|-------| +| `id`, `name` | Stable id (the header's `bot`, `play-bots --a/--b`, the game's `--difficulty`) and display name | the file stem; not `passive`; non-empty name | +| `income_interval_ds` | The bonus is paid every this many deciseconds | 0 (no bonus), or 50 to 72000 and in ticks more than `cmd_delay_ticks` | +| `income_from_ds` | The schedule counts from this match time; the first payment completes one interval later | below `match_cap_ds` | +| `income` | The bonus bundle, `{resource id: amount}` | resource ids of `resources.ron`; each 0 to 500 | +| `wave_age_gate` | `Personality` keeps the personality's `wave_requires_age`; `Age(id)` replaces it; `Off` removes it | an age id for `Age` | +| `largest_wave_only` | Every `wave_sizes` row takes the last (largest) row's size | `true` or `false` | +| `max_wave_units` | Caps every wave and the personality's `max_wave_units` | `None`, or 1 to 64 | +| `wave_size_permille` | Scales every `wave_sizes` row, rounded up, at least 1, at most the cap | 250 to 4000 | +| `wave_interval_permille` | Scales `wave_interval_ds`, rounded to nearest | 250 to 4000 | +| `all_in_after_ds` | Replaces the personality's all-in time | `None`, or 0 to 72000 | +| `first_raid` | `Some((by_ds, prep_ds, min_armed, max_armed))`: the deadline raid. If no wave has gone out by `by_ds`, a raid of up to `max_armed` home armed units (at least `min_armed`) goes at once, past every gate; from `by_ds - prep_ds` the bot trains raiders toward `min_armed` even before its military latch | `1 <= min_armed <= max_armed <= 64`, `prep_ds < by_ds`, `by_ds` below `match_cap_ds` | +| `worker_target_permille` | Scales every `Workers` count of the build order, rounded up, at least 1 (`rules::ai::resolve_order`): the lever of M6 owner decision 2's disclosed Easy handicap (850 is 15% fewer Yeomen); 1000 is none | 500 to 1000 | +| `extra_research` | Tech ids the level researches beyond its build order: each becomes an optional `Research` step appended to the resolved order (`rules::ai::resolve_order`), skipped when the order already researches it (M6 owner decision 12: Easy's Statecraft II, a defensive Easy holding wider borders) | at most 8 tech ids, each once | +| `pressure` | How the level presses its sieges (M6 owner decision 12), the fields below; all off is `(reinforce_min_units: 0, commit_ds: 0, retreat_ratio_permille: 1000, retreat_cooldown_permille: 1000, home_guard_units: 0, lure_ds: 0, cohesion_tiles: 0, launch_ratio_permille: 0, assault_ratio_permille: 0)` | | +| `pressure.reinforce_min_units` | While a siege wave assaults or holds its target (or, with `assault_ratio_permille`, stages short of the strength to assault), the home armed units at the rally (within `assemble_radius_tiles`, beyond the home guard) join it as one group once at least this many stand there, attack-moving to the target (to the stage point while it stages); during the all-in the launch and the joiners go in such groups from the rally instead of one unit at a time from anywhere; never while the defence blocks launches | 0 (off) to 64 | +| `pressure.commit_ds` | A siege wave whose target Town was seen losing hit points within this long neither retreats nor ends on `wave_max_ds` | 0 (off) to 72000 | +| `pressure.retreat_ratio_permille`, `pressure.retreat_cooldown_permille` | Scale the personality's `retreat_ratio_permille` (0: the level never retreats) and `retreat_cooldown_ds`, rounded to nearest | 0 to 4000; 250 to 4000 | +| `pressure.home_guard_units` | Armed units every launch, reinforcement and all-in joiner leaves at home | 0 to 64 | +| `pressure.lure_ds` | For this long after its assault order a siege wave holds at its Wain point (out of every enemy shooter's reach) while an enemy armed unit stands within `siege_focus_tiles` of the target, so the defenders come out to it away from the Town's and Watchtowers' fire | 0 (off) to 72000 | +| `pressure.cohesion_tiles` | While a siege wave assaults, its armed units more than this many tiles nearer the goal than its rear quarter stop (a stopped unit guards and fights where it stands) until the rest close up; only the units past the stage point count (a group still on its way from the rally holds nobody up), and a unit within an enemy shooter's reach is never stopped | 0 (off) to 64 | +| `pressure.launch_ratio_permille` | A siege wave (not the all-in, not a raid) launches only when the Lanchester strength (`sum(hp) x sum(dps)`) of the units it would take, times 1000, reaches this times that of the enemy armed units standing on the enemy's own land | 0 (off) to 10000 | +| `pressure.assault_ratio_permille` | A staged siege wave (not the all-in) starts its assault only when its own Lanchester strength times 1000 reaches this times that of the enemy armed units on the enemy's own land; short of it the wave holds at its stage point (a rally group of `reinforce_min_units` joins it there) and, still short at `stage_max_ds`, comes home | 0 (off) to 10000 | + +`rules::ai::resolve(personality, difficulty)` applies the wave knobs once, a +pure function of the two rows (so a bot rebuilt mid-match plays the same +knobs); counters, influence and noise stay the personality's at every +level, and retreat is the personality's scaled by `pressure`. The +`standard` level resolves to the personality with its all-in at 36:00 +(retreat unchanged since `rules_version` 31); `resolve` leaves the rest of `pressure` to the bot +(`crates/ai`, `waves.rs` and `retreat.rs`), which reads it from the level. M6 +also requires every build order to build a trainer of an armed kind before +its first `Advance` (a difficulty's raid trains armed units before then). +The shipped values, the bundle sweep and the line-4 flip (`hard.ron` +`income_interval_ds` 200 to 300) are in docs/design/ai.md, "Difficulty +table". diff --git a/data/ai/build_orders/boom.ron b/data/ai/build_orders/boom.ron new file mode 100644 index 0000000..007a13d --- /dev/null +++ b/data/ai/build_orders/boom.ron @@ -0,0 +1,47 @@ +// Boom (M6): a second Town at once, Statecraft I and a third Town, Trade I +// and Letters I, one early Muster Hall (production waits for the Masonry +// Age unless the bot must defend), then Trade II and large siege waves. +// Schema: crates/rules/src/ai.rs (deny_unknown_fields). Knobs: +// personalities.ron. +( + id: "boom", + steps: [ + Workers(count: 7, split: {"grain": 3, "lumber": 4}), + Build(kind: "croft", count: 2, at: NearTown), + Build(kind: "lumber_yard", count: 1, at: Terrain), + Build(kind: "town", count: 2, at: Expansion, optional: true), // second Town + Workers(count: 10, split: {"grain": 4, "lumber": 6}), + Build(kind: "scriptorium", count: 1, at: NearTown), + Train(kind: "scribe", count: 3), + Build(kind: "croft", count: 4, at: NearTown), + Build(kind: "lumber_yard", count: 2, at: Terrain), + Research(tech: "statecraft_1"), // age tech 1; a third Town + Workers(count: 13, split: {"grain": 5, "lumber": 6, "ore": 2}), + Build(kind: "ore_pit", count: 1, at: Terrain), + Research(tech: "trade_1"), // age tech 2 + Build(kind: "town", count: 3, at: Expansion, optional: true), // third Town + Build(kind: "muster_hall", count: 1, at: NearTown), // the early Muster Hall + Research(tech: "arms_1"), // pop 25 -> 50 + Workers(count: 17, split: {"grain": 7, "lumber": 7, "ore": 3}), + Build(kind: "croft", count: 7, at: NearTown, optional: true), // needs the 2nd Town + Research(tech: "letters_1"), + Advance(age: "masonry"), + Research(tech: "trade_2"), + Build(kind: "scriptorium", count: 2, at: NearTown), + Train(kind: "scribe", count: 6), + Build(kind: "engine_yard", count: 1, at: NearTown), + Military, // production starts; latched + Train(kind: "mangonel", count: 2), + Build(kind: "muster_hall", count: 2, at: NearTown), + Research(tech: "arms_2"), + Workers(count: 21, split: {"grain": 9, "lumber": 8, "ore": 4}), + Build(kind: "lumber_yard", count: 3, at: Terrain), + Build(kind: "ore_pit", count: 2, at: Terrain), + Research(tech: "statecraft_2"), + Advance(age: "charter"), + Research(tech: "arms_3"), + Workers(count: 25, split: {"grain": 10, "lumber": 10, "ore": 5}), + Build(kind: "muster_hall", count: 3, at: NearTown), + Build(kind: "croft", count: 10, at: NearTown, optional: true), + ], +) diff --git a/data/ai/build_orders/rush.ron b/data/ai/build_orders/rush.ron new file mode 100644 index 0000000..c18005f --- /dev/null +++ b/data/ai/build_orders/rush.ron @@ -0,0 +1,46 @@ +// Rush (M6): a Muster Hall as the fourth step and army production from the +// fifth, so Skirmisher and Bowman raids start around minute 5; Masonry +// after Arms I and Trade I, then small siege waves. Schema: +// crates/rules/src/ai.rs (deny_unknown_fields). Knobs: personalities.ron. +( + id: "rush", + steps: [ + Workers(count: 7, split: {"grain": 4, "lumber": 3}), + Build(kind: "croft", count: 2, at: NearTown), + Build(kind: "lumber_yard", count: 1, at: Terrain), + Build(kind: "muster_hall", count: 1, at: NearTown), + Military, // production starts; latched + Workers(count: 9, split: {"grain": 5, "lumber": 4}), + Build(kind: "scriptorium", count: 1, at: NearTown), + Build(kind: "croft", count: 3, at: NearTown), + Train(kind: "scribe", count: 2), + Build(kind: "ore_pit", count: 1, at: Terrain), + Workers(count: 11, split: {"grain": 5, "lumber": 4, "ore": 2}), + Research(tech: "arms_1"), // age tech 1; pop 25 -> 50 + Build(kind: "croft", count: 4, at: NearTown), + Build(kind: "lumber_yard", count: 2, at: Terrain), + Workers(count: 13, split: {"grain": 6, "lumber": 5, "ore": 2}), + Research(tech: "trade_1"), // age tech 2 + Advance(age: "masonry"), + Build(kind: "engine_yard", count: 1, at: NearTown), + Train(kind: "mangonel", count: 1), + Build(kind: "town", count: 2, at: Expansion, optional: true), + Train(kind: "scribe", count: 3), + Research(tech: "arms_2"), + Build(kind: "croft", count: 5, at: NearTown), + Workers(count: 16, split: {"grain": 7, "lumber": 6, "ore": 3}), + Build(kind: "muster_hall", count: 2, at: NearTown), + Build(kind: "scriptorium", count: 2, at: NearTown), + Train(kind: "scribe", count: 6), + Research(tech: "trade_2"), + Research(tech: "letters_1"), + Research(tech: "statecraft_1"), + Advance(age: "charter"), // 6 counting techs before it + Research(tech: "arms_3"), + Build(kind: "croft", count: 8, at: NearTown, optional: true), // needs the 2nd Town + Build(kind: "lumber_yard", count: 3, at: Terrain), + Workers(count: 20, split: {"grain": 8, "lumber": 8, "ore": 4}), + Build(kind: "ore_pit", count: 2, at: Terrain), + Workers(count: 23, split: {"grain": 8, "lumber": 10, "ore": 5}), + ], +) diff --git a/data/ai/build_orders/standard.ron b/data/ai/build_orders/standard.ron index 23b52a0..54a2e0d 100644 --- a/data/ai/build_orders/standard.ron +++ b/data/ai/build_orders/standard.ron @@ -19,11 +19,15 @@ Build(kind: "muster_hall", count: 1, at: NearTown), Military, // production starts; latched Research(tech: "arms_1"), // age tech 1; pop 25 -> 50 + // M6: a second Muster Hall now and a third after Masonry, so income + // (the level's bonus included) becomes army instead of a stockpile. + Build(kind: "muster_hall", count: 2, at: NearTown), Build(kind: "croft", count: 5, at: NearTown), Workers(count: 14, split: {"grain": 5, "lumber": 6, "ore": 3}), Research(tech: "trade_1"), // age tech 2; Yield Cap up Build(kind: "watchtower", count: 1, at: BorderEdge, optional: true), Advance(age: "masonry"), + Build(kind: "muster_hall", count: 3, at: NearTown), Build(kind: "engine_yard", count: 1, at: NearTown), Train(kind: "mangonel", count: 1), // the first wave's siege, saved up for Build(kind: "scriptorium", count: 2, at: NearTown), // Lore for the Charter techs @@ -37,11 +41,10 @@ Research(tech: "statecraft_1"), Advance(age: "charter"), // 6 counting techs before it Research(tech: "arms_3"), - // After the Charter: more Lumber and Ore, and a second Muster Hall, so - // the waves grow (pop 100 with Arms III). + // After the Charter: more Lumber and Ore, so the waves grow (pop 100 + // with Arms III). Build(kind: "lumber_yard", count: 3, at: Terrain), Workers(count: 20, split: {"grain": 8, "lumber": 8, "ore": 4}), - Build(kind: "muster_hall", count: 2, at: NearTown), Build(kind: "ore_pit", count: 2, at: Terrain), Workers(count: 23, split: {"grain": 8, "lumber": 10, "ore": 5}), ], diff --git a/data/ai/build_orders/tower.ron b/data/ai/build_orders/tower.ron new file mode 100644 index 0000000..0392ef8 --- /dev/null +++ b/data/ai/build_orders/tower.ron @@ -0,0 +1,50 @@ +// Tower (M6): an Ore Pit early, a border Watchtower by the eighth step, +// two Ore Pits and six Ore workers by the Masonry Age, Harrying I at once +// (no wave launches before it), three border Watchtowers, a Bowman-led army +// and (M6 owner decision 12) Statecraft II after Arms III, so a cap game in +// which no Town fell is not a mirror image of its opponent's. Schema: +// crates/rules/src/ai.rs (deny_unknown_fields). Knobs: personalities.ron. +( + id: "tower", + steps: [ + Workers(count: 7, split: {"grain": 3, "lumber": 4}), + Build(kind: "croft", count: 2, at: NearTown), + Build(kind: "lumber_yard", count: 1, at: Terrain), + Build(kind: "ore_pit", count: 1, at: Terrain), + Workers(count: 10, split: {"grain": 3, "lumber": 4, "ore": 3}), + Build(kind: "scriptorium", count: 1, at: NearTown), + Train(kind: "scribe", count: 3), + Build(kind: "watchtower", count: 1, at: BorderEdge, optional: true), + Build(kind: "croft", count: 3, at: NearTown), + Build(kind: "muster_hall", count: 1, at: NearTown), + Military, // production starts; latched + Research(tech: "arms_1"), // age tech 1; pop 25 -> 50 + Build(kind: "ore_pit", count: 2, at: Terrain), + Workers(count: 14, split: {"grain": 4, "lumber": 4, "ore": 6}), + Research(tech: "trade_1"), // age tech 2 + Build(kind: "watchtower", count: 2, at: BorderEdge, optional: true), + Build(kind: "croft", count: 5, at: NearTown), + Advance(age: "masonry"), + Research(tech: "harrying_1", optional: true), // hosted by the Watchtower + Build(kind: "engine_yard", count: 1, at: NearTown), + Train(kind: "mangonel", count: 1), + Build(kind: "watchtower", count: 3, at: BorderEdge, optional: true), + Build(kind: "town", count: 2, at: Expansion, optional: true), + Build(kind: "scriptorium", count: 2, at: NearTown), + Train(kind: "scribe", count: 6), + Research(tech: "arms_2"), + Workers(count: 17, split: {"grain": 6, "lumber": 5, "ore": 6}), + Research(tech: "letters_1"), + Research(tech: "trade_2"), + Research(tech: "statecraft_1"), + Advance(age: "charter"), // 6 counting techs before it + Research(tech: "harrying_2", optional: true), + Research(tech: "arms_3"), + Research(tech: "statecraft_2"), // a turtle widens its borders + Build(kind: "lumber_yard", count: 2, at: Terrain), + Workers(count: 20, split: {"grain": 8, "lumber": 6, "ore": 6}), + Build(kind: "muster_hall", count: 2, at: NearTown), + Build(kind: "lumber_yard", count: 3, at: Terrain), + Workers(count: 23, split: {"grain": 8, "lumber": 9, "ore": 6}), + ], +) diff --git a/data/ai/difficulty/easy.ron b/data/ai/difficulty/easy.ron new file mode 100644 index 0000000..6ce5717 --- /dev/null +++ b/data/ai/difficulty/easy.ron @@ -0,0 +1,37 @@ +// Easy (M6): a defensive bot for learning the loop. It receives a bonus +// bundle every 30 s from 3:00 (the first one at 3:30), sends waves only after +// the Masonry Age and only at its largest wave size (at most 14 armed units), +// 1.5 times as far apart, holds wider borders (Statecraft II), and makes its +// one big push, the all-in, at minute 33. No early raid and no siege +// pressure (M6 owner decision 12). No handicap: line 2 passes without M6 +// owner decision 2's fallback (850 per mille Yeomen), which measured as a +// stronger Easy (fewer Yeomen, more army under the pop cap). +// Disclosed on New Game and on the game-over screen; never rubber-banded. +// Schema: crates/rules/src/ai.rs (deny_unknown_fields). docs/design/ai.md, +// "Difficulty table". +( + id: "easy", name: "Easy", + income_interval_ds: 300, + income_from_ds: 1800, + income: {"grain": 20, "lumber": 20, "ore": 10}, // the same in all three files + wave_age_gate: Age("masonry"), + largest_wave_only: true, + max_wave_units: Some(14), + wave_size_permille: 1000, + wave_interval_permille: 1500, + all_in_after_ds: Some(19800), // 33:00, before Standard's 36:00, after Hard's 30:00 + first_raid: None, + worker_target_permille: 1000, // no Yeomen handicap + extra_research: ["statecraft_2"], // +1 Town radius: an untouched cap game is Easy's + pressure: ( + reinforce_min_units: 0, + commit_ds: 0, + retreat_ratio_permille: 1000, + retreat_cooldown_permille: 1000, + home_guard_units: 0, + lure_ds: 0, + cohesion_tiles: 0, + launch_ratio_permille: 0, + assault_ratio_permille: 0, + ), +) diff --git a/data/ai/difficulty/hard.ron b/data/ai/difficulty/hard.ron new file mode 100644 index 0000000..82f9d3b --- /dev/null +++ b/data/ai/difficulty/hard.ron @@ -0,0 +1,34 @@ +// Hard (M6). The bot receives a bonus bundle every 20 s from 3:00 (the first +// one at 3:20) and attacks before minute 12: no Masonry gate on its waves, +// 0.8 times the wave interval, and a raid by 10:00; it keeps up siege +// pressure as Standard does (M6 owner decision 12) and assaults a staged wave +// only when it outweighs the army at home 1.3 to 1. Disclosed on New Game and +// on the game-over screen; never rubber-banded. Schema: +// crates/rules/src/ai.rs (deny_unknown_fields). docs/design/ai.md, +// "Difficulty table" (the line-4 flip edits income_interval_ds 200 -> 300). +( + id: "hard", name: "Hard", + income_interval_ds: 200, + income_from_ds: 1800, + income: {"grain": 20, "lumber": 20, "ore": 10}, // the same in all three files + wave_age_gate: Off, + largest_wave_only: false, + max_wave_units: None, + wave_size_permille: 1000, + wave_interval_permille: 800, + all_in_after_ds: None, + first_raid: Some((by_ds: 6000, prep_ds: 1500, min_armed: 6, max_armed: 10)), + worker_target_permille: 1000, + extra_research: [], + pressure: ( + reinforce_min_units: 6, + commit_ds: 300, + retreat_ratio_permille: 0, + retreat_cooldown_permille: 1000, + home_guard_units: 0, + lure_ds: 600, + cohesion_tiles: 4, + launch_ratio_permille: 0, + assault_ratio_permille: 1300, + ), +) diff --git a/data/ai/difficulty/standard.ron b/data/ai/difficulty/standard.ron new file mode 100644 index 0000000..868f67f --- /dev/null +++ b/data/ai/difficulty/standard.ron @@ -0,0 +1,36 @@ +// Standard (M6): the fun-gate opponent and the New Game default. The bot +// receives a bonus bundle every 25 s from 3:00 (the first one at 3:25) and +// plays its personality's wave timing, keeps up siege pressure (M6 owner +// decision 12: groups from the rally join an assault, a siege that is taking +// a Town down neither retreats nor times out; a wave losing without progress +// retreats as the personality's does), launches only when it outweighs the +// army at home, and goes all-in at minute 36. Disclosed on New Game and on +// the game-over screen; never rubber-banded. _ds deciseconds, _permille per mille. +// Schema: crates/rules/src/ai.rs (deny_unknown_fields). docs/design/ai.md, +// "Difficulty table". +( + id: "standard", name: "Standard", + income_interval_ds: 250, // Easy 300, Hard 200; 0 = no bonus + income_from_ds: 1800, // payments complete at 3:00 + n intervals + income: {"grain": 20, "lumber": 20, "ore": 10}, // the same in all three files + wave_age_gate: Personality, // Personality | Age("masonry") | Off + largest_wave_only: false, + max_wave_units: None, + wave_size_permille: 1000, + wave_interval_permille: 1000, + all_in_after_ds: Some(21600), // 36:00; None keeps the personality's + first_raid: Some((by_ds: 9000, prep_ds: 1500, min_armed: 6, max_armed: 12)), + worker_target_permille: 1000, + extra_research: [], + pressure: ( + reinforce_min_units: 6, + commit_ds: 300, + retreat_ratio_permille: 1000, // the personality's retreat (review of the retune) + retreat_cooldown_permille: 1000, + home_guard_units: 0, + lure_ds: 600, + cohesion_tiles: 4, + launch_ratio_permille: 800, + assault_ratio_permille: 0, + ), +) diff --git a/data/ai/personalities.ron b/data/ai/personalities.ron index f196d0c..7e6b310 100644 --- a/data/ai/personalities.ron +++ b/data/ai/personalities.ron @@ -1,5 +1,7 @@ -// Personality knobs (M5b: one row). _ds deciseconds, _tiles tiles, _permille -// per mille. Schema: crates/rules/src/ai.rs (deny_unknown_fields). +// Personality knobs. _ds deciseconds, _tiles tiles, _permille per mille. +// Schema: crates/rules/src/ai.rs (deny_unknown_fields). M5b shipped +// `standard`; M6 adds rush, boom and tower (the `random` pool) and the +// knobs after `random` in every row (docs/design/ai.md, "Personalities"). ( personalities: [ ( @@ -24,6 +26,141 @@ defend_radius_tiles: 16, defend_regoal_tiles: 4, defend_release_ds: 150, defend_max_block_ds: 600, defend_guard_pop_ratio: 2, recall_wave: WhileStaging, recall_wave_town_hp_permille: 500, + random: false, + wave_mode: Siege, wain_rule: Always, wave_requires_tech: None, seat_last: true, + raid_min_armed: 6, raid_max_armed: 10, raid_every_ds: 1200, raid_max_ds: 1200, + raid_idle_ds: 300, raid_idle_tiles: 10, raid_worker_weight: 100, + raid_distance_permille: 1000, + retreat_ratio_permille: 650, retreat_loss_permille: 300, retreat_commit_ds: 200, + retreat_cooldown_ds: 1200, retreat_commit_town_hp_permille: 300, + influence_cell_tiles: 4, influence_radius_cells: 1, target_strength_weight: 10, + counter_weight_permille: 750, counter_floor_permille: 250, counter_cap_permille: 4000, + counter_join_permille: 1500, counter_kinds: [(kind: "outrider", weight: 2)], + defend_economy: true, defend_economy_tiles: 8, defend_wains_first: false, + reactive_towers: 0, + noise_wave_interval_permille: 150, noise_wave_units: 2, noise_first_wave_ds: 300, + noise_mix_permille: 200, noise_site_top_k: 2, + ), + // Rush: an early Muster Hall and raids of Skirmishers and Bowmen from + // about minute 5, no age gate; small siege waves once it has a + // Mangonel, with a Wain only after the enemy has Harrying. + ( + id: "rush", name: "Rush", build_order: "rush", + think_every_ds: 5, lookahead: 3, max_commands_per_think: 16, + site_checks_per_think: 4, rebalance_per_think: 2, blocked_retry_ds: 300, + building_gap_tiles: 1, near_town_min_tiles: 4, near_town_max_tiles: 12, + expansion_min_tiles: 14, resource_radius_tiles: 20, toward_enemy_penalty: 2, + border_scan_tiles: 40, tower_spacing_tiles: 8, + army_mix: [(kind: "skirmisher", weight: 4), (kind: "bowman", weight: 3), + (kind: "shieldbearer", weight: 1), (kind: "mangonel", weight: 2)], + supply_kind: "supply_wain", max_queue_per_building: 1, + rally_tiles_from_seat: 12, assemble_radius_tiles: 8, goal_tolerance_tiles: 3, + wave_requires_age: None, first_wave_not_before_ds: 0, + wave_sizes: [(from_ds: 0, armed: 8), (from_ds: 9000, armed: 10), + (from_ds: 12000, armed: 14), (from_ds: 15000, armed: 20)], + max_wave_units: 40, wave_interval_ds: 1500, min_siege: 1, wains_per_wave: 1, + stage_margin_tiles: 2, stage_radius_tiles: 6, stage_ready_permille: 750, + stage_max_ds: 600, siege_focus_tiles: 12, wain_standoff_tiles: 3, wain_leash_tiles: 6, + ring_units: 4, clear_defenders_units: 4, continue_min_units: 4, + recall_below_units: 3, wave_max_ds: 3600, all_in_after_ds: 18000, + defend_radius_tiles: 12, defend_regoal_tiles: 4, defend_release_ds: 150, + defend_max_block_ds: 600, defend_guard_pop_ratio: 2, + recall_wave: WhileStaging, recall_wave_town_hp_permille: 500, + random: true, + wave_mode: RaidUntilSiege, wain_rule: IfEnemyHarrying, wave_requires_tech: None, + seat_last: false, + raid_min_armed: 6, raid_max_armed: 10, raid_every_ds: 1200, raid_max_ds: 1200, + raid_idle_ds: 300, raid_idle_tiles: 10, raid_worker_weight: 100, + raid_distance_permille: 1000, + retreat_ratio_permille: 650, retreat_loss_permille: 300, retreat_commit_ds: 200, + retreat_cooldown_ds: 1200, retreat_commit_town_hp_permille: 300, + influence_cell_tiles: 4, influence_radius_cells: 1, target_strength_weight: 10, + counter_weight_permille: 750, counter_floor_permille: 250, counter_cap_permille: 4000, + counter_join_permille: 1500, counter_kinds: [(kind: "outrider", weight: 2)], + defend_economy: true, defend_economy_tiles: 8, defend_wains_first: false, + reactive_towers: 0, + noise_wave_interval_permille: 150, noise_wave_units: 2, noise_first_wave_ds: 300, + noise_mix_permille: 200, noise_site_top_k: 2, + ), + // Boom: a second Town at once, Statecraft I and a third Town, Masonry + // before the army; few, large siege waves from minute 15. + ( + id: "boom", name: "Boom", build_order: "boom", + think_every_ds: 5, lookahead: 3, max_commands_per_think: 16, + site_checks_per_think: 4, rebalance_per_think: 2, blocked_retry_ds: 300, + building_gap_tiles: 1, near_town_min_tiles: 4, near_town_max_tiles: 12, + expansion_min_tiles: 14, resource_radius_tiles: 20, toward_enemy_penalty: 2, + border_scan_tiles: 40, tower_spacing_tiles: 8, + army_mix: [(kind: "skirmisher", weight: 3), (kind: "bowman", weight: 2), + (kind: "shieldbearer", weight: 2), (kind: "mangonel", weight: 2)], + supply_kind: "supply_wain", max_queue_per_building: 1, + rally_tiles_from_seat: 12, assemble_radius_tiles: 8, goal_tolerance_tiles: 3, + wave_requires_age: Some("masonry"), first_wave_not_before_ds: 9000, + wave_sizes: [(from_ds: 0, armed: 14), (from_ds: 12000, armed: 20), + (from_ds: 15000, armed: 28)], + max_wave_units: 40, wave_interval_ds: 1800, min_siege: 2, wains_per_wave: 1, + stage_margin_tiles: 2, stage_radius_tiles: 6, stage_ready_permille: 750, + stage_max_ds: 600, siege_focus_tiles: 12, wain_standoff_tiles: 3, wain_leash_tiles: 6, + ring_units: 4, clear_defenders_units: 4, continue_min_units: 4, + recall_below_units: 3, wave_max_ds: 3600, all_in_after_ds: 18000, + defend_radius_tiles: 20, defend_regoal_tiles: 4, defend_release_ds: 150, + defend_max_block_ds: 600, defend_guard_pop_ratio: 2, + recall_wave: WhileStaging, recall_wave_town_hp_permille: 500, + random: true, + wave_mode: Siege, wain_rule: Always, wave_requires_tech: None, seat_last: true, + raid_min_armed: 6, raid_max_armed: 10, raid_every_ds: 1200, raid_max_ds: 1200, + raid_idle_ds: 300, raid_idle_tiles: 10, raid_worker_weight: 100, + raid_distance_permille: 1000, + retreat_ratio_permille: 650, retreat_loss_permille: 300, retreat_commit_ds: 200, + retreat_cooldown_ds: 1200, retreat_commit_town_hp_permille: 300, + influence_cell_tiles: 4, influence_radius_cells: 1, target_strength_weight: 10, + counter_weight_permille: 750, counter_floor_permille: 250, counter_cap_permille: 4000, + counter_join_permille: 1500, counter_kinds: [(kind: "outrider", weight: 2)], + defend_economy: true, defend_economy_tiles: 8, defend_wains_first: false, + reactive_towers: 0, + noise_wave_interval_permille: 150, noise_wave_units: 2, noise_first_wave_ds: 300, + noise_mix_permille: 200, noise_site_top_k: 2, + ), + // Tower: Ore first, border Watchtowers and Harrying I before any wave, + // a Bowman-led mix; its guard goes for enemy Supply Wains first and it + // adds a Watchtower where it had to defend. + ( + id: "tower", name: "Tower", build_order: "tower", + think_every_ds: 5, lookahead: 3, max_commands_per_think: 16, + site_checks_per_think: 4, rebalance_per_think: 2, blocked_retry_ds: 300, + building_gap_tiles: 1, near_town_min_tiles: 4, near_town_max_tiles: 12, + expansion_min_tiles: 14, resource_radius_tiles: 20, toward_enemy_penalty: 2, + border_scan_tiles: 40, tower_spacing_tiles: 8, + army_mix: [(kind: "bowman", weight: 4), (kind: "skirmisher", weight: 2), + (kind: "shieldbearer", weight: 2), (kind: "mangonel", weight: 2)], + supply_kind: "supply_wain", max_queue_per_building: 1, + rally_tiles_from_seat: 12, assemble_radius_tiles: 8, goal_tolerance_tiles: 3, + wave_requires_age: Some("masonry"), first_wave_not_before_ds: 0, + wave_sizes: [(from_ds: 0, armed: 10), (from_ds: 9000, armed: 14), + (from_ds: 12000, armed: 18), (from_ds: 15000, armed: 24)], + max_wave_units: 40, wave_interval_ds: 1800, min_siege: 1, wains_per_wave: 1, + stage_margin_tiles: 2, stage_radius_tiles: 6, stage_ready_permille: 750, + stage_max_ds: 600, siege_focus_tiles: 12, wain_standoff_tiles: 3, wain_leash_tiles: 6, + ring_units: 4, clear_defenders_units: 4, continue_min_units: 4, + recall_below_units: 3, wave_max_ds: 3600, all_in_after_ds: 18000, + defend_radius_tiles: 16, defend_regoal_tiles: 4, defend_release_ds: 150, + defend_max_block_ds: 600, defend_guard_pop_ratio: 2, + recall_wave: WhileStaging, recall_wave_town_hp_permille: 500, + random: true, + wave_mode: Siege, wain_rule: Always, wave_requires_tech: Some("harrying_1"), + seat_last: true, + raid_min_armed: 6, raid_max_armed: 10, raid_every_ds: 1200, raid_max_ds: 1200, + raid_idle_ds: 300, raid_idle_tiles: 10, raid_worker_weight: 100, + raid_distance_permille: 1000, + retreat_ratio_permille: 650, retreat_loss_permille: 300, retreat_commit_ds: 200, + retreat_cooldown_ds: 1200, retreat_commit_town_hp_permille: 300, + influence_cell_tiles: 4, influence_radius_cells: 1, target_strength_weight: 10, + counter_weight_permille: 750, counter_floor_permille: 250, counter_cap_permille: 4000, + counter_join_permille: 1500, counter_kinds: [(kind: "outrider", weight: 2)], + defend_economy: true, defend_economy_tiles: 8, defend_wains_first: true, + reactive_towers: 2, + noise_wave_interval_permille: 150, noise_wave_units: 2, noise_first_wave_ds: 300, + noise_mix_permille: 200, noise_site_top_k: 2, ), ], ) diff --git a/data/rules/README.md b/data/rules/README.md index 28921a2..7ae1466 100644 --- a/data/rules/README.md +++ b/data/rules/README.md @@ -51,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 | 25 | 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 | 30 | 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 | @@ -189,7 +189,16 @@ header-only re-record (only `m5_first_match`, new in that pull request, 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. +edit needs no bump from then on. M6 changed no value here either: +`rules_version` 26 added `../ai/difficulty/` (the disclosed bot bonus, paid +by step 6d' under `SIM_VERSION` 18), 27 the rush, boom and tower +personalities and the new bot knobs, 28 the tuned Standard build +order and Easy's Yeomen handicap, 29 the retune groundwork (the +difficulty files' `pressure` block and `noise_border_top_k`, all neutral, +and a siege wave's memory of its target's last hit points), 30 the retune +itself (M6 owner decision 12: siege pressure, the all-in times, Easy's +and `tower`'s Statecraft II) and 31 Standard's retreat restored after its +review, all under `../ai/`; in each only `m5_first_match.hash` moved. ## resources.ron @@ -567,7 +576,7 @@ 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` (25 to 26), update the one-line +2. Bump `rules_version` in `rules.ron` (28 to 29), 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: @@ -579,8 +588,8 @@ cargo test -p sim ``` `data-check` prints -`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` -(the visuals counts follow `../visuals.ron`; the repository is at 25, so before the edit it prints `rules_version=25`; +`OK rules_version=29 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` +(the visuals counts follow `../visuals.ron`; the repository is at 28, so before the edit it prints `rules_version=28`; 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 7beffee..f585918 100644 --- a/data/rules/rules.ron +++ b/data/rules/rules.ron @@ -3,10 +3,33 @@ // at load by rules::Rules::ticks_from_ds. Schema: crates/rules/src/lib.rs // (deny_unknown_fields). Authoring guide: data/rules/README.md ( - // 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, + // 26: M6 difficulty levels (data/ai/difficulty/, in rules_hash) and + // SIM_VERSION 18's disclosed bot bonus (step 6d'): every fixture is + // re-recorded for its header; only m5_first_match's .hash moves (its two + // Standard seats receive the bonus from tick 4100). + // 27: M6 slice 2 bot behaviours (influence maps, retreat, raids, the + // deadline raid, counters, seat_last, defence and noise; crates/ai under + // SIM_VERSION 18) and the rush, boom and tower personalities: every + // fixture is re-recorded for its header; only m5_first_match's .hash moves. + // 28: M6 slice 2 tuning: Standard's build order takes a 2nd Muster Hall + // after arms_1 and a 3rd after Masonry (income becomes army), and Easy + // gets its disclosed 850 per mille Yeomen handicap; only m5_first_match's + // .hash moves. + // 29: M6 retune (M6 owner decision 12) groundwork: the difficulty files gain + // the `pressure` block and every personality `noise_border_top_k`, all + // neutral, and a siege wave's memory gains its target's last hit points; + // every fixture is re-recorded for its header; only m5_first_match's + // .hash moves (the bots' hashed memory bytes). + // 30: M6 retune (M6 owner decision 12): Standard and Hard press their sieges + // (reinforce, commit, no retreat, lure, cohesion; Standard's launch gate, + // Hard's assault gate), the all-ins go Hard 30:00, Easy 33:00, Standard + // 36:00, Easy researches Statecraft II and drops the 850 Yeomen handicap, + // tower adds Statecraft II, the difficulty files gain `extra_research` and + // `assault_ratio_permille` and the personalities lose `noise_border_top_k`. + // 31: review of the M6 retune: Standard retreats again as its personality + // does (pressure.retreat_ratio_permille 0 -> 1000; Hard still never + // retreats); only m5_first_match's .hash moves. + rules_version: 31, tick_rate_hz: 20, cmd_delay_ticks: 2, default_map: "plains_1v1", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c64812f..63e9f53 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,8 +72,8 @@ The deterministic simulation library. Pure Rust, `#![forbid(unsafe_code)]`, sing | `movement.rs` | `SpatialGrid` (2x2 tiles, derived; `for_each_in`, a visitor that does not allocate, from M4a), `step` (arrival steering, separation, circle correction, repath triggers; from M4a the combat engagements of step 5a, hold or chase, replace a unit's own steering), `group_targets` (component-aware spiral offsets) | M1 (done), M4a (done) | | `replay.rs` | `.eonreplay` v1: `MAGIC`, header, `TickBatch`, `HashRecord` with sub-hashes, `ReplayWriter` (clean-exit trailer), truncation-tolerant `ReplayReader` (`ReplayFile`, `Corrupt` error; peeks `sim_version` before decoding the header, `SimVersion` error), `verify` with the four `VerifyOutcome`s; from M5b `verify_with` and `hash_dump_with` (a `ControllerFactory` called after the version and rules checks, so a bot match re-simulates with the bots its header names) and `ReplayError::Controller` | M1 (done; the writer thread lives in `game`, M2), M3a (version peek, done), M5b (done) | | `hash.rs` | `hash_value`: xxh3 over postcard | M0; per-subsystem sub-hashes M1 (done) | -| `ai_hook.rs` | `AiController` trait, `SimView`; from M3a the accessors `setup`, `economy`, `income`, `territory`, `building`, `towns`, `building_cost`, `check_build`, `worked_slots`, `idle_workers`, `pop`, `unit_cost` and `check_train`, and from M4a `age`, `researched`, `research_quote`, `check_research`, `check_advance_age` and `check_build_kind` (the `Build` checks that need no builder or tile, for the command card), then `unit_stats`, `unit_hp` and `check_attack`, which call the same functions `Sim::step` uses; from M4b `building_hp` (hit points and the kind's maximum, for health bars); from M5a `age_tech_count` (the researched techs that count toward an age, the number `AdvanceAge` compares with the gate) and, from the attrition slice, `harrying_level`, `supply_radius_tiles`, `supply_wains`, `supplied`, `attrition_pulse` and `attrition` (a unit's `Exposure` where it stands), and from the annexation slice `annexation` (a Town's running timer, `AnnexView`), `outcome` and `result`; `check_train` inherits `TownContested`; from M5b `AiMemory` (a bot's hashed memory bytes, with the size guide `AI_MEMORY_GUIDE_BYTES`), the memory parameter of `AiController::think`, `NoAi` for new code, and the accessors `check_gather`, `check_rally` and `ai_memory` | M0, M3a (done), M4a (done), M4b, M5a, M5b (done) | -| `economy.rs` | `Economy` (per player, `i64` stockpiles and income accumulators per resource), `RateInfo` and the computed rates (base yields plus worked slots), exact income through the Yield Cap and `stockpile_cap` (step 6d), the jobs step (6c, which ends with idle seeking from `gather.rs`), cost helpers (ramped building cost, deduct, refund) | M3a (done) | +| `ai_hook.rs` | `AiController` trait, `SimView`; from M3a the accessors `setup`, `economy`, `income`, `territory`, `building`, `towns`, `building_cost`, `check_build`, `worked_slots`, `idle_workers`, `pop`, `unit_cost` and `check_train`, and from M4a `age`, `researched`, `research_quote`, `check_research`, `check_advance_age` and `check_build_kind` (the `Build` checks that need no builder or tile, for the command card), then `unit_stats`, `unit_hp` and `check_attack`, which call the same functions `Sim::step` uses; from M4b `building_hp` (hit points and the kind's maximum, for health bars); from M5a `age_tech_count` (the researched techs that count toward an age, the number `AdvanceAge` compares with the gate) and, from the attrition slice, `harrying_level`, `supply_radius_tiles`, `supply_wains`, `supplied`, `attrition_pulse` and `attrition` (a unit's `Exposure` where it stands), and from the annexation slice `annexation` (a Town's running timer, `AnnexView`), `outcome` and `result`; `check_train` inherits `TownContested`; from M5b `AiMemory` (a bot's hashed memory bytes, with the size guide `AI_MEMORY_GUIDE_BYTES`), the memory parameter of `AiController::think`, `NoAi` for new code, and the accessors `check_gather`, `check_rally` and `ai_memory`; from M6 `seed` and `ai_noise` (a tick-free xxh3 of the seed, the player and a salt that never touches the `Pcg32`; the free function `ai_noise(seed, player, salt)` for tools) | M0, M3a (done), M4a (done), M4b, M5a, M5b (done), M6 (slice 1, done) | +| `economy.rs` | `Economy` (per player, `i64` stockpiles and income accumulators per resource), `RateInfo` and the computed rates (base yields plus worked slots), exact income through the Yield Cap and `stockpile_cap` (step 6d), the jobs step (6c, which ends with idle seeking from `gather.rs`), cost helpers (ramped building cost, deduct, refund); from M6 `RateInfo::at_cap` (the amber rule) and step 6d', the bot seats' disclosed bonus (`BonusSeat`, `bonus_table`, `bonus_step`, `SimEvent::BonusIncome`) | M3a (done), M6 (slice 1, done) | | `gather.rs` | `validate_gather` (pure) and `apply_gather`, slots taken through `Job::Gather`, stand tiles, worked slots and their raw income, gather-job validity for step 6c, `idle_seek_step` (idle workers seeking a free slot, the second half of 6c), `unit_takes_slot` (a finished builder, or a unit just trained there, taking its building's slot) | M3a (done) | | `training.rs` | `validate_train` (10 ordered checks, pure; the age and `requires_tech` checks from M4a, `TownContested` from M5a) and `enqueue`, pop used (unit entries only), `unit_cost_for` (the counts `n` and `k` that `Rules::unit_cost` prices), `cancel_slot` (queue Cancel with an exact refund, units and research alike), the training step (6b: head progress, spawn beside the building, rally walk or a slot there; a research head completes its tech; from M5a a contested Town's queue pauses), `validate_rally` and `set_rally`, and from M5a `refund_queue` (a flip's refund) | M3a (done), M4a (research entries), M5a (done) | | `town.rs` | Standard start, town count (sites included) and limit, attribution to the nearest own complete Town (from M5a skipping a contested Town); tier and radius refresh (`refresh`, `refresh_all`: the `town_growth_age` gate and the Statecraft push, from M4a; the source keeps its layer, so a contested Town stays neutral) | M3a (done), M4a (done), M5a (done) | @@ -84,12 +84,12 @@ The deterministic simulation library. Pure Rust, `#![forbid(unsafe_code)]`, sing | `attrition.rs` | Step 5b': Harrying levels (`HarryingLevel` rows), pulse intervals (halved per level) and sizes (age-gap table, late bonus), immunities, the per-step `SupplyGrid` of every player's Supply Wain tiles (never stored), `Exposure`, the `exposed_ticks` counters and the pulses | M5a (attrition slice, done) | | `annexation.rs` | Step 5d: for each complete Town at 0 HP, the attackers (`annexation_attackers` beside the footprint) and defenders (`annexation_defenders` within 6 tiles of its centre), `eligible`, entry, reset and the timer (`TownState::hold`), the Town's border source on the neutral layer, and the flip (`Flip`): transfers but for `stays_on_annexation` kinds, queue refunds, zeroed site `paid`, transferred border sources moved to the new owner's layer, idled jobs, restored hit points, re-attribution; `is_contested`, `AnnexView` | M5a (annexation slice, done) | | `victory.rs` | Step 6e: Surrender, Seat captured, all Towns lost (at the flip) and the territory cap, in that order, latched once in `State::outcome` with `MatchDecided` | M5a (annexation slice, done) | +| `stats.rs` | `MatchStats` (M6): engine-free, unhashed match statistics fed from the view and each step's events (units trained and lost by kind, Towns taken and lost, ages, the bonus, credited income, cap seconds, peak army, first contact, the longest quiet span, the first unit on enemy land, each side's lowest own Town), for the play-bots CSV, `sim-cli match-log` and the game-over screen; the sim never reads it | M6 (slice 1, done) | | `visibility.rs` | Per-player `u8` grid (0 unexplored, 1 explored, 2 visible), hashed | M7 | -| `influence.rs` | 4x4-tile influence maps, derived and unhashed | M6 | | `tests/` | `m0.rs`, `m1.rs` (determinism, snapshot/restore, proptests, path oracle, goldens), `m2.rs` (the four M2 scenarios, AttackMove-as-Move), `m3a.rs` (`territory_*` and `economy_*` tests, the `economy_second_town` golden), `m3b.rs` (the `economy_walkthrough` beats and golden), `m4a.rs` (`tech_*`: research, ages, the Modifier resolver, Arms hit points and damage; `combat_*`: damage, simultaneous hits, the explicit Attack, death and destruction, acquisition, AttackMove, guards, shooters and the `combat_skirmish` beats; `counters_*`: the counter triangle over 20 seeds; `golden_combat_skirmish`; `m4a_*`: seeded random streams over tech and combat), `m4b.rs` (`enemy_outpost_*`: the shared setup, the tower's reach, the walkthrough's commands and grants, and, ignored locally, the prologue, the walkthrough's beats and its golden), `m5a.rs` (`tech_harrying_*`; `attrition_*`: the four acceptance clauses, supply, resets, immunities, the age-gap table, death, `DebugDamage`, restore; `m5a_attrition_*`: a seeded random stream with restore; `annexation_*`: the five acceptance clauses, the 54000-tick stalemate included, the defender and attacker rules, the flip's transfers and refunds, the hold rule, restore and the outcome rules; `golden_capture_seat` and, ignored, `golden_stalemate_45min`; `m5a_random_*`: seeded random streams over attrition and annexation with restore); fixtures `move_500_short`, `move_500`, `group_spiral`, `snapshot_restore`, `economy_second_town`, `economy_walkthrough`, `combat_skirmish`, `enemy_outpost_walkthrough`, `capture_seat`, `stalemate_45min` under `tests/fixtures/` with `.hash` siblings | M1 (done), M2 (done), M3a (done) onward | | `benches/` | criterion benches `step_500_units`, `astar_budget` (M1, done), `territory_recompute` (M3a, done), `combat_step` (M4a, done: 20 steps of the 400-unit battle from its first fighting tick, on a restored snapshot) | M1, M3a, M4a | -State layout is not an ECS. Units and buildings live in `BTreeMap` and `BTreeMap` with the `IdGen` allocator (`IdGen::unit()`, `IdGen::building()`), itself hashed state. Iteration is always id order or sorted order with an id tiebreak. Derived structures (influence maps, spatial grid, connected components, path cache) are rebuilt, never hashed. +State layout is not an ECS. Units and buildings live in `BTreeMap` and `BTreeMap` with the `IdGen` allocator (`IdGen::unit()`, `IdGen::building()`), itself hashed state. Iteration is always id order or sorted order with an id tiebreak. Derived structures (spatial grid, connected components, path cache) are rebuilt, never hashed. The influence maps, planned here at M0, live in the bot instead (`crates/ai/src/standard/influence.rs`, built per plan; M6, [DETERMINISM.md](DETERMINISM.md#derived-caches)). At M0 the crate holds `Fx` and `FxVec2`, every id type and the `IdGen` allocator, the full `Command` enum with `sort_commands`, `MatchSetup` (including player slots), `SimView`, the `AiController` trait, and a `Sim` whose hashed state is `{ tick, seed, rng: Pcg32, ids, players, units, buildings, pending, commands_applied }`. `step` queues the tick's commands for `tick + cmd_delay`, runs the AI and queues its commands the same way, then applies everything due this tick in `(player, seq)` order; only `Surrender` has a real handler, and every other command advances the RNG once so the hash already depends on the command stream. `hash`, `snapshot` and `restore` are the real scheme (xxh3 over postcard, `restore` calls a `rebuild_derived` that is empty until M1). Subsystem ticks start in M1. @@ -129,7 +129,7 @@ At M0: `lib.rs` (the `Rules` struct with the full v0.1 field set from `rules.ron Scripted skirmish opponents implementing `sim::AiController`. Every bot is a deterministic function of `SimView` and, from M5b, its own hashed `AiMemory`. The clippy ban list applies. -At M0: `Passive`, a bot that never acts, and `Scripted`, which replays a fixed command list so `sim-cli selftest` exercises the delay queue and the RNG. M5b's first slice adds `controller_for(&MatchSetup, &Rules)` (the controller for a header's `ai_seats`, or a `BotError`), `seats.rs` (`Seats`, one bot per AI slot), `harness.rs` (the engine-free `BotMatch`, the `Observer` of the play-bots CSV facts, `GameRow`, `CSV_HEADER`, `exit_code`), and the Standard bot in `standard/` (the cadence gate, the `BotMemory` codec, `Facts`, the plan context `Ctx` with its `Ledger`, the static `MapAnalysis`). Its economy slice adds the rules, run in a fixed order over one `Ctx`: `defend.rs` (threats to any own Town, the home guard, emergency production, wave recall), `build_order.rs` (the window, saving up, cooldowns, the `Military` latch, the site keeper), `sites.rs` (`site_for`: `NearTown`, `Terrain`, `Expansion` and `BorderEdge` candidates against the live view), `economy.rs` (the gather split by largest remainder, clamped to slots and the Yield Cap) and `army.rs` (Supply Wains first, the army mix by deficit, the rally); `ai::describe` is the dev panel's one-line summary. Its waves slice adds `waves.rs` (launch, stage, assault with siege focus and the Wain point, ring hold, continue, recall and the all-in), run between production and the rally upkeep, and `StandardBot::think_with_stats` (the `check_build` probes a plan made, each counted by `Ctx::check_build`, for the work-cap test). Its review fixes size the home guard to the threat once a defence is past `defend_max_block_ds` (so the wave launches through a lasting raid), pace an `Advance`'s saving by its scarcest resource (`build_order::paced`), and keep a border building's `Build` step and site keeper away from enemy military. `tests/common/mod.rs` is the testkit (`Arena`). M6 adds difficulties, personalities and influence-map counter composition. +At M0: `Passive`, a bot that never acts, and `Scripted`, which replays a fixed command list so `sim-cli selftest` exercises the delay queue and the RNG. M5b's first slice adds `controller_for(&MatchSetup, &Rules)` (the controller for a header's `ai_seats`, or a `BotError`), `seats.rs` (`Seats`, one bot per AI slot), `harness.rs` (the engine-free `BotMatch`, the `Observer` of the play-bots CSV facts, `GameRow`, `CSV_HEADER`, `exit_code`), and the Standard bot in `standard/` (the cadence gate, the `BotMemory` codec, `Facts`, the plan context `Ctx` with its `Ledger`, the static `MapAnalysis`). Its economy slice adds the rules, run in a fixed order over one `Ctx`: `defend.rs` (threats to any own Town, the home guard, emergency production, wave recall), `build_order.rs` (the window, saving up, cooldowns, the `Military` latch, the site keeper), `sites.rs` (`site_for`: `NearTown`, `Terrain`, `Expansion` and `BorderEdge` candidates against the live view), `economy.rs` (the gather split by largest remainder, clamped to slots and the Yield Cap) and `army.rs` (Supply Wains first, the army mix by deficit, the rally); `ai::describe` is the dev panel's one-line summary. Its waves slice adds `waves.rs` (launch, stage, assault with siege focus and the Wain point, ring hold, continue, recall and the all-in), run between production and the rally upkeep, and `StandardBot::think_with_stats` (the `check_build` probes a plan made, each counted by `Ctx::check_build`, for the work-cap test). Its review fixes size the home guard to the threat once a defence is past `defend_max_block_ds` (so the wave launches through a lasting raid), pace an `Advance`'s saving by its scarcest resource (`build_order::paced`), and keep a border building's `Build` step and site keeper away from enemy military. `tests/common/mod.rs` is the testkit (`Arena`). M6 adds difficulties, personalities and influence-map counter composition. Its slice 1 makes every difficulty id of `data/ai/difficulty/` a bot (`bot_ids`, `is_standard_bot`; `controller_for` builds `StandardBot::new(.., personality, difficulty)`, which stores `rules::ai::resolve`'s knobs and the level's first raid), adds `bot_stats` and `resolve_personality` (the `random` choice), and feeds the harness's `MatchStats` for the 26 M6 CSV columns. Its slice 2 adds the behaviours: per-plan influence maps (`standard/influence.rs`, not a sim cache), retreat when losing (`standard/retreat.rs`), counter-weighted composition (`standard/compose.rs`), raids and the level's deadline raid, the Wain rule, `seat_last` and influence-scored siege targets, `defend_economy`, Wains first and reactive towers, the seed-alternating plan phase, mirror-aware site ties and bounded tick-free noise (`SimView::ai_noise`), with the rush, boom and tower personalities as data (`data/ai/personalities.ron` and three build orders). At the M5b close (`SIM_VERSION` 17, `rules_version` 24) the crate is: @@ -138,16 +138,19 @@ At the M5b close (`SIM_VERSION` 17, `rules_version` 24) the crate is: | `lib.rs` | `Passive`, `Scripted` and `selftest_human_commands`; `controller_for(&MatchSetup, &Rules)`, the controller for a header's `ai_seats` (`BOT_PASSIVE`, `BOT_STANDARD`), or a `BotError` (`UnknownBot`, `UnknownPersonality`, `SlotMismatch`); `describe(view, player)`, the dev panel's line per bot | M0, M5b | | `seats.rs` | `Seats`: one controller per AI slot, called with that slot's player | M5b | | `harness.rs` | Engine-free, no clock and no I/O, shared by `sim-cli play-bots` and the tests: `BotMatch` (a `Sim` with its bots, stepped and observed), the `Observer` of the CSV facts (from hashed state and drained events), `GameRow`, `CSV_HEADER` and `exit_code` | M5b | -| `standard/mod.rs` | `StandardBot` (its seat, personality and `MapAnalysis`): the cadence gate, then `run_rules` over one `Ctx`; `plan` without the gate for tests, `think_with_stats` and `PlanStats` for the work-cap test | M5b | -| `standard/memory.rs` | `BotMemory` (the hashed memory: the military latch, cooled-down steps, the remembered site, the wave roster and timers, the defend latch, counters), its postcard codec (the default encodes to no bytes) and `stats` for the bench | M5b | -| `standard/facts.rs`, `standard/ctx.rs` | `Facts::gather`, one pass over the view per plan; `Ctx`, the plan context (knobs, facts, the `Ledger` of stock less reservations, claimed units, the commands and their cap, the `check_build` probe counter, this plan's footprints, the rally), `goal_or_post` and `on_station` | M5b | -| `standard/sites.rs` | `MapAnalysis`, the static site tables built once from the map, the rules and the personality (`NearTown` rings, `Terrain`, `Expansion` and `BorderEdge` candidates, the rally seed), and `site_for`, the per-plan choice against the live view | M5b | -| `standard/build_order.rs` | `satisfied` and `first_unsatisfied`; the window, saving up (`paced` for an `Advance`), cooldowns, the `Military` latch, the site keeper and the border backoff | M5b | +| `standard/mod.rs` | `StandardBot` (its seat, personality and `MapAnalysis`): the cadence gate, then `run_rules` over one `Ctx`; `plan` without the gate for tests, `think_with_stats` and `PlanStats` for the work-cap test. M6: the knobs and build order resolved for the level (`rules::ai::resolve`, `resolve_order`), the plan phase alternating with the seed, `PlanStats::influence` for the bench, and the retreat rule between the defence and the build order; M6 retune (M6 owner decision 12): the level's `rules::Pressure`, resolved with the knobs and handed to every plan's `Ctx` | M5b, M6 | +| `standard/memory.rs` | `BotMemory` (the hashed memory: the military latch, cooled-down steps, the remembered site, the wave roster and timers, the defend latch, counters; M6 appends the retreat, wiped-wave and raid counters, the deadline raid's latch, the last retreat, the last defended building, the reactive towers built, and per wave its kind (`WaveKind::Siege` or `Raid`), launched size and last contact), its postcard codec (the default encodes to no bytes) and `stats` for the bench and the CSV | M5b, M6 | +| `standard/facts.rs`, `standard/ctx.rs` | `Facts::gather`, one pass over the view per plan (M6: `visible_enemies`, the one seam for enemy units that M7's fog filters, and `Enemies`, the enemy's armed kinds, worked buildings and Harrying); `Ctx`, the plan context (knobs, facts, the `Ledger` of stock less reservations, claimed units, the commands and their cap, the `check_build` probe counter, this plan's footprints, the rally; M6: the level's deadline raid, the `StatsCache`, the lazy per-plan `Influence`, `noise(salt)` with the `salt` constants and `jitter`; M6 retune: the level's `pressure`), `goal_or_post` and `on_station` | M5b, M6 | +| `standard/sites.rs` | `MapAnalysis`, the static site tables built once from the map, the rules and the personality (`NearTown` rings, `Terrain`, `Expansion` and `BorderEdge` candidates, the rally seed), and `site_for`, the per-plan choice against the live view; M6: mirror-aware ties (a footprint's distances from its doubled centre and the partner tile's index under the map's symmetry, so both seats pick mirrored sites) and the noise among the top `noise_site_top_k` `Expansion` and `BorderEdge` candidates | M5b, M6 | +| `standard/build_order.rs` | `satisfied` and `first_unsatisfied`; the window, saving up (`paced` for an `Advance`), cooldowns, the `Military` latch, the site keeper and the border backoff; M6: `reactive_tower`, a Watchtower toward the last defended building after a defence, up to `reactive_towers` | M5b, M6 | | `standard/economy.rs` | Worker balancing: the active split by largest remainder, clamped to slots and the Yield Cap; idle workers to the largest deficit; `rebalance_per_think` moves | M5b | -| `standard/army.rs` | Production (Supply Wains first, then the army mix by deficit over the kinds the bot can field), `home_armed`, the rally upkeep | M5b | -| `standard/waves.rs` | Launch, stage point, assault with siege focus, the Wain point and leash, ring hold, defender clearing, continue, the end of a wave and the all-in; `choose_target` (the function M6's influence maps replace) | M5b | -| `standard/defend.rs` | Threats to every own complete Town, the home guard and siege interception, the latch and its release, the 60 s launch cap with the guard sized to the threat, wave recall | M5b | -| `tests/common/mod.rs`, `tests/m5b.rs` | The `Arena` testkit; the bot's integration tests (`bot_takes_seat` and the cadence, determinism and rebuild tests run; the long `m5b_` games are ignored and run in CI with `--ignored m5b_`; `golden_m5_first_match` is ignored and run locally with `--ignored golden_m5_first_match`, CI checking the fixture with `sim-cli verify --release` and the game's `--headless-run` instead) | M5b | +| `standard/army.rs` | Production (Supply Wains first, then the army mix by deficit over the kinds the bot can field), `home_armed`, the rally upkeep; M6: the mix weighted by `compose` and per-kind noise, emergency production toward `first_raid.min_armed` before the military latch, Wains only while the Wain rule asks for one | M5b, M6 | +| `standard/waves.rs` | Launch, stage point, assault with siege focus, the Wain point and leash, ring hold, defender clearing, continue, the end of a wave and the all-in; M6: `choose_target` scored by distance and the influence around each Town (`seat_last`, noise between the best two), raids (`raid_target`: the most worked enemy building outside tower reach, `launch_raid`, `run_raid`), the level's deadline raid, the Wain rule, the age or tech gate, and the size, first-wave and interval noise; M6 retune (M6 owner decision 12), the level's `pressure`: `home_guard`, `rally_group` and `reinforce` (rally groups join an assaulting, holding or gated-staging wave, and the all-in goes in groups), `committed` (a siege whose target is losing hit points neither times out nor retreats), the lure at the Wain point (`luring`), cohesion, and the launch and assault strength gates (`army_strength`, `enemy_home_strength`, `assault_ready`; the all-in passes both) | M5b, M6 | +| `standard/defend.rs` | Threats to every own complete Town, the home guard and siege interception, the latch and its release, the 60 s launch cap with the guard sized to the threat, wave recall; M6: `defend_economy` (raiders at worked buildings away from a Town), tower guards attacking Wains first, and the defended building that `reactive_tower` answers | M5b, M6 | +| `standard/influence.rs` | M6: per-plan influence grids (own units, enemy units, enemy shooters; summed hit points and damage per second per 4x4-tile cell), the Lanchester `strength` of a block of cells, `isqrt`, the `StatsCache` | M6 | +| `standard/compose.rs` | M6: counter-weighted composition (`weighted_mix`: the mix weighted by the visible enemy kinds and the `units.ron` counter table, floor, cap, and the counter kinds that join the mix) | M6 | +| `standard/retreat.rs` | M6: retreat when losing (`assess`, `losing`, `step`: a wave's own strength against the enemy's around it and its losses, the commit window and the no-retreat cases, one grouped `Move` home and the cooldown; M6 retune: a `committed` siege never retreats, and the level's `pressure.retreat_*` permilles scale the ratio and cooldown, 0 meaning the level never retreats) | M6 | +| `tests/common/mod.rs`, `tests/m5b.rs`, `tests/m6.rs` | The `Arena` testkit; the bot's integration tests (`bot_takes_seat` and the cadence, determinism and rebuild tests run; the long `m5b_` games are ignored and run in CI with `--ignored m5b_`; `golden_m5_first_match` is ignored and run locally with `--ignored golden_m5_first_match`, CI checking the fixture with `sim-cli verify --release` and the game's `--headless-run` instead); M6's long games are the ignored `m6_` tests (Hard's attack before minute 12 for every personality from both seats, rush's early attack, every level and personality taking Passive's Seat, memory under 1 KiB, restore during a raid and after a retreat), run by CI's `fun-gate` job | M5b, M6 | See [design/ai.md](design/ai.md), "M5b implementation notes". @@ -215,10 +218,11 @@ Headless tooling that runs on an Ubuntu CI runner with no GPU. | `hash-dump --ticks N --seed S` | Prints the whole-state hash after every tick of the scripted match | M0 | | `hash-dump --every N` | Re-simulates a replay and prints `tick hash units buildings economy territory tech pathing rng map meta` every N ticks, for bisecting | M1 (done) | | `verify ` | Re-simulates a replay and prints exactly one of `OK final_hash=0x... ticks=N`, `DIVERGED at tick N (subsystem: X)`, `SIM VERSION MISMATCH (...)`, `RULES CHANGED since recording (...)`; exit 0 only for OK | M1 (done) | -| `bench --units N --ticks T` / `bench --astar --budget B` / `bench --territory --sources S --edits E` / `bench --units N --ticks T --combat [--harrying]` / `bench --units N --ticks T --bots` | Mean and p95 step time plus arrival percentage for N movers crossing the map, the cost of one saturated A* tick, mean and p95 incremental territory recompute over E random source edits with a full-recompute equality check (exit 1 on mismatch), or mean, p95 and fighting-tick step time while N units of four line kinds fight, half on each side (`sim::scenarios::combat_bench`; exit 1 when fewer than 100 ticks were fighting), and with `--harrying` the same battle with attrition on (`combat_bench_harrying`: both players at Harrying II, a P1 Town beside the arena, a Supply Wain in each attack-move), adding `attrition_pulses=`, `supplied=` and `exposed=`, or with `--bots` the time inside two Standard bots' `think` per tick (`scenarios::bots_bench`: two seats at Masonry with 200 units a side, half at the own rally and half on the enemy's land) against the same stream with Passive seats, with the waves and defences they made (exit 1 when the bots were not exercised); wall clock is allowed in sim-cli, not in sim | M1 (done), M3a (`--territory`, done), M4a (`--combat`, done), M5a (`--harrying`, done), M5b (`--bots`, done) | +| `bench --units N --ticks T` / `bench --astar --budget B` / `bench --territory --sources S --edits E` / `bench --units N --ticks T --combat [--harrying]` / `bench --units N --ticks T --bots [--bot ] [--personality ]` | Mean and p95 step time plus arrival percentage for N movers crossing the map, the cost of one saturated A* tick, mean and p95 incremental territory recompute over E random source edits with a full-recompute equality check (exit 1 on mismatch), or mean, p95 and fighting-tick step time while N units of four line kinds fight, half on each side (`sim::scenarios::combat_bench`; exit 1 when fewer than 100 ticks were fighting), and with `--harrying` the same battle with attrition on (`combat_bench_harrying`: both players at Harrying II, a P1 Town beside the arena, a Supply Wain in each attack-move), adding `attrition_pulses=`, `supplied=` and `exposed=`, or with `--bots` the time inside two Standard bots' `think` per tick (`scenarios::bots_bench`: two seats at Masonry with 200 units a side, half at the own rally and half on the enemy's land) against the same stream with Passive seats, with the waves and defences they made (exit 1 when the bots were not exercised); from M6 `--bot` and `--personality` pick the level and personality of both seats (default `standard`/`standard`), and the report adds `raids=`, `retreats=` and `influence_plans=` (plans that built the influence maps; the exit gate also needs 20 of them once any wave went out); wall clock is allowed in sim-cli, not in sim | M1 (done), M3a (`--territory`, done), M4a (`--combat`, done), M5a (`--harrying`, done), M5b (`--bots`, done), M6 (`--bot`, `--personality`, the influence gate, done) | | `fuzz --ticks N --seed S --cases C` | Seeded random command streams run twice and compared | M1 (done) | | `record --scenario --ticks N --out [--hash-every-tick \| --hash-every N]` | Records a scripted scenario (any name in `sim::scenarios::NAMES`: `move_500`, `move_500_short`, `group_spiral`, `snapshot_restore`, from M2 `units200`, `units200_auto`, `scripted_moves`, `hud_click`, from M3a `economy_second_town`, from M3b `economy_walkthrough`, from M4a `combat_skirmish`, from M4b `unit_looks`, `enemy_outpost` and `enemy_outpost_walkthrough`, and from M5a `capture_seat` and `stalemate_45min`, from the art pass `art_bench`, and the sim-cli scenario `m5_first_match` from M5b, the Standard bot in both seats, outside `NAMES`) to an `.eonreplay`; the goldens are made with it. From M5a a scenario sets its own hash cadence (1000 ticks for `stalemate_45min`, 100 for `m5_first_match`), `capture_seat` and `m5_first_match` stop right after the step that decides the match, and the summary line adds `outcome=... reason=... outcome_tick=...` once the match is decided | M1 (done), M2 (names), M3a (name), M3b (name), M4a (name), M4b (names), M5a (names, `--hash-every`, the outcome), M5b (`m5_first_match`) | -| `play-bots --seeds A..B [--a passive\|standard] [--b ...] [--personality-a ...] [--personality-b ...] [--max-ticks N] [--jobs N] [--allow-undecided]` | Seeded bot-vs-bot games on `--jobs` threads, one CSV row per game on stdout in seed order (the seven docs/design/ai.md columns, then diagnostics; no wall time, so two runs `diff` clean), timings on stderr; exit 1 when a game has no winner (unless `--allow-undecided`) or panics, 2 on usage, rules or bot-id errors. From M5b `verify`, `hash-dump `, `record` and the game build every replay's bots from its header (`ai::controller_for`) | M5b (slice 1: the harness, with the Standard bot's skeleton; the economy slice: the bot's economy, build order, sites, defence and production, and B's `second_town_tick`, `tower_tick` and `bo_done_tick` appended to the CSV) | +| `play-bots --seeds A..B [--a passive\|] [--b ...] [--personality-a [,...]] [--personality-b ...] [--mirrors] [--max-ticks N] [--jobs N] [--allow-undecided]` | Seeded bot-vs-bot games on `--jobs` threads, one CSV row per game on stdout in seed order (the seven docs/design/ai.md columns, then diagnostics; no wall time, so two runs `diff` clean), timings on stderr; exit 1 when a game has no winner (unless `--allow-undecided`) or panics, 2 on usage, rules or bot-id errors. From M5b `verify`, `hash-dump `, `record` and the game build every replay's bots from its header (`ai::controller_for`). Since M6 two personality lists of several entries walk the ordered distinct pairs, A fastest (`--mirrors` adds the same-id pairs), `random` resolves per seed and seat from the `random: true` personalities, and the CSV appends the M6 columns (docs/design/ai.md) | M5b (slice 1: the harness, with the Standard bot's skeleton; the economy slice: the bot's economy, build order, sites, defence and production, and B's `second_town_tick`, `tower_tick` and `bo_done_tick` appended to the CSV), M6 (slice 1: difficulty ids, pairing, `--mirrors`, `random`, the M6 columns, done) | +| `match-log [--date D] [--commit C]` | Re-simulates a replay with the bots its header names (`sim::verify_observed`, every recorded hash checked), feeds `MatchStats` after every step and prints its docs/PLAYTEST.md match-log row, a statistics table and the verify line; exit 0 only when the replay verifies OK (`just match-log ` fills the date and commit) | M6 (slice 1, done) | A global `--data ` flag (default `data`) selects the data directory. Every subcommand has landed since M5b's `play-bots`; until then the missing ones exited with code 2 and printed `not implemented until `. Scenario command streams are defined once, in an engine-free crate, and shared by `record` and the tests in `crates/sim/tests/m1.rs`, so a fixture and the test that checks it can never disagree about the input. @@ -244,7 +248,7 @@ The art pass (slice 2) vendored KayKit Medieval Hexagon (buildings, construction ### `.github/` -`ci.yml` runs five jobs: `check` on macOS (fmt, clippy, nextest, doc, machete, typos, asset and trademark gates, single-Bevy check, the game's headless replay of the `move_500` fixture, `sim-cli verify --release` of that fixture and, from M4a, of `combat_skirmish`, from M4b of `enemy_outpost_walkthrough`, and from M5a of `capture_seat` and `stalemate_45min`), `headless` on Ubuntu (sim, rules, ai and sim-cli tests with `PROPTEST_CASES=256`, from M4b the ignored `enemy_outpost` tests, data-check, selftest, `verify --release` of the `move_500`, `group_spiral`, `snapshot_restore`, `economy_second_town`, `economy_walkthrough`, `combat_skirmish`, `enemy_outpost_walkthrough` and, from M5a, `capture_seat` and `stalemate_45min` fixtures, engine boundary check, `pathfinding`-is-dev-only check, the same headless replay run and, from M4b, the game's `--headless-run` of `enemy_outpost_walkthrough`), `game-linux` on Ubuntu (clippy of the game crate, then `cargo build` and the Xvfb + lavapipe render smoke test `scripts/render_smoke.sh` over the `economy_walkthrough`, from M4b the `unit_looks` and `enemy_outpost` and, from the art pass, the `art_bench` scenario, which fails on any ERROR log line and uploads the `render-smoke` artifact; since the art pass every smoke runs with `--draw-stats` and a step gates each run's draw calls under 500 with `scripts/check_draws.sh`), `deny` (cargo-deny) and `hash-parity` (diffs the `tick=1200 hash=...` replay line and the `move_500`, `combat_skirmish` and, from M5a, `capture_seat` and `stalemate_45min` `OK final_hash=...` lines produced on each OS; until M2 the first line was the 200-tick skirmish smoke). `release-check.yml` builds release and bundles on pushes to main. `release.yml` publishes on tags. Dependabot ignores Bevy and the two egui crates for minor and major bumps. See [RELEASING.md](RELEASING.md). +`ci.yml` runs six jobs: `check` on macOS (fmt, clippy, nextest, doc, machete, typos, asset and trademark gates, single-Bevy check, the game's headless replay of the `move_500` fixture, `sim-cli verify --release` of that fixture and, from M4a, of `combat_skirmish`, from M4b of `enemy_outpost_walkthrough`, and from M5a of `capture_seat` and `stalemate_45min`; from M6 the "M6 acceptance 3" step: two `play-bots --seeds 7..7` runs and a Hard-rush against Easy-tower pair beside them, each pair diffed, the hashes written to `hash-macos-seed7.txt`), `headless` on Ubuntu (sim, rules, ai and sim-cli tests with `PROPTEST_CASES=256`, from M4b the ignored `enemy_outpost` tests, data-check, selftest, `verify --release` of the `move_500`, `group_spiral`, `snapshot_restore`, `economy_second_town`, `economy_walkthrough`, `combat_skirmish`, `enemy_outpost_walkthrough` and, from M5a, `capture_seat` and `stalemate_45min` fixtures, engine boundary check, `pathfinding`-is-dev-only check, the same headless replay run and, from M4b, the game's `--headless-run` of `enemy_outpost_walkthrough`; from M6 the same "M6 acceptance 3" step, writing `hash-linux-seed7.txt`), `fun-gate` on Ubuntu (M6: ROADMAP M6 lines 1, 2 and 4 with the literal `play-bots` commands through `scripts/fun_gate.sh`, `check_ordering.sh`, `check_attack_before.sh` and `check_difficulty_flip.sh`, then `cargo test -p ai -- --ignored m6_`; every step and every command in it runs, and the job fails if any does), `game-linux` on Ubuntu (clippy of the game crate, then `cargo build` and the Xvfb + lavapipe render smoke test `scripts/render_smoke.sh` over the `economy_walkthrough`, from M4b the `unit_looks` and `enemy_outpost` and, from the art pass, the `art_bench` scenario, which fails on any ERROR log line and uploads the `render-smoke` artifact; since the art pass every smoke runs with `--draw-stats` and a step gates each run's draw calls under 500 with `scripts/check_draws.sh`), `deny` (cargo-deny) and `hash-parity` (diffs the `tick=1200 hash=...` replay line and the `move_500`, `combat_skirmish` and, from M5a, `capture_seat` and `stalemate_45min` `OK final_hash=...` lines produced on each OS, and from M6 the two seed-7 hash files; until M2 the first line was the 200-tick skirmish smoke). `release-check.yml` builds release and bundles on pushes to main. `release.yml` publishes on tags. Dependabot ignores Bevy and the two egui crates for minor and major bumps. See [RELEASING.md](RELEASING.md). ### `docs/` diff --git a/docs/BUILD_TIMES.md b/docs/BUILD_TIMES.md index e96e6b1..296eecb 100644 --- a/docs/BUILD_TIMES.md +++ b/docs/BUILD_TIMES.md @@ -54,6 +54,33 @@ This document records measured build, test and CI wall times on the development | `cargo test -p ai --profile ci` at the M5b close (58 lib tests, 6 in `tests/m5b.rs`, 15 ignored) | 2026-10-26 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac | 0.86 to 0.92 warm (three runs: 0.86, 0.88, 0.92; the binaries report 0.30 and 0.28 s) | Budget: under 10 s for the crate's non-ignored tests (M5b plan). `-- --ignored m5b_`: 14 passed, the m5b binary 14.0 s; `-- --ignored golden_m5_first_match`: 1 passed in 1.7 s. | | `sim-cli verify --release crates/sim/tests/fixtures/m5_first_match.eonreplay` at the M5b close | 2026-10-26 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac | 1.35 to 1.42 (three runs: 1.40, 1.35, 1.42) | `OK final_hash=0x3d2552033d51b4a2 ticks=26012`. | | `just m5b-checks` at the M5b close (ROADMAP M5b line 4's proxy: the golden's verify, the fast-forwarded overlay run, the menu run, the New Game run, `scripts/m2_checks.sh` checks 3 and 4, all windowed runs under Xvfb on lavapipe in background mode) | 2026-10-26 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, llvmpipe software Vulkan; not the dev Mac | 164 wall, the binaries already built | Every step `PASS` (docs/PLAYTEST.md, "Automated proxies (M5b)"). | +| `cargo test -p sim --profile ci` in M6 slice 1 (`SIM_VERSION` 18, `rules_version` 26: the bonus, `ai_noise`, `RateInfo::at_cap` and `MatchStats` lib tests, and `MatchStats` assertions inside the existing `capture_seat` test, no second run) | 2026-10-29 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac | 8.7 to 8.8 warm (three runs: 8.76, 8.69, 8.67; load average 1.1 to 2.1) | Budget: under 10 s on the dev Mac; same shell-clock method. The new sim lib tests take milliseconds; no mitigation was needed. | +| `cargo test -p ai --profile ci` in M6 slice 1 (71 lib tests with the difficulty, `bot_stats`, `random` and slice 2's influence and compose tests; 6 in `tests/m5b.rs`, the rebuild test now also over a Hard and an Easy seat; 15 ignored) | 2026-10-29 | same VM | 1.10 to 1.11 warm (two runs: 1.11, 1.10; the binaries report 0.38 and 0.31 s) | Budget: under 10 s for the crate's non-ignored tests. `-- --ignored`: 15 passed in 15.7 s (the m5b binary), the restore test now also restoring a Hard-against-Easy game at 3003. | +| `sim-cli play-bots --seeds 7..7` twice (ROADMAP M6 line 3; release, `SIM_VERSION` 18, `rules_version` 26) | 2026-10-29 | same VM | 1.52 and 1.39 shell (`wall_s=1.47` and `1.36`) | `7,a,decisive,22832,0xf977475b5041b09f,...,seat_captured` both times, `diff` empty; the Hard-against-Easy pair CI runs beside it, `0x894a27abd3d954e5` at tick 24296, 1.95 and 1.67 s. Each OS job adds about four such games, under 15 s. | +| ROADMAP M5b acceptance 1 in M6 slice 1 (same command, release) | 2026-10-29 | same VM | 3.9 wall for the five games (`wall_s=3.88`, with clippy building in parallel) | Every game a decisive Seat capture by player a at tick 22832 (the Standard level's bonus from tick 4100 ends it earlier than 26012), seed 1's hash `0x7207c1e8dc3ce351` is `m5_first_match.hash`. | +| `sim-cli verify --release crates/sim/tests/fixtures/m5_first_match.eonreplay` in M6 slice 1 (22832 ticks) | 2026-10-29 | same VM | 1.19 to 1.37 (three runs: 1.19, 1.26, 1.37) | `OK final_hash=0x7207c1e8dc3ce351 ticks=22832`; the game's `--headless-run` prints `tick=22832 hash=0x7207c1e8dc3ce351`. `just rerecord` of all eleven fixtures took 13.5 s with the release binary already built. | +| `cargo test -p sim --profile ci` in M6 slice 2 (`rules_version` 28; no sim code changed, the fixtures re-recorded) | 2026-10-29 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac | 8.6 to 8.8 warm (three runs: 8.59, 8.77, 8.63; load average 2.2 to 3.2) | Budget: under 10 s. | +| `cargo test -p ai --profile ci` in M6 slice 2 (92 lib tests, 6 in `tests/m5b.rs`, `tests/m6.rs` compiled; 20 ignored) | 2026-10-29 | same VM | 1.35 warm (two runs) | Budget: under 10 s. `-- --ignored m5b_ m6_ golden_m5_first_match`: 15 and 5 tests in 20.3 s and 13.7 s of test time (1 min 20 s with the build). | +| ROADMAP M6 line 1 (`play-bots --seeds 1..20 ... --personality-a rush,boom,tower --personality-b rush,boom,tower --max-ticks 72000 --jobs 5`, release, `rules_version` 28) | 2026-10-29 | same VM | 15.7 (`wall_s=15.71`; 17 s shell with the binary built) | 20 decisive games from 16884 to 42567 ticks, at most 4.3 s each; seeds 21..40 and 41..60 15.9 and 15.2 s. | +| ROADMAP M6 line 2 (two literal commands, default `--jobs 1`) | 2026-10-29 | same VM | 209 and 101 (`wall_s`) | Easy against Standard runs 12 of 20 games to 54000 ticks; `--jobs 4` divides it by about 3.5. | +| `scripts/check_difficulty_flip.sh` (four runs of 20 games at `--jobs 4`) | 2026-10-29 | same VM | 177 shell | 29, 43, 44 and 61 s per run. | +| `sim-cli play-bots --seeds 7..7` twice (ROADMAP M6 line 3) at `rules_version` 28 | 2026-10-29 | same VM | 4.87 and 4.75 shell (`wall_s=4.83` and `4.72`) | `7,a,decisive,39988,0x049460f3de373f69,...`; the hard-rush/easy-tower pair beside it 7.8 and 8.0 s (`0xdcb4a653ae3d2ebd`, b at 53818). | +| `sim-cli verify --release crates/sim/tests/fixtures/m5_first_match.eonreplay` at `rules_version` 28 (41889 ticks) | 2026-10-29 | same VM | 4.3 to 4.7 (three runs: 4.69, 4.47, 4.30) | `OK final_hash=0xf78f9cf501cfe707 ticks=41889`; the game's `--headless-run` prints `tick=41889 hash=0xf78f9cf501cfe707`. `just rerecord` of all eleven fixtures took 19.3 s with the release binary built (2 min 24 s at `rules_version` 27, building it). | +| `sim-cli bench --units 400 --ticks 1200 --bots` at `rules_version` 28 (three runs each) | 2026-10-29 | same VM | `ai_ms mean` 0.0331, 0.0333, 0.0401 ms (`influence_plans=32`, 2 waves); with `--bot hard --personality rush` 0.0323, 0.0301, 0.0296 ms (2 raids, `influence_plans=0`: these do not cover the influence, retreat or target path, and since the review fix the gate fails this 1200-tick run); with `--bot hard --personality rush --ticks 1800` 0.0287, 0.0259, 0.0230 ms (`influence_plans=49`, 2 raids, exit 0) | ROADMAP M5b's budget is 0.5 ms; the M6 plan's 0.1 ms. The Hard-rush raids leave in the bench's last 20 s, inside their `retreat_commit_ds`, so only the longer run reaches their retreat checks. | +| `cargo test -p sim --profile ci` at the M6 retune (`rules_version` 30; no sim code changed, the fixtures re-recorded) | 2026-10-30 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac | 8.7 to 9.3 warm (three runs: 8.72, 9.29, 8.67; load average 1.2 to 2.9) | Budget: under 10 s. | +| ROADMAP M6 line 1 at `rules_version` 30 (the literal command, release) | 2026-10-30 | same VM | 13.3 and 12.7 (`wall_s`, two runs, `diff` empty) | 20 winners, 19 decisive before 48000, median 26796.5; seeds 21..40 and 41..60 12.3 and 13.0 s. | +| ROADMAP M6 line 2 at `rules_version` 30 (the two literal commands at `--jobs 4`, as CI runs them) | 2026-10-30 | same VM | 58.6 and 21.0 (`wall_s`) | Easy against Standard: one game to the cap (54000 ticks), median tick 51722; Standard against Hard: median 34176, longest 44890. | +| `scripts/check_difficulty_flip.sh` at `rules_version` 30 | 2026-10-30 | same VM | 184 shell | 28, 29, 56 and 71 s per run (the runs at 300 go longer: 9 and 6 tiebreaks). | +| `sim-cli play-bots --seeds 7..7` twice (ROADMAP M6 line 3) at `rules_version` 30 | 2026-10-30 | same VM | 4.22 and 4.18 (`wall_s`) | `7,b,decisive,46075,0x5d56c36dc4ba164e,...` both times, `diff` empty; the hard-rush/easy-tower pair beside it 1.4 s (`0x6993967af0e04396`, a at 19936). | +| `sim-cli verify --release crates/sim/tests/fixtures/m5_first_match.eonreplay` at `rules_version` 30 (52108 ticks) | 2026-10-30 | same VM | 5.0 to 5.5 (three runs: 5.54, 5.15, 5.05) | `OK final_hash=0xfe374e4de42eae0e ticks=52108`. | +| `sim-cli bench --units 400 --ticks 1200 --bots` at `rules_version` 30 (three runs; the bench sets the levels' launch and assault gates to 1 per mille and restores the retreat of a level that never retreats, docs/design/ai.md) | 2026-10-30 | same VM | `ai_ms mean` 0.0365, 0.0357, 0.0397 ms (p95 0.24 to 0.28, `influence_plans=32`, 2 waves, exit 0); `--bot hard --personality rush --ticks 1800` 0.0208 ms (`influence_plans=49`, exit 0) | Budget 0.5 ms (M6 plan: 0.1). Hard rush at 1200 ticks still fails the exercise gate (`influence_plans=0`), as at 28. | +| `cargo test -p sim --profile ci` after the review of the retune (`rules_version` 31; sim code changed only in a doc comment, the fixtures re-recorded) | 2026-10-30 | same VM | 8.7 to 9.7 warm (three runs: 8.86, 9.65, 8.70; load average 3.3 to 3.7) | Budget: under 10 s. | +| ROADMAP M6 line 1 at `rules_version` 31 (the literal command, release) | 2026-10-30 | same VM | 12.3 and 11.9 (`wall_s`, two runs, `diff` empty) | 20 winners, 19 decisive before 48000, median 26481; seeds 21..40 and 41..60 10.5 and 12.8 s. | +| ROADMAP M6 line 2 at `rules_version` 31 (the two literal commands at `--jobs 4`, as CI runs them) | 2026-10-30 | same VM | 58.0 and 17.7 (`wall_s`) | Easy against Standard: three games to the cap (54000 ticks), median tick 52177.5; Standard against Hard: median 32048.5, longest 51988. | +| `scripts/check_difficulty_flip.sh` at `rules_version` 31 | 2026-10-30 | same VM | 180 shell | 28, 28, 55 and 69 s per run; its CSVs are 30's (Standard plays no part). | +| `sim-cli play-bots --seeds 7..7` twice (ROADMAP M6 line 3) at `rules_version` 31 | 2026-10-30 | same VM | 2.62 and 2.61 (`wall_s`) | `7,b,decisive,26823,0x293a97cb1b22256f,...` both times, `diff` empty; the hard-rush/easy-tower pair beside it 1.37 and 1.29 s (`0x6993967af0e04396`, a at 19936, unchanged). | +| `sim-cli verify --release crates/sim/tests/fixtures/m5_first_match.eonreplay` at `rules_version` 31 (38818 ticks) | 2026-10-30 | same VM | 3.9 to 4.0 (three runs: 3.91, 4.00, 3.93) | `OK final_hash=0xa9d87378122649bf ticks=38818`. | +| `sim-cli bench --units 400 --ticks 1200 --bots` at `rules_version` 31 (three runs; the bench's gate and retreat overrides as at 30, now restoring only Hard's retreat) | 2026-10-30 | same VM | `ai_ms mean` 0.0394, 0.0369, 0.0359 ms (p95 0.26 to 0.28, `influence_plans=32`, 2 waves, exit 0); `--bot hard --personality rush --ticks 1800` 0.0237 ms (`influence_plans=49`, exit 0) | Budget 0.5 ms (M6 plan: 0.1). Hard rush at 1200 ticks still fails the exercise gate (`influence_plans=0`, exit 1), as at 28 and 30. | | `cargo test -p rules` | 2026-10-05 | same | 0.01 run (about 5 compile, cold crate) | 15 tests. | | `cargo clippy -p sim -p rules -p ai -p sim-cli --all-targets` | 2026-10-05 | same | 0.37 | Warm. | | `cargo run -q -p sim-cli -- data-check data` | 2026-10-05 | same | 1.8 | Warm target dir, debug profile. | @@ -105,6 +132,7 @@ This document records measured build, test and CI wall times on the development | `hash-parity` | ubuntu-24.04 | 2026-10-06 | 5 | 6 | Artifact download plus `diff`. Needs `check` and `headless`. | | `headless`, the steps M5b's waves slice adds | ubuntu-24.04 | 2026-10-23 | not yet measured | about 25 s of work measured locally | Not yet run on GitHub Actions (nothing pushed). On the 4-vCPU VM: the `m5_first_match` verify 1.5 s, acceptance 1's play-bots twice 5 s, acceptance 2 from either side 3.5 s, the ignored `m5b_` tests 11.3 s (built by the job's earlier `cargo test`), the game's `--headless-run` of `m5_first_match` 1.9 s (the game is built for `move_500`). The `check` job adds the 1.5 s verify; `hash-parity` one more `diff`. Estimated 1 to 2 minutes on the runners. | | `game-linux`, the `art_bench` render smoke and the draw-call gate art-pass slice 6 adds | ubuntu-24.04 | 2026-10-28 | not yet measured | about 125 s of work measured locally | Not yet run on GitHub Actions (nothing pushed). The run lasts its 120 s deadline (`art_bench '' 200 120`) plus start-up; the gate step reads four logs in well under a second. Measured on the 4-vCPU VM: tick 200 at 63.8 s. Read the real figure off the art pass PR's Actions run. | +| `check` and `headless`, the M6 line-3 step (seed 7 twice, Standard and Hard against Easy) | macos-26, ubuntu-24.04 | 2026-10-29 | not yet measured | about 8 s of games measured locally (`rules_version` 31; about 11 s at 30, 25 s at 28) | Not yet run on GitHub Actions (nothing pushed). Four one-game `play-bots` runs on the release `sim-cli` each job already builds for `verify`. At `rules_version` 31: 2.6 + 2.6 s for Standard against Standard and 1.4 + 1.3 s for the Hard-rush against Easy-tower pair (the rules-31 seed-7 row above); at 30 4.2 + 4.2 and 1.4 + 1.4 s; at 28 4.87 + 4.75 and 7.8 + 8.0 s; 6.5 s at `rules_version` 26. | ## Audio backend at M0 @@ -131,6 +159,7 @@ Append a row whenever a bench acceptance passes. Budgets come from the design. | AI think time per tick (`sim-cli bench --units 400 --ticks 1200 --bots`, release: `scenarios::bots_bench`, two Standard seats at Masonry with 2000 of each resource, 200 units a side, half at the own rally with 2 Mangonels and a Supply Wain, half on the enemy's land 12 tiles north of its Seat; `ai_ms` is the time inside the bots' `think` per tick) | M5b | 2026-10-23 | 0.032 to 0.036 | 0.21 to 0.22 | < 0.5 | Measured on a 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac; `SIM_VERSION` 17, `rules_version` 23. Three runs at seed 1 at the slice's head: `ai_ms` mean 0.0360 / 0.0342 / 0.0317 ms, p95 0.218 / 0.224 / 0.208 ms, max 1.722 / 1.255 / 1.243 ms; `plans=240` (every 10 ticks, two seats), `plan_ms_max` 1.24 to 1.72 ms (one plan), `commands=268`; whole step mean 0.696 / 0.649 / 0.620 ms (p95 2.6 to 2.8 ms) against 0.771 / 0.723 / 0.692 ms for the same stream with Passive seats (`control_step_ms`); both seats defend first, then each launches a wave (`waves_launched=2 defends=2`); `rejected=5` (orders for units that died during the command delay); `path_queue_max=307`; `final_hash=0x1408f5fd31a1ba6f` every run. Exit 0 (the bench exits 1 only when the bots were not exercised: fewer than 200 plans, 2 waves or 1 defence). Re-measure on the dev Mac. | | AI think time per tick after the M5b waves slice's review fixes (same command and world) | M5b | 2026-10-23 | 0.033 to 0.036 | 0.20 to 0.21 | < 0.5 | Same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac; `SIM_VERSION` 17, `rules_version` 24. Three runs at seed 1: `ai_ms` mean 0.0338 / 0.0361 / 0.0332 ms, p95 0.213 / 0.203 / 0.206 ms, max 2.757 / 2.298 / 1.376 ms; `plans=240`, `plan_ms_max` 1.38 to 2.76 ms, `commands=268`; whole step mean 0.629 / 0.666 / 0.634 ms against 0.677 / 0.714 / 0.731 ms for `control_step_ms`; `waves_launched=2 defends=2 engaged=0 rejected=5 path_queue_max=307 final_hash=0x1408f5fd31a1ba6f`, the slice head's hash, so no decision in this 1200-tick world changed (a defence's cap is only reached after 1200 ticks of it, and the build backoff is for border buildings only). Exit 0 every run. Re-measure on the dev Mac. | | AI think time per tick at the M5b close (same command and world) | M5b | 2026-10-26 | 0.033 to 0.035 | 0.21 to 0.28 | < 0.5 | Same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac; `SIM_VERSION` 17, `rules_version` 24. Three runs at seed 1: `ai_ms` mean 0.0332 / 0.0351 / 0.0346 ms, p95 0.206 / 0.280 / 0.217 ms, max 1.607 / 1.280 / 1.370 ms; `plans=240`, `plan_ms_max` 1.60 / 1.28 / 1.37 ms, `commands=268`; whole step mean 0.670 / 0.689 / 0.689 ms against 0.745 / 0.747 / 0.734 ms for `control_step_ms`; `waves_launched=2 defends=2 engaged=0 rejected=5 path_queue_max=307 final_hash=0x1408f5fd31a1ba6f`. Exit 0 every run. ROADMAP M5b line 3 is ticked on these runs (PR #47). Re-measure on the dev Mac. | +| AI think time per tick in M6 slice 1 (same command and world; `bench --bots` now names `bot=standard personality=standard`) | M6 | 2026-10-29 | 0.036 to 0.038 | 0.22 to 0.23 | < 0.5 (M6 slice gate: mean <= 0.1) | Same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac; `SIM_VERSION` 18, `rules_version` 26. Three runs at seed 1: `ai_ms` mean 0.0378 / 0.0356 / 0.0360 ms, p95 0.228 / 0.216 / 0.234, `plans=240`, `plan_ms_max` 2.00 / 1.38 / 1.42, `waves_launched=2 defends=2 raids=0 retreats=0`, `final_hash=0x1408f5fd31a1ba6f` (unchanged: the 1200-tick world ends before any bonus); `step_ms` mean 0.655 to 0.678 against `control_step_ms` 0.741 to 0.813. With `--bot hard`: mean 0.0331, `final_hash=0x6f3a9dec5cb1fa92` (Hard's resolved wave interval). | | Frame time, `art_bench` (400 units fighting + two full Towns, no fog), `--window-size 1280x720` on the Retina display (2560x1440 physical), release, window focused (art pass AP5) | Art pass | TBD (the owner's Mac run) | | | >= 60 fps in the `fight` line, p95 <= 16.7 | The owner pastes the `window`, `fight`, `draws` (with `mode=`), `triangles` and `minimap` lines and the `--max-fps 1000` headroom run (docs/PLAYTEST.md, "Owner's Mac checklist (art pass)"). Lavapipe proxy, which can neither pass nor fail it: draws max 109, `triangles main_max=2090277`, 0.9 fps. | | Frame time, 400 units + two Towns + fog, 1440p release | M7 | TBD (M7) | | | >= 60 fps on a base M-series chip | Draw calls < 500 from the dev-panel counter | diff --git a/docs/DATA_FORMAT.md b/docs/DATA_FORMAT.md index 591071e..0226d78 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; `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). +- `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, its `difficulty/` levels from M6) 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,9 +69,9 @@ 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 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)). +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/`, from M5b `ai/personalities.ron` and every `ai/build_orders/*.ron`, and from M6 every `ai/difficulty/*.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`. +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. Added in M6: every build order builds a trainer of an armed kind (such as a Muster Hall) in a non-optional step before its first `Advance`; every difficulty level under `data/ai/difficulty/` has an `id` equal to its file stem, never `passive`, and a non-empty name, a `standard` level exists, its `income` names resource ids with amounts of 0 to 500, `income_interval_ds` is 0 or 50 to 72000 and in ticks more than `cmd_delay_ticks`, `income_from_ds` and `first_raid.by_ds` are below `match_cap_ds`, the multipliers are 250 to 4000 per mille, `max_wave_units` is 1 to 64, an `Age(..)` gate names an age, and a `first_raid` has `1 <= min_armed <= max_armed <= 64` and `prep_ds < by_ds`, and `worker_target_permille` is 500 to 1000; since the M6 retune (owner decision 12) a level's `pressure` block has `reinforce_min_units` and `home_guard_units` of 0 to 64, `commit_ds` and `lure_ds` of 0 to 72000, `retreat_ratio_permille` 0 to 4000, `retreat_cooldown_permille` 250 to 4000, `cohesion_tiles` 0 to 64 and `launch_ratio_permille` and `assault_ratio_permille` 0 to 10000, and its `extra_research` names at most 8 techs, each once. M6's slice 2 adds the personality knobs of retreat, raids, influence, counters, the defence and the noise: `wave_requires_tech` names a tech, raid sizes are 1 to 64 with `raid_min_armed <= raid_max_armed`, times are up to 72000 ds and radii 1 to 64 tiles, per-mille shares stay in their ranges (counter factors 0 to 8000 with floor <= 1000 <= join <= cap, noise up to 500), `counter_kinds` are armed non-civilian kinds listed once, the influence cell is 1 to 16 tiles and its radius 0 to 8 cells, and `noise_site_top_k` is 1 to 8. The field-by-field list is in `data/rules/README.md` and `data/ai/README.md`. ## How `data-check` reports errors @@ -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=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 visuals=units:8 buildings:8 terrain:3 models=36` (`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; since the art pass `visuals=` counts the unit and building entries and the dressed terrains of `visuals.ron`, and `models=` the distinct glTF files its Scenes name, each of which must be a file under the assets directory: `--assets ` (default `/../assets`, the repository's `assets/`), or `--no-assets` to skip the check, which appends ` (files not checked: --no-assets)`; a missing file is an `Invalid` error naming `visuals.ron`, the field and the path, such as `` data/visuals.ron: buildings["town"].path: "models/x.gltf" is not a file under assets ``). 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=30 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` (`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, and `difficulties` its levels, from M6; 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; since the art pass `visuals=` counts the unit and building entries and the dressed terrains of `visuals.ron`, and `models=` the distinct glTF files its Scenes name, each of which must be a file under the assets directory: `--assets ` (default `/../assets`, the repository's `assets/`), or `--no-assets` to skip the check, which appends ` (files not checked: --no-assets)`; a missing file is an `Invalid` error naming `visuals.ron`, the field and the path, such as `` data/visuals.ron: buildings["town"].path: "models/x.gltf" is not a file under assets ``). Exit code 1 prints one line starting with `error:` in one of three shapes, from `rules::Error`: | Variant | Format | Example cause | |---|---|---| @@ -107,14 +107,14 @@ The same loader runs at game start, so a broken file is caught before a window o | `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: { "": , ... }, buildings: { "": , ... })` plus the optional art-pass tables below, `units` keyed by `units.ron` `id` and `buildings` (required since the art pass) by `buildings.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)` (a glTF model under `assets/`, merged into one mesh, drawn from art-pass slice 3 for buildings, sites, rubble, flags and props and from slice 4 for units: turned by `yaw_deg`, scaled, then moved by the offsets in world axes) with the optional `x_offset_tiles_x100` and `z_offset_tiles_x100` (art slice 3, default 0; with `y_offset_tiles_x100`, each within ±500, they centre an off-centre file and lift a sunk one), `nodes: ["glob*"]` (at most 16 non-empty node-name globs; empty keeps every node), `parts: [(path, nodes, offset_x100: (x, y, z), scale_x100: 100, yaw_deg: 0)]` (at most 8 more files in the same mesh), `team: Some("")` (repaint those texel rects in the owner's colour; `None` is neutral) `fallback: Some()` (what is drawn when the model is missing; for a unit a `Primitive` or a `Body`, never a `Scene`, for a building or a site `Primitive(Cuboid)`), and on a building or a unit `banner_at: Some()` (where the team banner stands; needs `banner`; on a unit, art slice 4, from its position in its own axes, +X ahead, so the banner moves and turns with it) and, on a Town kind only, `seat_flag_at: Some()` (where the Seat flag stands on a Seat; needs `seat_flag`; without it the flag stands on the ground at the footprint's north-east corner), each `Mount` being `(offset_x100: (x, y, z), scale_x100: 100, yaw_deg: 0)` from the footprint centre on the ground (`x` and `z` within ±300, `y` 0..=800, the scale 1..=10000, the yaw ±360). A building is `Primitive(Cuboid)` (the M3b team-coloured box over the footprint, `buildings.rs`) or such a `Scene`. Optional tables: `team_swatches: { "": (rects: [(x, y, w, h)], desaturate_permille: 0..=1000) }` (texel rects of a model's base texture, sides 1..=4096, inside a 4096-texel texture); `sites: []` (1..=4 construction stages, `Primitive(Cuboid)` or a neutral Scene; default `[Primitive(Cuboid)]`, the M3b rising box); `rubble: Some((visual: , ds: 1..=1200))`; `seat_flag: Some()`; `banner: Some()` (art slice 3, owner decision 12: the team banner model, a Scene with a `team` and no fallback, drawn at every complete building's and every unit's `banner_at` as one shared mesh); `terrain: { "forest" \| "mountain" \| "water": (interior_permille, edge_permille (each 0..=1000), jitter_x100 (0..=45), scale_jitter_pct (0..=50), looks: [(visual: , at: Interior \| Edge \| Any, weight: 1..=100, shadow: true)]) }` (1..=8 neutral looks; `grass` is rejected: props on buildable land would hide buildings); `neutral_desaturate_permille: 0..=1000` (default 0; review of art slices 3-4: every neutral model, props, sites and rubble, is desaturated this far toward grey, as a swatch's `desaturate_permille` does outside its rects). Props, the rubble, the banner and the flag are skipped when their model fails, so they take no fallback; sites, rubble and props take no team. Validation (`Visuals::validate`): a kind without a visual or a key naming no kind is an error naming `units[""]` or `buildings[""]`; 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 (and every part's) must be relative, end in `.glb` or `.gltf` and contain no `..` and no leading `/` or `\`, `scale_x100` must be in `1..=10000`, `yaw_deg` in `-360..=360`, a `team` must name a `team_swatches` key, errors naming the field (`units[""].path`, `buildings["town"].fallback`, `units[""].parts[0].path`, `terrain["forest"].looks[0].weight`, `team_swatches[""].rects[0]`, `buildings["town"].banner_at.offset_x100`, `buildings[""].x_offset_tiles_x100`); a `banner_at` outside a building or a unit, or without a `banner`, a `seat_flag_at` outside a Town kind or without a `seat_flag`, and a `banner` without a `team` are errors; two unit kinds whose looks are the same shape must differ by 1.4x in width, height or depth (`docs/ART_STYLE.md`, one silhouette per kind), measured in integer maths on the nominal sizes of the `Primitive` and `Body` looks (a Scene by its fallback), the error naming the later kind; 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. Art pass, slice 1: every building kind `Primitive(Cuboid)`, empty `team_swatches` and `terrain`, so the look is unchanged; `sim-cli data-check` checks every Scene file exists under the assets directory. Slice 3: every building a KayKit Hexagon Scene with its banner (the Town also the Seat flag), the three construction stages, rubble, the flag as `banner` and `seat_flag`, and forest, mountain and water props (`docs/ART_STYLE.md`, "Building looks"). Slice 4: every unit kind a Scene with its M4b look as the fallback (six baked KayKit Adventurers characters, the Mangonel as the catapult nodes with a banner, the Wain as a loaded wheelbarrow) and the four `adv_*` swatches (`docs/ART_STYLE.md`, "Unit looks (art slice 4)"); the 2026-10-28 review moved every banner inside its footprint, baked the Bowman from the hooded Rogue and set `neutral_desaturate_permille: 500` | M2 (primitives), M4b (`Body`, `Cone`), art pass (`buildings`, Scene fields, sites, rubble, flag, terrain, swatches; glTF drawn from its slice 3) | | `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 | +| `data/ai/difficulty/*.ron` | One difficulty level per file (`easy.ron`, `standard.ron`, `hard.ron`; M6 owner decision 3, so ROADMAP M6 line 4's `hard.ron` edit is literal): `id` (the file stem; `passive` is reserved and `standard` must exist), `name`, the disclosed bonus (`income_interval_ds`, `income_from_ds`, `income: {resource: amount}`, the same bundle at every level) that the sim pays a bot seat whose `AiSeat.bot` names the level (step 6d', `SIM_VERSION` 18), and the wave knobs `rules::ai::resolve` applies to the personality (`wave_age_gate: Personality \| Age(id) \| Off`, `largest_wave_only`, `max_wave_units`, `wave_size_permille`, `wave_interval_permille`, `all_in_after_ds`), `first_raid` (the deadline raid), `worker_target_permille` (scaling the build order's `Workers` counts: the lever of M6 owner decision 2's disclosed Easy handicap) and (M6 owner decision 12) `extra_research` (techs appended to the build order as optional steps) and `pressure`, how the level presses its sieges (reinforcement, commit, retreat scaling, home guard, lure, cohesion and the launch and assault strength ratios; `data/ai/README.md`). Schema `crates/rules/src/ai.rs`, attached as `Rules::ai.difficulties`, in `rules_hash` | 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, and the `random` flag (required in every row since M6's slice 2: `standard` is `false`, rush, boom and tower are `true`) puts a row in the pool `random` draws from. Schema `crates/rules/src/ai.rs` | M5b (one row), M6 | Every subdirectory carries a README with its schema in plain English and one worked example. `data/` is licensed like code; see [adr/0001-license.md](adr/0001-license.md). ## Changing the bot's data (M5b) -`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). +`data/ai/` is in `rules_hash` like every other loaded file but `visuals.ron`, so an edit to a build order, a personality or (M6) a difficulty level 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, at the `standard` difficulty), so an edit to `standard.ron`, the `standard` row or `difficulty/standard.ron` 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 820863a..5663bd7 100644 --- a/docs/DETERMINISM.md +++ b/docs/DETERMINISM.md @@ -8,10 +8,10 @@ This document is the contract that makes Eonmark's simulation reproducible: the 2. No `f32`, `f64`, `std::collections::HashMap`, `HashSet`, `std::time::Instant`, `SystemTime`, `rand::random`, `rand::thread_rng` or `rand::rng` in `crates/sim`, `crates/rules` or `crates/ai`. Each crate has a `clippy.toml` with the ban list; CI runs clippy with `-D warnings`. 3. `cargo tree -p sim -e normal | grep -E 'bevy|glam|wgpu|winit'` must print nothing. The same holds for `rules`, `ai` and `sim-cli`. `pathfinding` is a dev-dependency only. 4. Ids are monotonic and never reused. Every sort key is a total order with a `UnitId` or `BuildingId` tiebreak. -5. All randomness comes from the single `Pcg32` stored in `Sim`. It is part of the hashed state. The seed comes from the replay header. +5. All randomness drawn by the sim comes from the single `Pcg32` stored in `Sim`. It is part of the hashed state. The seed comes from the replay header. The bot's noise (M6) is not a generator: `SimView::ai_noise(player, salt)` is a pure hash of the hashed seed, the player and a salt (`xxh3_64("eonmark-ai-noise" ‖ seed ‖ player ‖ salt)`, no tick), so it never draws from the `Pcg32` and a bot's noise cannot shift combat's tie draws. 6. Commands are sorted by `(player_id, client_seq)` before application. A command issued during tick N applies at tick N + `cmd_delay` in every mode: live, replay and any future network mode. 7. No trigonometry. Units store a facing vector; heading is derived render-side in `f32`. Squared distances use `dist_sq_i64`. Overflow in the sim crate panics in release (`overflow-checks = true`) rather than wrapping silently. -8. Derived caches (influence maps, spatial grid, path cache, component ids) are never hashed. The path request queue and the active (suspended) A* search are hashed state, serialised in full. The path cache key includes `cost_grid_generation`, the whole cache is cleared on any cost-grid mutation and on `restore()`, and a hit is honoured only when it can charge the budget exactly as the real search would have (the transparent-cache rule in [design/pathing.md](design/pathing.md)). `restore()` rebuilds every derived structure before returning. A property test runs the sim with the path cache disabled and enabled and asserts identical hashes on every tick. +8. Derived caches (spatial grid, path cache, component ids, and the bot's per-plan influence maps) are never hashed. The path request queue and the active (suspended) A* search are hashed state, serialised in full. The path cache key includes `cost_grid_generation`, the whole cache is cleared on any cost-grid mutation and on `restore()`, and a hit is honoured only when it can charge the budget exactly as the real search would have (the transparent-cache rule in [design/pathing.md](design/pathing.md)). `restore()` rebuilds every derived structure before returning. A property test runs the sim with the path cache disabled and enabled and asserts identical hashes on every tick. 9. Golden fixtures regenerate only in a commit that bumps `rules_version` with a one-line reason. `verify` fails fast on `sim_version` mismatch and reports `RULES CHANGED since recording` on `rules_hash` mismatch. 10. Tests from M1: same seed and commands twice (sequentially and on two threads) give identical hashes; snapshot at tick 300 then continue to 1200 equals the uninterrupted run, including spawning 50 units after the restore; restore, clear caches, step one tick matches; proptest random command streams; cross-OS final hash equality of fixtures in CI (ubuntu-24.04 against macos-26). @@ -61,9 +61,9 @@ Every ordering decision in the sim is a total order that ends in an id. Examples ## Derived caches -Four structures are recomputed from hashed state and are never hashed themselves: influence maps (4x4-tile cells, every 10 ticks), the uniform spatial grid (2x2-tile cells, rebuilt each tick), connected-component ids (recomputed per `cost_grid_generation`) and the bounded path cache. +Three structures in the sim are recomputed from hashed state and are never hashed themselves: the uniform spatial grid (2x2-tile cells, rebuilt each tick), connected-component ids (recomputed per `cost_grid_generation`) and the bounded path cache. The influence maps (4x4-tile cells) are a fourth derived structure, but they live in the bot, not the sim. -The influence maps do not exist yet (M6), and where they live is open. Refreshed every 10 ticks as planned, they would not be restore-exact: a restore between two refreshes rebuilds them from the current state while the uninterrupted run still reads the last refresh, and a bot reading them would decide differently. M5b's recommendation (docs/design/ai.md, "M5b implementation notes" and "Open questions") is to compute them in `crates/ai` from the view on the bot's own plan ticks, a pure function of hashed state like the rest of the bot's derived facts; the alternative is to make them hashed state. M6 decides and records it here. +M6 decided where the influence maps live. Refreshed every 10 ticks as a sim cache, as first planned, they would not be restore-exact: a restore between two refreshes rebuilds them from the current state while the uninterrupted run still reads the last refresh, and a bot reading them would decide differently. So, as M5b recommended, `crates/ai` builds them (`standard/influence.rs`) from the `SimView` on the bot's own plan ticks, at most once per plan and only when a rule asks (`Ctx::influence`), and drops them with the plan: a pure function of hashed state like the rest of the bot's plan facts. The sim has no influence code, nothing about them is hashed or restored, and a bot rebuilt mid-match reads the same grids (`m6_restore_during_a_raid_idle_and_after_a_retreat_matches_uninterrupted`). ### Path cache generation rule @@ -71,7 +71,7 @@ The cost grid carries a `u32` `cost_grid_generation` that increments on every ch ### Snapshot and restore rule -`snapshot()` serializes every hashed field verbatim with postcard. `restore()` deserializes them and then eagerly rebuilds influence maps, the spatial grid and connected components, and clears the path cache, before returning. The supply grid of M5a's attrition is not a stored structure at all: step 5b' builds it from the Supply Wains' positions inside each step, so there is nothing to rebuild; neither are M5a's annexation attacker and defender sets or its flips, which step 5d computes and step 6e reads within one step. Since M5a the sim also keeps the rules-only combat tables (the counter table, preferences, shooters and largest unit radius, `combat::CombatTables`) on `Sim`, built in `Sim::new` and rebuilt in `rebuild_derived`; they depend on the rules alone, so they can never disagree with the state. No derived structure is lazily rebuilt, so the first `step` after restore sees the same inputs as the uninterrupted run. The command delay queue (`pending`, a `BTreeMap` keyed by application tick) is hashed state, so a snapshot carries in-flight commands and a restore applies them on the right tick. +`snapshot()` serializes every hashed field verbatim with postcard. `restore()` deserializes them and then eagerly rebuilds the spatial grid and connected components, and clears the path cache, before returning (the influence maps are the bot's, built per plan, so there is nothing to rebuild). Since M6 it also rebuilds the bot seats' bonus schedule (`Sim.bonus`, step 6d'), a pure function of the header and the rules like the combat tables, so a restore mid-interval pays on the same ticks as the uninterrupted run. The supply grid of M5a's attrition is not a stored structure at all: step 5b' builds it from the Supply Wains' positions inside each step, so there is nothing to rebuild; neither are M5a's annexation attacker and defender sets or its flips, which step 5d computes and step 6e reads within one step. Since M5a the sim also keeps the rules-only combat tables (the counter table, preferences, shooters and largest unit radius, `combat::CombatTables`) on `Sim`, built in `Sim::new` and rebuilt in `rebuild_derived`; they depend on the rules alone, so they can never disagree with the state. No derived structure is lazily rebuilt, so the first `step` after restore sees the same inputs as the uninterrupted run. The command delay queue (`pending`, a `BTreeMap` keyed by application tick) is hashed state, so a snapshot carries in-flight commands and a restore applies them on the right tick. ### The active A* search is hashed, the cache is not @@ -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 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. | +| `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), from M6 with `difficulty/*.ron`, whose bonus the sim does read (step 6d'). `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. | @@ -139,7 +139,7 @@ The reader tolerates truncation. It decodes records with `postcard::take_from_by | `players` | `Vec` | Loaded | Slot id and `is_ai` per player, in id order. `MatchSetup::skirmish` and, from M5b, `MatchSetup::vs_bot` make slot 0 human and slot 1 the AI; `MatchSetup::bots` makes both slots AI; `MatchSetup::scenario` makes two human slots. | | `debug_commands` | `bool` | Loaded | Whether the debug commands are accepted: `DebugSpawn`, from M3a `DebugPlaceBuilding` and `DebugGrant`, and from M4a `DebugResearch` and `DebugSpawnJittered` (which draws two numbers from the RNG per unit it spawns, so a fixture's jitter follows from the seed). `false` for `skirmish` and, from M5b, for `vs_bot` and `bots`; `true` for `scenario` (fixtures, benches, tests). From M5a also `DebugDamage`, which deals damage to any existing unit or building and draws nothing. From M5b also `DebugAdvanceAge`, which moves its owner one age on without the age's cost or techs (`UnknownPlayer` for a missing owner, `FinalAge` in the last age) and draws nothing. A debug command in a match with `false` is rejected with `CommandRejected { reason: DebugCommandsDisabled }` and changes nothing; like every applied or rejected command it still increments `commands_applied` (hashed in `meta`), so the hash reflects the full command stream. Since the M4a combat core slice no rejection draws from the RNG: `Attack` was the last command without a handler, rejected with `NotImplemented` and one draw so the hash followed the command stream (`Research` and `AdvanceAge` lost theirs in the tech slice). | | `start` | `StartKind` | Loaded | What each player has at tick 0 (M3a, the last field). `Standard` applies `standard_start` from `rules.ron` in `Sim::new`: per player in id order the Seat, the start units and the stockpile. `Empty` starts with nothing. `skirmish` uses `Standard`, `scenario` uses `Empty`; a scenario may set `Standard`, as `economy_second_town` and `economy_walkthrough` do. | -| `ai_seats` | `Vec` | Loaded; the bots are rebuilt from it | M5b, the last field (`SIM_VERSION` 17). One `AiSeat { player, bot, personality }` per AI slot, exactly the `is_ai` slots, once each, in id order (`Sim::new` asserts it; `ai::controller_for` reports it as an error first). `bot` is `passive` or `standard` (M6 adds `easy` and `hard`; it is the difficulty id of docs/design/ai.md), `personality` a `data/ai/personalities.ron` id, empty for `passive`. `skirmish` names `passive` for slot 1, `vs_bot` and `bots` the given bots, `scenario` none. `verify`, `hash-dump`, `record`, the game and its `--headless-run` build the controller from it with `ai::controller_for`, so a bot match re-simulates with the bots it was recorded with; an unknown bot or personality is `ReplayError::Controller` (exit 2), reported after the version and rules checks. | +| `ai_seats` | `Vec` | Loaded; the bots are rebuilt from it | M5b, the last field (`SIM_VERSION` 17). One `AiSeat { player, bot, personality }` per AI slot, exactly the `is_ai` slots, once each, in id order (`Sim::new` asserts it; `ai::controller_for` reports it as an error first). `bot` is `passive` or, since M6, a difficulty id (`easy`, `standard`, `hard`: a file stem under `data/ai/difficulty/`, the level's Standard bot and its disclosed bonus, step 6d'; M5b's `standard` is the `standard` level), `personality` a `data/ai/personalities.ron` id, empty for `passive` (`random` is resolved before the header is written, so a header never holds it). `skirmish` names `passive` for slot 1, `vs_bot` and `bots` the given bots, `scenario` none. `verify`, `hash-dump`, `record`, the game and its `--headless-run` build the controller from it with `ai::controller_for`, so a bot match re-simulates with the bots it was recorded with; an unknown bot or personality is `ReplayError::Controller` (exit 2), reported after the version and rules checks. | M5b appended `ai_seats`, which carries the difficulty id ai.md plans; faction ids (M9) are added to the header when that system arrives. New fields go at the end, as `start` did in M3a and `ai_seats` in M5b, together with a `SIM_VERSION` bump, so the reader's `sim_version` peek reports an older file as a version mismatch rather than a decode error. The same struct is the future lockstep handshake payload. @@ -266,7 +266,7 @@ Fixtures live under `crates/sim/tests/fixtures/` as `.eonreplay` files with a si | `enemy_outpost_walkthrough` | 5039 (standard start, seed 13: a 200 vs 200 jittered prologue that P1, with Arms I to III, wins by tick 711; then P0's real build-up: a Scriptorium and a Muster Hall, Letters I, Trade I at the Letters price, the Masonry Age, an Engine Yard, five line units and two Mangonels; the Mangonels, which outrange a Watchtower, land four hits on it (480 of its 500 HP) and the attack-moving line units, inside its range, the rest, so it is destroyed during tick 4839; the M4b owner proxy) | `cargo test -p sim -- --ignored enemy_outpost` and the game's `--headless-run` in the headless CI job, `sim-cli verify --release` in CI on both operating systems | | `capture_seat` | 1628, the tick the match is decided (standard start, seed 31: P1 researches Harrying I; P0 removes P1's Yeomen and takes its Seat to 300 HP with `DebugDamage`, four Mangonels bring it to 0 by tick 146, eight Skirmishers covered by a Supply Wain walk onto its ring, a Bowman outside the Wain's cover bleeds; the timer starts in step 427 and the Seat flips in step 1627: `Victory { P0, decisive: true }`, `SeatCaptured`; the M5a capture golden and the game's `--scenario capture_seat`) | `cargo test -p sim`, `sim-cli verify --release` in CI on both operating systems; the M5a hash-parity fixture | | `stalemate_45min` | 54000 (empty start, seed 45, no unit: P0's Town and Watchtower own 1248 tiles, P1's Town 1245; the match cap decides `Victory { P0, decisive: false }`, `Territory`; a hash record every 1000 ticks) | `sim-cli verify --release` in CI on both operating systems; `cargo test -p sim -- --ignored golden_stalemate_45min` locally (the acceptance test runs the same stream unignored) | -| `m5_first_match` | 26012, the tick the match is decided (M5b: the Standard bot in both seats, `MatchSetup::bots`, standard start, seed 1, no input; P0's fourth wave takes P1's Seat: `Victory { P0, decisive: true }`, `SeatCaptured`; a hash record every 100 ticks; a `sim-cli` scenario, `Scenario::FirstMatch`, outside `sim::scenarios::NAMES`, whose header names the bots that every re-simulation rebuilds) | `sim-cli verify --release` in CI on both operating systems and the game's `--headless-run` in the headless CI job; the M5b hash-parity fixture; `cargo test -p ai -- --ignored golden_m5_first_match` locally | +| `m5_first_match` | 38818, the tick the match is decided (M5b: the Standard bot in both seats, `MatchSetup::bots`, standard start, seed 1, no input; since M6 both seats are at the `standard` difficulty and receive its disclosed bonus from tick 4100, and play the slice-2 behaviours and build order; 26012 at the M5b close, 22832 at M6 slice 1, 34098 (P1's capture) at `rules_version` 27, 41889 at 28, 52108 at 30; P0's wave takes P1's Seat: `Victory { P0, decisive: true }`, `SeatCaptured`; a hash record every 100 ticks; a `sim-cli` scenario, `Scenario::FirstMatch`, outside `sim::scenarios::NAMES`, whose header names the bots that every re-simulation rebuilds) | `sim-cli verify --release` in CI on both operating systems and the game's `--headless-run` in the headless CI job; the M5b hash-parity fixture; `cargo test -p ai -- --ignored golden_m5_first_match` locally | Regeneration policy: @@ -404,3 +404,19 @@ At the M5b close the sim is at `SIM_VERSION` 17 and the rules at `rules_version` ## 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`. + +## What M6 adds + +The difficulty slice (`SIM_VERSION` 17 -> 18, `rules_version` 25 -> 26; M6 plan slice 1, the owner decisions of 2026-10-28) makes the difficulty levels data and pays their disclosed bonus inside the sim. `data/ai/difficulty/{easy,standard,hard}.ron` are attached as `Rules::ai.difficulties` and are in `rules_hash`; an `AiSeat.bot` names a level (or `passive`). + +- **Step 6d' (`economy::bonus_step`).** It runs right after the income step (6d) and before the outcome (6e). `Sim::new` derives `Sim.bonus`, one `BonusSeat { player, from_ticks, every_ticks, bundle }` per AI seat whose level pays (a non-zero interval and a non-empty bundle), in id order, from the header and the rules (`economy::bonus_table`); it is unhashed and rebuilt in `rebuild_derived`, like the combat tables. On the step that completes tick `n` with `n > from` and `(n - from) % every == 0`, each seat that has not surrendered gets the bundle added to its stockpile, then the stockpile caps clamp it (Lore), and the sim emits the appended `SimEvent::BonusIncome { player, amounts }` with what was credited (events are unhashed). It is integer arithmetic on the hashed stockpiles (the `economy` sub-hash) on a fixed tick schedule, so a replay re-derives it with nothing recorded and a restore is exact (`bonus_schedule_survives_restore_mid_interval`). Human slots and `passive` seats get nothing (`bonus_pays_named_seats_only`), and the step never reads another player's state. With the shipped files (`income_from_ds` 1800, the same bundle at every level) the first payments complete ticks 4000 (Hard, every 400 ticks), 4100 (Standard, every 500) and 4200 (Easy, every 600). +- **`SimView::ai_noise(player, salt)`** (and the free `sim::ai_noise(seed, player, salt)`): `xxh3_64` over `"eonmark-ai-noise"`, the seed (a little-endian `u64`), the player byte and the salt (a little-endian `u64`), in that order, with no tick, so a decision salted with a wave count or a site index is stable across plans without memory. It never touches the `Pcg32`: a thousand calls leave the `rng` sub-hash and the whole hash unchanged (`ai_noise_is_pure_and_leaves_the_rng_alone`). Rule 5 above is worded for it. `ai::resolve_personality` (the `random` personality) uses the same hash, and slice 2's bot jitter will. +- **`sim::stats::MatchStats`** is engine-free, unhashed and not in `State`: a pure function of the view after every step and that step's events (deaths and spawns by kind, annexations, ages, the bonus, cross-player hits), with rates, cap time (`RateInfo::at_cap`, the HUD's amber rule) and army size sampled once a second of match time. The play-bots harness, the game (`SimHandle::drain_events`, which `fast_forward` uses too) and `sim-cli match-log` (through `sim::verify_observed`, `verify_with` with a per-step observer) feed it and agree by test. The sim never reads it. + +Bot code moves with it: every difficulty is the Standard bot playing `rules::ai::resolve(personality, difficulty)`, computed in `StandardBot::new` from the rules and the header (so a rebuilt controller plays the same knobs); at slice 1 the `standard` level resolved to the personality itself, and since the retune below (`rules_version` 30) it resolves to the personality with its all-in at 36:00 (its retreat was 0 at 30 and the personality's again from 31; the rest of its `pressure` block is read by the bot). Slice 2's bot behaviours ship under the same `SIM_VERSION` 18 (one bump per merged PR, the M5b precedent), with a `rules_version` bump and `just rerecord` per data change. + +All eleven fixtures were re-recorded for the new `rules_version`, `rules_hash` and `sim_version` in their headers. Only `m5_first_match.hash` moved (`0x3d2552033d51b4a2` at tick 26012 to `0x7207c1e8dc3ce351` at tick 22832): its two Standard seats now receive the bonus. A per-tick `sim-cli hash-dump --every 1` of the M5b fixture by the pre-bump binary with its own `data/` against the re-recorded one by the new binary is identical through tick 4099 and first differs at tick 4100, in `economy` first (the first Standard payment; `units`, `buildings` and the rest agree on that line). The other ten fixtures seat no difficulty level, so no bonus is paid and their `.hash` files did not change. + +The behaviours slice (`rules_version` 26 -> 27, `SIM_VERSION` stays 18 in PR 1) changes bot decisions only in `crates/ai`: per-plan influence maps (built in the bot, "Derived caches" above), retreat, raids and the level's deadline raid, counters, `seat_last`, the defence additions, reactive towers, the seed-alternating plan phase (`((me + seed) mod players) x every / players`), mirror-aware site ties and bounded noise from `SimView::ai_noise` (salted by wave count, site index or kind, never by tick, so a decision is stable across plans without memory). `BotMemory` appends its new fields at the end (postcard, the default still encodes to no bytes), and the hidden state stays restore-exact (`m6_restore_during_a_raid_idle_and_after_a_retreat_matches_uninterrupted`). Its tuning commit (`rules_version` 27 -> 28) edits `data/ai/` only: Standard's build order and Easy's `worker_target_permille`. In both commits every fixture was re-recorded for its header and only `m5_first_match.hash` moved: `0x7207c1e8dc3ce351` (tick 22832, P0) to `0xa97c1bd612bca5f5` (34098, P1) at 27, then to `0xf78f9cf501cfe707` (41889, P0) at 28; the ten others seat no bot. + +The M6 retune (owner decision 12; `SIM_VERSION` stays 18, still unreleased in PR 1) adds the difficulty files' `pressure` block and every siege wave's memory of its target's last hit points (a new `Wave` field in the hashed `BotMemory`; the restore tests `m5b_restore_during_a_wave_matches_uninterrupted` and `m6_restore_during_a_raid_idle_and_after_a_retreat_matches_uninterrupted` still pass), all neutral, at `rules_version` 29; then at 30 `crates/ai` reads the shipped values (reinforcement groups, commit, no retreat, the lure, cohesion and the launch and assault gates in `waves.rs`) and `data/ai/` changes the difficulty files, `tower`'s build order and `personalities.ron` (whose `noise_border_top_k` is removed). In both commits every fixture was re-recorded for its header and only `m5_first_match.hash` moved: `0xf78f9cf501cfe707` (41889) to `0x71ed5e727f1430f2` at 29 (the bots' hashed memory bytes), then to `0xfe374e4de42eae0e` (52108, P0's Seat capture) at 30; the ten others seat no bot, and their `.hash` files are unchanged. The review of the retune (`rules_version` 31, data only) gives Standard its personality's retreat back (`pressure.retreat_ratio_permille` 0 to 1000); every fixture was re-recorded for its header and only `m5_first_match.hash` moved, to `0xa9d87378122649bf` (38818, P0's Seat capture). diff --git a/docs/IMPLEMENTER_NOTES.md b/docs/IMPLEMENTER_NOTES.md index 9b06624..35b5333 100644 --- a/docs/IMPLEMENTER_NOTES.md +++ b/docs/IMPLEMENTER_NOTES.md @@ -107,7 +107,7 @@ Each trap below was verified during design against the pinned versions. Re-check ## Definition of done for a PR -A PR is done when every command below passes locally and its output is pasted into the PR description, and GitHub Actions is green on every job (check, headless, deny, game-linux, hash-parity). `just ci` runs the same list and skips optional tools with an install hint; CI is the gate. +A PR is done when every command below passes locally and its output is pasted into the PR description, and GitHub Actions is green on every job (check, headless, deny, game-linux, hash-parity and, from M6, fun-gate). `just ci` runs the same list and skips optional tools with an install hint; CI is the gate. ```bash cargo fmt --all -- --check diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a1f9fc7..9fa94f5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -414,12 +414,18 @@ The skirmish is worth playing again: three difficulties and three personalities - sim-cli `play-bots` CSV and a CI job running 5 seeded bot games on ubuntu. - `docs/PLAYTEST.md` match log template and three logged human-vs-Standard matches; 2-3 `rules.ron` tuning iterations budgeted. +### Notes + +- Owner decisions, 2026-10-28: the owner took the default of all eleven questions of the M6 implementation plan. (1) "A winner" in line 1 is `winner` `a` or `b`, so territory-tiebreak wins count, and an exact-tie Draw or a panic fails; decisive is `reason == seat_captured` with `tick < 48000`; "past 24000" is `tick > 24000`; the median clause holds by construction under the 54000 cap and is still printed. (2) The bot's income is a disclosed fixed bonus bundle the sim pays each bot seat every 30, 25 or 20 s (Easy, Standard, Hard) from 3:00, the same bundle at every level, the smallest that passes lines 2 and 4, never rubber-banded, stated on New Game and on the game-over screen; its ceiling is a bonus share of at most 200 per mille of income for Standard and 300 for Hard. If line 2 cannot pass under the ceiling, Easy alone gets a disclosed handicap (`worker_target_permille` 850, 15% fewer Yeomen) and the flip is re-checked; Hard gets nothing beyond its shorter interval. Standard, the fun-gate opponent, receives its small disclosed bonus too. (3) One difficulty file per level, `data/ai/difficulty/{easy,standard,hard}.ron`, so line 4's `hard.ron` edit is literal. (4) `play-bots` walks the distinct ordered personality pairs when both lists have several entries; `--mirrors` adds the mirrors; single lists cycle as before. (5) Bot variety comes from a tick-free `SimView::ai_noise` (a pure hash that never touches the `Pcg32`) with bounded jitter, plus a plan phase that alternates with the seed's parity; DETERMINISM rule 5 and `docs/design/ai.md` are amended. (6) New Game defaults: Difficulty Standard, Style Random (rush, boom or tower; Balanced is an explicit choice), shown as "?" until the game-over screen, a fresh wall-clock seed, speed x1 that persists across Play Again. (7) Speeds x1, x2 and x4, Space, `-` and `=`, in release builds; the pause is active (orders allowed, camera and selection work); the Esc pause menu stays M7. (8) The game-over screen shows this milestone's list plus units trained, peak army, the bot's bonus total and share, its waves, raids and retreats, "enemy first on your land", "your lowest Town", first contact, the final hash and the replay name; the sparkline is a `help wanted` issue. (9) Evidence: `--screenshot-at` stands in for `ci_testing`; wall times are taken on the VM with Mac re-runs in the owner's session; x4 throughput is shown headlessly and on the Mac. (10) The three logged matches are streamed live as the first M6 stream after slice 3 merges: blind seeds, one per style, at x1, in one sitting, with the STREAMING.md pre-flight, one match-log row per match and each replay verified afterwards; an optional unlogged smoke match after PR 1 may feed the tuning. (11) The owner alone judges the fun gate, at 3/5, reading amber per match; every failed criterion gets a `rules.ron` commit (`data/ai` may ride along), or a `needs-design` issue if none fits. +- Owner decisions, 2026-10-29 (after PR 1's first round, in which line 2 and line 4's flip failed): (12) Close lines 2 and 4 by breaking the stalemates: difficulty gets real behaviour, as the deliverable wording says (Easy defensive, with smaller and later waves; Standard and Hard keep up siege pressure, reinforcing and re-launching against a defended Town, until a Town falls), and mirrored sites must no longer make cap games exact copies; the bonus stays the fixed disclosed bundle under the ceiling of (2); about one tuning session. Declined: a bigger bonus above the ceiling, and an ADR re-reading line 2. (13) PR 1 is opened now, the retune runs on the same branch, and PR 1 merges only when every fun-gate CI step is green (main stays green); slice 3 starts after. (14) The bonus ceiling of (2) is read averaged over a run, pooled across the matrix (Standard <= 200 and Hard <= 300 per mille of pooled income): a fixed, disclosed bundle that never reacts to the game; a single losing seat may exceed it; no per-match cap and no `SIM_VERSION` bump for it. +- Decision numbers: "M6 owner decision N" in code and docs is answer N above; a bare "owner decision N" in M5b-era text is M5b's ("Notes" under M5b). + ### Acceptance -- [ ] `cargo run -p sim-cli --release -- play-bots --seeds 1..20 --a standard --b standard --personality-a rush,boom,tower --personality-b rush,boom,tower --max-ticks 72000 --jobs 5` exits 0 with a winner in all 20 games; >= 10/20 are decisive (capital capture) before tick 48000; median game length <= tick 54000; >= 10/20 last past tick 24000. -- [ ] `play-bots --a easy --b standard --seeds 1..20` gives Standard >= 15/20 wins; `--a standard --b hard` gives Hard >= 15/20 wins. -- [ ] Two `play-bots --seeds 7..7` runs print identical final hashes; CI asserts this on ubuntu and macos. -- [ ] Selecting Hard yields an AI attack on the player's borders before game minute 12 in 3 of 3 seeded games (asserted headlessly via `first_attack_tick < 14400` with a passive opponent); changing `hard.ron` `income_interval_s` from 20 to 30 measurably flips the hard-vs-easy result (documented in `docs/design/ai.md`). Note: the design text says `income_interval_s`; the authored field follows the `_ds` convention as `income_interval_ds` (200 to 300), see `docs/design/ai.md`. +- [x] `cargo run -p sim-cli --release -- play-bots --seeds 1..20 --a standard --b standard --personality-a rush,boom,tower --personality-b rush,boom,tower --max-ticks 72000 --jobs 5` exits 0 with a winner in all 20 games; >= 10/20 are decisive (capital capture) before tick 48000; median game length <= tick 54000; >= 10/20 last past tick 24000. (Observed on PR #56, 2026-10-30, `SIM_VERSION` 18, `rules_version` 31, on the 4-vCPU Intel Xeon 2.1 GHz Linux VM: the literal command exits 0, `play-bots: games=20 jobs=5 wall_s=12.33 a=9 b=11 draw=0 none=0 panic=0 decisive=19 tiebreak=1`; a second run (`wall_s=11.94`) gives a CSV whose `diff` is empty; `scripts/fun_gate.sh` (M6 owner decision 1's readings) prints `fun_gate: games=20 winners=20 draws=0 panics=0 decisive_lt_48000=19 median_tick=26481 past_24000=12 tiebreaks=1` and `fun_gate: ok`, every pairing's games distinct (winner, tick), `first_attack_tick_iqr: 7155`, `ai_ms_mean_max: 0.0122`. Seeds 21..40 and 41..60 with the same command: 20/20 and 20/20 winners, 19 and 18 decisive before 48000, median 24602.5 and 25595, 10 and 12 past 24000, no draw. CI: the `fun-gate (ubuntu-24.04)` job's step "M6 acceptance 1" printed `play-bots: games=20 jobs=5 wall_s=15.97 a=9 b=11 draw=0 none=0 panic=0 decisive=19 tiebreak=1`, `fun_gate: games=20 winners=20 draws=0 panics=0 decisive_lt_48000=19 median_tick=26481 past_24000=12 tiebreaks=1`, `fun_gate: ok (need 10 decisive before 48000 and 10 past 24000 of 20)` (Actions run 41008686740 at head de5b0cd).) +- [x] `play-bots --a easy --b standard --seeds 1..20` gives Standard >= 15/20 wins; `--a standard --b hard` gives Hard >= 15/20 wins. (Observed on PR #56, 2026-10-30, `SIM_VERSION` 18, `rules_version` 31, after the M6 retune (owner decisions 12 and 14) and its review, on the 4-vCPU Intel Xeon 2.1 GHz Linux VM. The literal commands (`play-bots ...`, default `--jobs 1`): Easy against Standard exits 0, `play-bots: games=20 jobs=1 ... a=1 b=19 draw=0 none=0 panic=0 decisive=17 tiebreak=3`; `scripts/check_ordering.sh e_s.csv b 15` prints `check_ordering: b won 19/20 (need 15; a=1 b=19 draw=0 none=0 panic=0)` and `cap games 3 (draws 0), untouched (no Town taken) 1, of them within 3 tiles 0`. Standard against Hard exits 0, `play-bots: games=20 jobs=1 ... a=4 b=16 draw=0 none=0 panic=0 decisive=20 tiebreak=0`; `check_ordering: b won 16/20 (need 15; a=4 b=16 draw=0 none=0 panic=0)`, `cap games 0`. CI's form (`--jobs 4 --allow-undecided`) gives the same rows (`wall_s=57.95` and `17.74`). Seeds 21..40: Standard 20/20, Hard 14/20; 41..60: Standard 19/20, Hard 16/20 (one Draw). Standard's wins over seeds 1..60 come 10 before Easy's 33:00 all-in and 46 after tick 50000 (docs/design/ai.md, "Deviations pending the owner"). Pooled bonus shares (M6 owner decision 14) over these runs and the flip's base runs: Standard 174, Hard 207, Easy 161 per mille. CI: the `fun-gate (ubuntu-24.04)` job's step "M6 acceptance 2" printed `play-bots: games=20 jobs=4 wall_s=75.98 a=1 b=19 draw=0 none=0 panic=0 decisive=17 tiebreak=3`, `check_ordering: b won 19/20 (need 15; a=1 b=19 draw=0 none=0 panic=0)`; `play-bots: games=20 jobs=4 wall_s=24.68 a=4 b=16 draw=0 none=0 panic=0 decisive=20 tiebreak=0`, `check_ordering: b won 16/20 (need 15; a=4 b=16 draw=0 none=0 panic=0)` (Actions run 41008686740 at head de5b0cd).) +- [x] Two `play-bots --seeds 7..7` runs print identical final hashes; CI asserts this on ubuntu and macos. (Observed on PR #56, 2026-10-30, `SIM_VERSION` 18, `rules_version` 31. Locally, on the 4-vCPU Intel Xeon 2.1 GHz Linux VM: `target/release/sim-cli play-bots --seeds 7..7 > s7a.csv` and again `> s7b.csv` exit 0 both times (`wall_s=2.62` and `2.61`, `play-bots: games=1 jobs=1 ... a=0 b=1 draw=0 none=0 panic=0 decisive=1 tiebreak=0`); `diff s7a.csv s7b.csv` is empty; the row reads `7,b,decisive,26823,0x293a97cb1b22256f`. The pair beside it (`--a hard --b easy --personality-a rush --personality-b tower --allow-undecided`, not part of the line) prints `0x6993967af0e04396` (a, decisive at tick 19936) both times, diff empty. CI: the macOS `check` job's step "M6 acceptance 3" printed `seed=7 standard/standard final_hash=0x293a97cb1b22256f` and `seed=7 hard-rush/easy-tower final_hash=0x6993967af0e04396`, the ubuntu `headless` job's same step printed the same two lines, both with an empty `diff s7a.csv s7b.csv`, and the `hash parity (macOS vs Linux)` step "compare M6 acceptance 3" printed both files and an empty `diff` (Actions run 41008686740 at head de5b0cd).) +- [x] Selecting Hard yields an AI attack on the player's borders before game minute 12 in 3 of 3 seeded games (asserted headlessly via `first_attack_tick < 14400` with a passive opponent); changing `hard.ron` `income_interval_s` from 20 to 30 measurably flips the hard-vs-easy result (documented in `docs/design/ai.md`). Note: the design text says `income_interval_s`; the authored field follows the `_ds` convention as `income_interval_ds` (200 to 300), see `docs/design/ai.md`. (Observed on PR #56, 2026-10-30, `SIM_VERSION` 18, `rules_version` 31, on the 4-vCPU Intel Xeon 2.1 GHz Linux VM. First clause: `play-bots --seeds 1..3 --a passive --b hard --max-ticks 14400 --allow-undecided` then `scripts/check_attack_before.sh --side b --before 14400` prints `check_attack_before: 3/3 rows pass` (first attack at 12003, 12008, 12003; an armed unit on the enemy's land by 12432); with `--personality-b rush,boom,tower` 3/3 (5843, 12008, 12733; on land by 12782); from seat A (`--a hard --b passive`, `--side a`) 3/3 (12008, 12003, 12008). Second clause: `scripts/check_difficulty_flip.sh` prints `hard interval 200: hard as a 16/20, hard as b 16/20`, `hard interval 300: hard as a 2/20 (easy 18, draw 0, untouched cap games 0), hard as b 0/20 (easy 20, draw 0, untouched cap games 0)` and `check_difficulty_flip: PASS` (180 s); docs/design/ai.md, "Difficulty table (M6)", records it and why it flips from the measured capture ticks (at 200, 25 of Hard's 32 wins take Easy before Hard's 30:00 all-in; at 300 that pressure fails, Hard's all-in is wiped and Easy wins at 47686 to 54000). Seeds 21..40 pass; seeds 41..60 miss the 200 clause from seat B (9/20), recorded there as a risk. The ignored `m6_` tests pass (5, among them `m6_hard_attacks_before_14400_vs_passive_every_personality_both_sides`). CI: the `fun-gate (ubuntu-24.04)` job's steps "M6 acceptance 4, first clause" and "second clause" printed `check_attack_before: 3/3 rows pass (side b, before 14400)` twice and `check_attack_before: 3/3 rows pass (side a, before 14400)`; `check_difficulty_flip: hard interval 200: hard as a 16/20, hard as b 16/20`, `check_difficulty_flip: hard interval 300: hard as a 2/20 (easy 18, draw 0, untouched cap games 0), hard as b 0/20 (easy 20, draw 0, untouched cap games 0)`, `check_difficulty_flip: PASS (Hard >= 15 at 200; Hard <= 9 and Easy >= 11 at 300; both seat orders)` (Actions run 41008686740 at head de5b0cd).) - [ ] Game speed x4 and pause work and the replay verifies with the same final hash as the x1 replay of the same scripted input (`--scenario scripted_match --speed 4`). - [ ] **[owner]** Three complete human-vs-Standard matches logged in `docs/PLAYTEST.md` with duration 20-40 min each; in >= 2 of 3 the AI's army reaches the player's borders and annexes or reduces a Town below 50% HP; the Yield Cap readout turns amber at least once; owner fun rating >= 3/5; each failed criterion triggers a `rules.ron` tuning commit and a replay of the three matches before M7 starts (max 3 iterations before escalating to an ADR on the ruleset). Automated proxy: the 20-game bot run above (winner in all, >= 10/20 decisive, median <= tick 54000) plus a `ci_testing` screenshot of the game-over stats screen. The proxy does not substitute for the owner's rating; this line is the fun gate and is never waived. diff --git a/docs/STREAMING.md b/docs/STREAMING.md index 7bc68c8..283cd7e 100644 --- a/docs/STREAMING.md +++ b/docs/STREAMING.md @@ -6,8 +6,8 @@ How the owner streams a match from the dev Mac: the commands, the settings that | Format | Command | Notes | |---|---|---| -| You against the Standard bot | `cargo run --release -p game -- --skip-menu --window-size 1280x720 --draw-stats` | `--skip-menu` starts the match at once. Without `--seed ` the seed comes from the wall clock; it is in the replay's file name (`-.eonreplay`), which the outcome overlay shows. A seed does not reproduce a match you played: your orders are in the replay, not the seed. | -| Bot against bot (spectate) | `cargo run --release -p game -- --spectate --seed 1 --window-size 1280x720 --draw-stats` | Seed 1 is the `m5_first_match` setup: P0 takes P1's Seat at tick 26012, 21:40 on the overlay's clock. You can select units and move the camera but give no orders. `--fast-forward-to 9000` skips the opening (7:30) before the window opens; it works only with `--spectate` and is refused if the match is decided by then. The replay is recorded from that tick and still verifies. | +| You against the Standard bot | `cargo run --release -p game -- --skip-menu --window-size 1280x720 --draw-stats` | `--skip-menu` starts the match at once. Without `--seed ` the seed comes from the wall clock; it is in the replay's file name (`-.eonreplay`), which the outcome overlay shows. A seed does not reproduce a match you played: your orders are in the replay, not the seed. Since M6 `--difficulty ` (`easy`, `standard`, the default, or `hard`) and `--personality ` (default `standard`) choose the bot; every level receives a disclosed bonus bundle from 3:00 (`data/ai/difficulty/`, docs/design/ai.md), so say which level you play. | +| Bot against bot (spectate) | `cargo run --release -p game -- --spectate --seed 1 --window-size 1280x720 --draw-stats` | Seed 1 is the `m5_first_match` setup: P0 takes P1's Seat at tick 38818, 32:20 on the overlay's clock (26012, 21:40, at the M5b close; 22832, 19:01, once M6 gave both Standard seats their level's bonus, before the slice-2 behaviours; 41889, 34:54, before the M6 retune; 52108, 43:25, at `rules_version` 30). You can select units and move the camera but give no orders. `--fast-forward-to 9000` skips the opening (7:30) before the window opens; it works only with `--spectate` and is refused if the match is decided by then. The replay is recorded from that tick and still verifies. | - **Why 1280x720:** it is the logical size the art pass measures fps at (AP5 in the ROADMAP's "Art pass (pre-stream)"). On a Retina display it is 2560x1440 physical pixels, which OBS scales down to 1080p. A 1920x1080 window renders 3840x2160 physical pixels, 2.25 times as many, for nothing once OBS scales it to 1080p. - **`--draw-stats`** costs next to nothing and makes the game print its `frame_stats:` lines on exit (frame times, the window, draw calls); without it a plain match prints only the minimap's line. diff --git a/docs/design/ai.md b/docs/design/ai.md index a712848..f080468 100644 --- a/docs/design/ai.md +++ b/docs/design/ai.md @@ -9,12 +9,12 @@ Area: `area:ai`. Milestones: M5b (first complete opponent, Standard only), M6 (d | Quantity | Value | Where it is set | | --- | --- | --- | | AI cost | < 0.5 ms mean per tick added to the step, 400 units, bots on | `sim-cli bench --units 400 --ticks 1200 --bots` (M5b) | -| Easy | income interval 30 s, defensive | `data/ai/difficulty.ron` | -| Standard | income interval 25 s | `data/ai/difficulty.ron` | -| Hard | income interval 20 s, aggressive, first attack before minute 12 (tick 14400) | `data/ai/difficulty.ron` | +| Easy | income interval 30 s, defensive | `data/ai/difficulty/easy.ron` (M6) | +| Standard | income interval 25 s | `data/ai/difficulty/standard.ron` (M6) | +| Hard | income interval 20 s, aggressive, first attack before minute 12 (tick 14400) | `data/ai/difficulty/hard.ron` (M6) | | Standard first wave | >= 8 units including 1 Supply Wain before tick 18000 (15 min), after reaching Masonry Age, in 5/5 seeds vs Passive | M5b acceptance | | Difficulty ordering | Standard beats Easy >= 15/20 seeds; Hard beats Standard >= 15/20 seeds | M6 acceptance | -| Influence map cells | 4 x 4 tiles (32 x 32 cells on the 128 map), refreshed every 10 ticks, unhashed | `crates/sim/src/influence.rs` | +| Influence map cells | 4 x 4 tiles (32 x 32 cells on the 128 map), built by the bot at most once per plan when a rule reads them, never stored, hashed or restored | `crates/ai/src/standard/influence.rs` (M6); the cell size is `influence_cell_tiles` in `data/ai/personalities.ron` | | Match cap | 45 min = tick 54000, then territory-percentage tiebreak | `data/rules/rules.ron` | | Personalities | rush, boom, tower | `data/ai/build_orders/*.ron`, `data/ai/personalities.ron` | @@ -38,11 +38,11 @@ Because the bot runs inside the step, its commands are not written to the replay `ai::Passive` is the controller that returns an empty `Vec`. Tests, benches and the M0 skeleton use it. -Determinism constraints on the crate (enforced by `crates/ai/clippy.toml` and CI): no `f32`/`f64`, no `HashMap`/`HashSet`, no `Instant`/`SystemTime`, no `rand::random`/`thread_rng`, no engine crates. Ties are broken by the lowest id or lowest tile index. The design permits randomness only from the single `Pcg32` inside the sim; `think` receives a shared `SimView`, so if a later rule needs a random draw the sim must expose one through the view (for example a per-tick `u64` drawn before `think` runs). No v0.1 rule needs it. +Determinism constraints on the crate (enforced by `crates/ai/clippy.toml` and CI): no `f32`/`f64`, no `HashMap`/`HashSet`, no `Instant`/`SystemTime`, no `rand::random`/`thread_rng`, no engine crates. Ties are broken by the lowest id or lowest tile index. The design permits randomness only from the single `Pcg32` inside the sim; `think` receives a shared `SimView`, so if a later rule needs a random draw the sim must expose one through the view. M6 does (owner decision 5 of 2026-10-28): `SimView::ai_noise(player, salt) -> u64` is a pure hash of the hashed seed, the player and a salt (`xxh3_64("eonmark-ai-noise" ‖ seed ‖ player ‖ salt)`), tick-free and never the `Pcg32`, so a bot's noise cannot shift combat's tie draws and a decision salted with a wave count or a site index is stable across plans with nothing remembered ([../DETERMINISM.md](../DETERMINISM.md), rule 5). It is the bot's only noise; slice 2's jitter uses it. -Bot state (M5b). What a bot must remember between plans (wave rosters and timers, the defend latch, the military latch, blocked-step cooldowns, a chosen site) lives in the player's `AiMemory`, opaque bytes the bot encodes itself (the Standard bot uses postcard), stored in `Player::ai_memory` and hashed in the `meta` sub-hash. Step 2 takes every AI player's memory out of the state before it builds the view and hands each bot only its own, so no bot reads another's; a surrendered bot neither thinks nor has its memory touched. Because the memory is hashed state, `Sim::snapshot()` carries it and a restore reproduces the bot's next decision exactly, a lockstep peer agrees on it, and a bot divergence names `meta`. Everything that can be re-derived from `SimView` is recomputed every plan, so the memory stays small (`AI_MEMORY_GUIDE_BYTES`, 4096, is the design ceiling; the default Standard memory encodes to no bytes). Any other state a controller keeps must be a pure function of immutable inputs: the Standard bot's `MapAnalysis` is built once from the map's terrain and starts, the rules and its personality, never from the live view, so a controller rebuilt mid-match issues the same commands. Which bot drives which slot is in the replay header (`MatchSetup.ai_seats`: each AI slot's `bot`, `passive` or `standard`, and `personality`, a `data/ai/personalities.ron` id), and `ai::controller_for` builds the controller from it for every re-simulation. Bot code is versioned by `SIM_VERSION` (owner decision 2): from 17 on, a `crates/ai` change that can alter a decision bumps it, and the fixture policy then forces the `rules_version` bump and a re-record. +Bot state (M5b). What a bot must remember between plans (wave rosters and timers, the defend latch, the military latch, blocked-step cooldowns, a chosen site) lives in the player's `AiMemory`, opaque bytes the bot encodes itself (the Standard bot uses postcard), stored in `Player::ai_memory` and hashed in the `meta` sub-hash. Step 2 takes every AI player's memory out of the state before it builds the view and hands each bot only its own, so no bot reads another's; a surrendered bot neither thinks nor has its memory touched. Because the memory is hashed state, `Sim::snapshot()` carries it and a restore reproduces the bot's next decision exactly, a lockstep peer agrees on it, and a bot divergence names `meta`. Everything that can be re-derived from `SimView` is recomputed every plan, so the memory stays small (`AI_MEMORY_GUIDE_BYTES`, 4096, is the design ceiling; the default Standard memory encodes to no bytes). Any other state a controller keeps must be a pure function of immutable inputs: the Standard bot's `MapAnalysis` is built once from the map's terrain and starts, the rules and its personality, never from the live view, so a controller rebuilt mid-match issues the same commands. Which bot drives which slot is in the replay header (`MatchSetup.ai_seats`: each AI slot's `bot`, `passive` or a difficulty id (`easy`, `standard` or `hard`, M6), and `personality`, a `data/ai/personalities.ron` id), and `ai::controller_for` builds the controller from it for every re-simulation. Bot code is versioned by `SIM_VERSION` (M5b owner decision 2): from 17 on, a `crates/ai` change that can alter a decision bumps it, and the fixture policy then forces the `rules_version` bump and a re-record. -Cadence. `think` is called every tick. Keeping under 0.5 ms mean means the bot does its planning on a cadence rather than every call; the natural cadence is the 10-tick influence-map refresh. The cadence and any per-rule timers are data in `difficulty.ron` or `personalities.ron`, not constants in Rust. From M5b the Standard bot plans every `think_every_ds` (5 ds, 10 ticks) on ticks where `tick % every == player x every / players` (P0 on 0, 10, ..., P1 on 5, 15, ...), and `rules::Rules::load` requires that cadence in ticks to exceed `cmd_delay_ticks`, so a plan's commands apply before the next plan reads the view. An off-cadence call, and every call once the match is decided, returns at once. +Cadence. `think` is called every tick. Keeping under 0.5 ms mean means the bot does its planning on a cadence rather than every call; the natural cadence is a plan every 10 ticks (since M6 the influence maps are built on the plan that reads them, not on a refresh of their own). The cadence and any per-rule timers are data in `difficulty/.ron` or `personalities.ron`, not constants in Rust. From M5b the Standard bot plans every `think_every_ds` (5 ds, 10 ticks) on ticks where `tick % every == ((player + seed) mod players) x every / players`. Since M6 (M6 owner decision 5, 2026-10-28) which seat plans first alternates with the seed's parity: in a two-player match P0 plans on ticks 0, 10, ... and P1 on 5, 15, ... on even seeds, and the reverse on odd seeds, so in `m5_first_match` (seed 1) P1 plans first (M5b's phase, `player x every / players`, let P0 plan first in every match). And `rules::Rules::load` requires that cadence in ticks to exceed `cmd_delay_ticks`, so a plan's commands apply before the next plan reads the view. An off-cadence call, and every call once the match is decided, returns at once. ## Architecture of the scripted bot (M5b) @@ -70,35 +70,219 @@ A personality is a build order plus a handful of knobs. The three are seeded int The personality id is part of `MatchSetup` so it is in the replay header and the lockstep handshake. `play-bots` selects it with `--personality-a` and `--personality-b`. +### Personalities (as built, M6 slice 2) + +Each row of `data/ai/personalities.ron` states every knob (`data/ai/README.md` lists them); rush, boom and tower are in the `random` pool, `standard` (the New Game's Balanced style and the M5b bot) is not. The rows differ in these knobs; everything else (retreat 650 per mille after losing 300 per mille, raids of 6 to 10 every 2 minutes, influence cells of 4 tiles, counters at 750 per mille with the Outrider as counter kind, `defend_economy`, the noise) is shared. + +| Knob | `standard` | `rush` | `boom` | `tower` | +| --- | --- | --- | --- | --- | +| Build order | M5b's, with a second Muster Hall after Arms I and a third after Masonry (tuning, rules 28) | the Muster Hall and the military latch right after the first Lumber Yard, before the Scriptorium; the second Town only after Masonry | the second Town at once, Statecraft I, a third Town, an early Muster Hall, military after Masonry | an early Ore Pit and border Watchtower, up to three Watchtowers, Harrying I right after Masonry | +| Army mix (Skirmisher, Bowman, Shieldbearer, Mangonel) | 3, 2, 1, 2 | 4, 3, 1, 2 | 3, 2, 2, 2 | Bowman-led: 2, 4, 2, 2 | +| Wave gate | Masonry | none | Masonry, and not before 15:00 | Masonry and Harrying I | +| Wave sizes (armed units) | 8, 12 from 15:00, 16 from 20:00, 24 from 25:00 | 8, 10, 14, 20 | 14, 20 from 20:00, 28 from 25:00, two siege units | 10, 14, 18, 24 | +| Wave interval | 3:00 | 2:30 | 3:00 | 3:00 | +| Wave mode, Wain rule | siege waves, a Wain always | raids until it has a siege unit, a Wain only once the enemy has Harrying | siege, Wain always | siege, Wain always | +| `seat_last` | yes | no | yes | yes | +| Defence | radius 16 | radius 12 | radius 20 | radius 16, tower guards hit Wains first, up to 2 reactive Watchtowers | + +Every personality goes all-in at 30:00 unless the level sets another time (since the M6 retune Easy 33:00 and Standard 36:00; Hard keeps 30:00). + ## Difficulty table (M6) -Difficulty is a cheat and the documentation says so. The bot does not play better on Hard; it receives more resources and attacks earlier. The cheat is a periodic bonus income: every `income_interval_ds` deciseconds the sim adds a bonus bundle (amounts in `difficulty.ron`) to the AI player's stockpile. A shorter interval is a larger cheat. The bonus is applied by the sim's economy step for players whose difficulty id in `MatchSetup` names one, so it is hashed, replayed and counted in `rules_hash` like every other rule. The human player receives nothing. +Difficulty is a cheat and the documentation says so. Until the M6 retune the bot did not play better on Hard; it received more resources and attacked earlier. Since the retune (M6 owner decision 12, 2026-10-29) the levels also play differently: Standard and Hard keep up siege pressure, Easy stays defensive and widens its borders, and the three go all-in at different times ("Difficulty table (as retuned)", below). The cheat is a periodic bonus income: every `income_interval_ds` deciseconds the sim adds a bonus bundle (amounts in `difficulty.ron`) to the AI player's stockpile. A shorter interval is a larger cheat. The bonus is applied by the sim's economy step for players whose difficulty id in `MatchSetup` names one, so it is hashed, replayed and counted in `rules_hash` like every other rule. The human player receives nothing. | Level | `income_interval_ds` | Aggression | Expected behaviour | | --- | --- | --- | --- | -| Easy | 300 (30 s) | defensive: waves only after the Masonry Age and only at the largest wave size | lets a new player learn the loop | -| Standard | 250 (25 s) | normal wave timing | the fun-gate baseline | -| Hard | 200 (20 s) | aggressive: first wave before minute 12 (tick 14400), smaller wave size, no Masonry gate | measurable pressure for an experienced player | +| Easy | 300 (30 s) | defensive: waves only after the Masonry Age and only at the largest wave size (14), 1.5 times as far apart, Statecraft II borders, one all-in at 33:00 | lets a new player learn the loop | +| Standard | 250 (25 s) | its personality's wave timing, siege pressure (rally groups, commit; it retreats as its personality does since `rules_version` 31), launches only when it outweighs the army at home, all-in at 36:00 | the fun-gate baseline | +| Hard | 200 (20 s) | aggressive: first wave before minute 12 (tick 14400), Standard's wave sizes 0.8 times as far apart (the design's smaller waves lost to concentration, M6 plan 2a #4), no Masonry gate, siege pressure, never retreats, assaults only at 1.3 to 1, all-in at the personality's 30:00 | measurable pressure for an experienced player | + +The M6 acceptance requires the knob to matter: changing Hard's `income_interval_ds` from 200 to 300 (the design text says `income_interval_s` 20 to 30; the field follows the `_ds` convention) must measurably flip the Hard-versus-Easy result in `play-bots`. Record that run here when it is done. The bar (M6 plan 2a #3 and section 7, line 4): at 200 Hard wins at least 15/20 in both seat orders; at 300 Hard wins at most 9/20 and Easy takes the majority, at least 11/20, in both seat orders (a draw is a win for neither, so Hard's 9/20 alone is not a flip). `scripts/check_difficulty_flip.sh` checks all four counts and Easy's two. + +| Run | Hard interval | Easy interval | Seat order | Hard wins / 20 | Easy wins / 20 | Draws | Bundle (both levels) | `rules_version`, commit | Note | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| baseline | 20 | 30 | Hard as A | 18 | 1 | 1 | 20 Grain, 20 Lumber, 10 Ore | 28, 72e63f4 | passes the 15/20 bar | +| baseline | 20 | 30 | Hard as B | 16 | 2 | 2 | same | 28, 72e63f4 | passes the 15/20 bar | +| flipped | 30 | 30 | Hard as A | 10 | 4 | 6 | same | 28, 72e63f4 | Hard still beats Easy 10 to 4: no flip | +| flipped | 30 | 30 | Hard as B | 6 | 8 | 6 | same | 28, 72e63f4 | Easy leads, but 8/20 is not the majority | +| baseline | 20 | 30 | Hard as A | 16 | 4 | 0 | same | 30, 62611ff | passes (Easy without the 850 handicap) | +| baseline | 20 | 30 | Hard as B | 16 | 4 | 0 | same | 30, 62611ff | passes | +| flipped | 30 | 30 | Hard as A | 2 | 18 | 0 | same | 30, 62611ff | flips: Easy's majority | +| flipped | 30 | 30 | Hard as B | 0 | 20 | 0 | same | 30, 62611ff | flips: Easy's majority | + +At `rules_version` 30 (the M6 retune, 2026-10-30, the 4-vCPU VM) `scripts/check_difficulty_flip.sh` prints `PASS`: Hard 16/20 and 16/20 at 200; at 300 Hard 2/20 with Easy 18 and Hard 0/20 with Easy 20, no draw and no untouched cap game in any of the 80 games. At `rules_version` 31 (Standard's retreat back; Standard plays no part in the flip) the four CSVs are byte-identical to 30's. + +Why it flips, from the measured capture ticks (seeds 1..20; Hard's all-in is the personality's 30:00, tick 36000): + +- **At 200 Hard wins by siege pressure, before its all-in.** 25 of its 32 wins take both of Easy's Towns before tick 36000 (Hard as A: 12 of 16, captures 24097 to 35948; Hard as B: 13 of 16, 25850 to 33975). +- **At 300 the same pressure fails.** Hard wins 2 games (26499 and 42831); Easy wins the other 38, with late captures at 47686 to 53975 (23) or on territory at the cap (15, Easy's Statecraft II), after Hard's 30:00 all-in has been wiped against Easy's defended Towns. +- So the 20 s against 30 s bonus decides whether Hard's pre-all-in pressure converts against a defensive Easy; Hard's all-in at 30:00, which a failed siege leaves to meet Easy's intact army, then costs it the late game. + +The flip holds beyond the line's seeds only in part: seeds 21..40 pass (Hard 16 and 16 at 200; 3 with Easy 17, and 1 with Easy 19, at 300), but on seeds 41..60 the 200 clause misses from seat B (Hard as A 16/20, Hard as B 9/20; at 300 4 with Easy 16, and 1 with Easy 19). Over seeds 1..60 Hard as B beats Easy 41/60. Its 9 wins on 41..60 are all captures before its all-in (25613 to 32580); Easy's 11 come after it (46832 to 54000, 3 on territory), once Hard's 30:00 all-in against a defended Easy is wiped. The acceptance line reads seeds 1..20, which pass; the margin is recorded as a risk (below), and a strength gate on Hard's all-in (the gate its assaults already have) was measured: it makes Hard win at 300 too (Hard 20/20 and 15/20, review batch z1), so it is not shipped. + +At `rules_version` 28, measured with `scripts/check_difficulty_flip.sh` on the 4-vCPU VM on 2026-10-29, and again after the review fixes (byte-identical CSVs): **FAIL**. Easy keeps its disclosed `worker_target_permille` 850 handicap at both intervals, so the flipped run is an equal-bonus control, not an equal-income one. 30 of the 40 flipped games reach the cap with no Town taken by either side, and 29 of those end within 3 tiles of territory (12 exact-tie Draws and 17 tiebreaks by 3 tiles): the result at 300 is mostly decided by the bots' site noise, not by play ("Difficulty table (as tuned)", below). + +The M6 acceptance text names the file `hard.ron`; the crate layout named `data/ai/difficulty.ron`. M6 owner decision 3 (2026-10-28) chose one file per level, `data/ai/difficulty/{easy,standard,hard}.ron`, so line 4's `hard.ron` edit is literal; `data-check` validates them and `rules_hash` covers them ([../DATA_FORMAT.md](../DATA_FORMAT.md)). + +### Difficulty table (as built, M6 slice 1) + +M6 owner decision 2 (2026-10-28) fixes what "income every 30/25/20 s" means: a **disclosed bonus bundle** the sim pays a bot seat every `income_interval_ds`, from `income_from_ds` (3:00) on, the same bundle at every level, so the interval alone sizes the cheat. It is not a think interval (that would make Easy unresponsive) and not a rate multiplier (invisible, and it fights the Yield Cap). It never reads the human's state (no rubber band), the New Game row and the game-over screen state it (slice 3), and its share stays under a ceiling: Standard's bonus share is at most 200 per mille over Standard-vs-Standard matches, Hard's at most 300 against Standard. The share is the M6 plan's (section 3.3) `bonus / (bonus + income)`, where income is the credited gathering and base yields without the bonus (the CSV's `income_*` columns; `MatchStats::bonus_share_permille`); `bonus / income` would read higher. Neither the owner's text nor the plan says whether the ceiling binds pooled over a run or for every seat of every match: "as tuned" below gives both, and the reading is an open question for the owner. Slice 2's sweep sets the bundle to the smallest that passes lines 2 and 4; if line 2 cannot pass under the ceiling, Easy alone gets a disclosed handicap (`worker_target_permille` 850) and the flip is re-checked. + +| Field | `easy.ron` | `standard.ron` | `hard.ron` | Why | +| --- | --- | --- | --- | --- | +| `income_interval_ds` | 300 (30 s) | 250 (25 s) | 200 (20 s) | the strength dial; the line-4 flip edits Hard's 200 to 300 | +| `income_from_ds` | 1800 | 1800 | 1800 | every opening is M5b's; first payments at 3:30, 3:25 and 3:20 (ticks 4200, 4100, 4000) | +| `income` | 20 Grain, 20 Lumber, 10 Ore | same | same | slice 1's starting bundle; slice 2's sweep over {30, 50, 80, 120} at 2:2:1 picks the smallest that passes | +| `wave_age_gate` | `Age("masonry")` | `Personality` | `Off` | Easy waits for Masonry; Hard has no Masonry gate (rule 5 above) | +| `largest_wave_only`, `max_wave_units` | true, 14 | false, none | false, none | Easy sends only its largest wave, capped at 14 armed units, not a 24-unit cliff | +| `wave_size_permille` | 1000 | 1000 | 1000 | Hard keeps Standard's wave sizes (750 per mille waves lost to concentration in the design runs) | +| `wave_interval_permille` | 1500 | 1000 | 900 | Hard attacks more often without feeding small waves | +| `all_in_after_ds` | 24000 (40:00) | the personality's | the personality's | a defensive Easy still ends games | +| `first_raid` | none | by 9000 ds (15:00), 6 to 12 armed | by 6000 ds (10:00), 6 to 10 armed | line 4 and guaranteed border pressure; slice 1 loads and stores it, slice 2's `waves::deadline_raid` sends it (see "as tuned" below) | -The M6 acceptance requires the knob to matter: changing Hard's `income_interval_ds` from 200 to 300 (the design text says `income_interval_s` 20 to 30; the field follows the `_ds` convention) must measurably flip the Hard-versus-Easy result in `play-bots`. Record that run here when it is done (table below is the template). +`rules::ai::resolve(personality, difficulty)` is the one place a level touches the bot's knobs, a pure function of the two rows that `StandardBot::new` stores, so a bot rebuilt mid-match plays the same knobs: every `wave_sizes` row times `wave_size_permille`, rounded up, at least 1 and at most the cap (the smaller of the personality's and the level's `max_wave_units`); `largest_wave_only` sets every row to the last row's size; `wave_interval_ds` times `wave_interval_permille`, rounded to nearest; the age gate; and the all-in time when the level sets one. Counters, influence and noise are personality knobs and equal at every level ("the bot does not play better on Hard"); retreat is the personality's scaled by the level's `pressure.retreat_*` permilles. At slice 1 the `standard` level resolved to the personality itself; since the M6 retune it resolves to the personality with its all-in at 36:00 (and, since `rules_version` 31, the personality's retreat; Hard never retreats), with the rest of its `pressure` block read by the bot ("Difficulty table (as retuned)", below). -| Run | Hard interval | Easy interval | Hard wins / 20 | Note | +The sim's side is step 6d' (`economy::bonus_step`, `SIM_VERSION` 18; [../DETERMINISM.md](../DETERMINISM.md), "What M6 adds"): the schedule is derived from the header and the rules, the bundle is added and clamped to the stockpile caps, and `SimEvent::BonusIncome` reports what was credited. The play-bots CSV totals it per side (`bonus_a`, `bonus_b`) next to the credited income (`income_a`, `income_b`), so each run shows the bonus share. + +Validators (`crates/rules/src/ai.rs`, each error naming the file and the field, with a negative test per rule): the `id` is the file stem, `passive` is reserved and a `standard` file must exist; `income` names resource ids, each 0 to 500; `income_interval_ds` is 0 or 50 to 72000 and in ticks more than `cmd_delay_ticks`; `income_from_ds` and `first_raid.by_ds` are below the match cap; the multipliers are 250 to 4000 per mille; `max_wave_units` is 1 to 64; an `Age(..)` gate names an age; `first_raid` has `1 <= min_armed <= max_armed <= 64` and `prep_ds < by_ds`; and every build order builds a trainer of an armed kind before its first `Advance`. `data-check` prints `difficulties=3`. + +### Difficulty table (as tuned, M6 slice 2) + +The shipped values (`rules_version` 28): the bundle stays 20 Grain, 20 Lumber, 10 Ore at every level and every slice-1 knob above is unchanged; Easy adds M6 owner decision 2's disclosed fallback handicap, `worker_target_permille` 850 (`rules::ai::resolve_order` scales every `Workers` count of its build order, rounded up: 15% fewer Yeomen), because line 2 did not pass under the ceiling. Standard's build order takes a second Muster Hall after Arms I and a third after Masonry: with one hall the bonus floated as stockpile (a traced game with a fourfold bundle held over 1300 of each resource at 10:00), so a 4x bundle won only 6/12 and 10/12 against 1x with the same knobs, and with three halls 12/12 from both seats. + +Bonus shares (`bonus / (bonus + income)`, per mille, from the CSVs). The ceiling is defined over Standard-vs-Standard matches: the default mirror (`play-bots --seeds 1..20 --jobs 4`, the `standard` personality) pools at 177 over its 40 seats, but one seat passes 200 (228, seed 5 seat A, which lost at tick 30886 with low income: a fixed bundle is a larger share of a small income); line 1's personality games pool at 184 (182 over seeds 1..60), with 11 of the 40 seats of seeds 1..20 over 200 (10 of them losing; the largest 259, seed 10 seat B, boom) and 36 of 120 over seeds 1..60 (the largest 267, seed 44 seat B). Hard pools at 213 against Standard, its largest seat 245, under 300 in every game. In line 2's games Standard pools at 169 against Easy and 177 against Hard, Easy at 158. Read as `bonus / income`, every pooled Standard figure is over 200 (204 to 225). So the shipped 20/20/10 bundle meets the ceiling pooled over a run and breaks it for single losing seats; which reading binds is the owner's call (open questions). + +| ROADMAP M6 line | Command | Result at `rules_version` 28 (2026-10-29, 4-vCPU VM) | +| --- | --- | --- | +| 2, first clause | `play-bots --a easy --b standard --seeds 1..20` | Standard 9/20 (a=6, b=9, 5 draws; 12 tiebreaks): **fails** (`check_ordering.sh` needs 15) | +| 2, second clause | `play-bots --a standard --b hard --seeds 1..20` | Hard 8/20 (a=12, b=8; 18 decisive): **fails** | +| 4, first clause | Hard against Passive, seeds 1..3, from seat B, with every Random style, and from seat A | 3/3 in each: Hard's deadline raid orders the attack at 12003-12733 and stands on the enemy's land by 12782; rush with no age gate at 5843 | +| 4, second clause | `scripts/check_difficulty_flip.sh` | 18/20 and 16/20 at 200, 10/20 and 6/20 at 300: the result drops from both seats but Hard as A misses the 9/20 bar by one game, **fails** | + +What the tuning measured (8 rounds of 12-seed grids on copies of `data/` passed with `--data`, the sweep table below; the table above is the 20-seed confirmation): + +- **Defending beats attacking.** With the design's shapes (Easy waits for Masonry and sends only its largest wave, at most 14 armed units, 1.5 times as far apart), Easy beat Standard 20/20 at the slice-2 baseline; at `rules_version` 28 Easy-vs-Standard games still mostly go to the 54000 cap (12 of 20 tiebreaks and 5 draws at equal territory) with Standard losing 270 units a game to Easy's 160. +- **Untouched cap games are decided by site noise, not by play.** When neither seat takes a Town, two Standard-bot seats (Easy, Standard and Hard are levels of the same bot) reach the cap with mirrored territory: slice 2's mirror-aware site ties (`MapAnalysis.mirror`, `sites.rs`) make P1 build the mirror image of P0. The one difference left is each seat's `noise_site_top_k` pick between its two best Expansion and BorderEdge candidates, which moves its territory by exactly 3 tiles (2493 or 2496). So an untouched cap game is an exact-tie Draw when both seats' noise picks the same rank, and otherwise a 3-tile tiebreak that `ai_noise` decides. In `play-bots --a easy --b standard --seeds 1..20`, 15 of the 17 cap games are untouched and 13 end within 3 tiles: the 5 Draws and 8 tiebreaks, 6 of them Standard's (6 of its 9 wins). With `noise_site_top_k: 1` in every personality (a scratch copy of `data/`) the same command gives 10 Draws, every one at 2493 to 2493. The flip at 300 is the same (30 of its 40 games untouched, 12 Draws). `scripts/check_ordering.sh` and the flip script print these counts. The tuning log had read the 3-tile gaps as seat bias (the plan's risk 6); with the site noise off no seat asymmetry is left. M5b owner decision 3 and the plan's risk 5 call a Draw that persists a bot bug, so how a match between two intact bots resolves before the cap is a needs-design question for the owner (below). +- **The bonus barely converts.** Bundles of 12/12/6 to 48/48/24 never got Standard past Easy, and Hard against Standard got worse as the bundle grew (the defender gains as much). Above 20/20/10 the pooled shares pass the ceiling before the ordering improves. More Muster Halls make a large bonus decisive, but the gap between levels under the ceiling (25 s against 30 s, about 3 per cent of total resources) stays within match-to-match variance. +- **Shapes trade one clause for another.** Giving Easy Standard's wave shape with the 850 handicap brings Standard to 9/12 against it, but Hard then beats Easy at equal bonus too (the flip's control fails, 12/12 and 9/12). Easy's all-in at 25:00 gives 9/12, at 30:00 and 35:00 3/12; Hard's interval at 1300 per mille gives 11/12 against Standard but lifts the flip's control. At 12 seeds single edits swing a reading by 4 to 8 games. + +The bundle sweep and the shipped data (line 2's two clauses count wins of seat B; the flip is Hard's wins as A and as B; shares are pooled over the Standard-against-Hard run): + +| Bundle (Grain, Lumber, Ore) | Seeds | Data | Standard beats Easy | Hard beats Standard | Flip at 200 / at 300 | Shares S / H (per mille) | +| --- | --- | --- | --- | --- | --- | --- | +| 12, 12, 6 | 12 | slice-2 knobs, the design's Easy | fails | 4/12 | not run | 109 / 141 | +| 20, 20, 10 | 20 | the same (round 0) | 0/20 | 9/20 | 7, 0 / 2, 1 | about 172-176 / 202 | +| 32, 32, 16 | 12 | the same | fails | 1/12 | not run | 244 / 301 | +| 48, 48, 24 | 12 | the same | fails | 2/12 | not run | 326 / 394 | +| 20, 20, 10 | 12 | three Muster Halls, Easy 850 (f1, shipped) | 5/12 | 6/12 | 10, 10 / 5, 3 | 178 / 211 | +| 22, 22, 11 | 12 | f1 with the larger bundle (h4) | 7/12 | 2/12 | 10, 8 / 5, 5 | 183 / 242 | +| 20, 20, 10 | 20 | f1 at `rules_version` 28 (the confirmation above) | 9/20 | 8/20 | 18, 16 / 10, 6 | 177 / 213 | + +So line 2 and the flip's 9/20 bar do not pass with data under the owner's ceiling. The candidates for the owner: a larger bundle (beyond the ceiling), a difficulty that also scales the defence (Easy's response radius or guard) or production (Hard's Muster Halls), making untouched games between two intact bots resolve before the cap (Standard's all-in or siege pressure against a defensive Easy, or a cap result that mirrored sites cannot decide: most of line 2's first clause and of the flip at 300 is site noise, above), or reading line 2 from both seats over more seeds. + +### Difficulty table (as retuned, M6 owner decision 12) + +M6 owner decision 12 (2026-10-29) answered the needs-design question above: Easy stays defensive with smaller, later waves; Standard and Hard keep up siege pressure until a Town falls; mirrored sites must no longer make cap games exact copies. Decision 14 fixed the bonus as the same disclosed bundle under an averaged ceiling: Standard at most 200 per mille and Hard at most 300, pooled over a run. The retune is `rules_version` 29 (the knobs, all neutral) and 30 (the shipped values); `SIM_VERSION` stays 18 and the bundle stays 20 Grain, 20 Lumber, 10 Ore at 30, 25 and 20 s. + +Each difficulty file gains a `pressure` block (`rules::Pressure`, all zero or 1000 per mille = off, which reproduces `rules_version` 28's games column for column) and an `extra_research` list ([../../data/ai/README.md](../../data/ai/README.md)): + +| Behaviour | Easy | Standard | Hard | What it does (crates/ai/src/standard/waves.rs, retreat.rs) | | --- | --- | --- | --- | --- | -| baseline | 20 | 30 | (fill in at M6) | | -| flipped | 30 | 30 | (fill in at M6) | expected to drop toward 10/20 or below | +| waves | after Masonry, only the largest (14 armed), 1.5 times as far apart | the personality's | Standard's sizes, 0.8 times as far apart, no age gate | unchanged from slice 1 except Hard's 900 -> 800 | +| `reinforce_min_units` | 0 | 6 | 6 | a group of at least 6 armed units at the rally joins an assaulting wave (all-in groups too); one armed unit always stays home | +| `commit_ds` | 0 | 300 | 300 | while the target lost HP in the last 30 s the wave neither retreats nor ends at `wave_max_ds` | +| `retreat_ratio_permille` | 1000 | 1000 (0 at `rules_version` 30) | 0 | Easy and Standard retreat as their personality does; Hard never retreats. At 30 Standard did not retreat either (the plain-Move retreat was a rout: 25 lost against 3 in the rules-28 trace); the review of the retune put Standard's back at 31 (`rules_version` 31: line 2 19/20 and 16/20). Hard's stays 0: at 500 it costs line 2's second clause (12/20), at 1000 that (13/20) and the flip's 200 clause (14/20); a deviation from M6 plan 2a #2 (retreat equal at every level), pending the owner (below) | +| `lure_ds` | 0 | 600 | 600 | while defenders stand near the target, the wave holds at the Wain point out of shooter reach for up to 60 s | +| `cohesion_tiles` | 0 | 4 | 4 | units past the stage point more than 4 tiles ahead of the rear quarter stop until it closes up; never one under fire | +| `launch_ratio_permille` | 0 | 800 | 0 | a siege wave launches only when its strength is at least 0.8 times the enemy army at home | +| `assault_ratio_permille` | 0 | 0 | 1300 | a staged wave assaults only at 1.3 times the enemy army at home; short, it waits at the stage point for rally groups and comes home at `stage_max_ds` | +| `all_in_after_ds` | 19800 (33:00) | 21600 (36:00) | the personality's (30:00 for `standard`) | the all-in order, below | +| `extra_research` | `["statecraft_2"]` | none | none | +1 radius on every Easy Town: a cap game in which no Town fell is Easy's | +| `worker_target_permille` | 1000 | 1000 | 1000 | the 850 Yeomen handicap is gone: it made Easy stronger (fewer Yeomen, more army pop at the 100 cap) | + +The `tower` build order also researches Statecraft II after Arms III (as `boom` already did), so in line 1 a tower seat's untouched cap game is not a mirror image of a rush seat's either. This research difference is the only tie-breaker decision 12's mirror clause has: it covers pairings with exactly one Easy seat, or with a `tower` or `boom` seat against `rush` or Balanced. Pairings whose two seats resolve to the same research still build mirror-image sites, separated only by `noise_site_top_k`: Standard against Hard on the same personality (line 2's second clause; `the_research_that_breaks_mirrored_cap_games_is_pinned` pins that the two levels resolve the same order), every same-level mirror and Balanced-against-Balanced. At `rules_version` 31, Easy against Easy over seeds 1..20 has 2 exact-tie Draws (seeds 10 and 11, 2664 a side) and 4 more untouched cap tiebreaks within 8 tiles; the Standard mirror over seeds 1..60 has 2 exact-tie Draws (seeds 8 and 38, 2496 a side) and one 3-tile tiebreak (seed 16); Standard against Hard over seeds 41..60 has one exact-tie Draw (seed 47, 2496 a side), none on the line's seeds 1..20. The same-level case is an open question for the owner (below). + +**Stalemate analysis, before (`rules_version` 28).** Line 2's first clause was 9/20 with 5 Draws and 12 tiebreaks: 15 of its 17 cap games had no Town taken, and 13 ended within 3 tiles, decided by `noise_site_top_k`. Traced (seed 8, a 2496/2496 Draw): Standard's 25-unit first wave lost 5 to 2 at Easy's second Town, retreated with a plain Move and lost 13 more for 1; Standard sent 7 waves, retreated 6 times and lost 297 units to Easy's 145, while Easy sat at 100/100 pop from 36000 and Standard's 30:00 all-in trickled one unit at a time into 68 defenders. The pop cap is tech-bound and income Yield-Cap-bound, so the bonus (about 10 per cent of the total) buys only faster replacement. The second clause was 8/20, and the flip at 300 (10/20 and 6/20) had 30 untouched cap games of 40 and 12 Draws. + +**What decides the games now.** Defence still beats attack: a wave that assaults a defended Town is wiped. What the retune measured (16 batches of 40 seeds, `play-bots --data` on copies of `data/`): + +- **The all-in order decides line 2's first clause; the flip is decided before the all-ins.** Against a defensive Easy, the side whose all-in comes first throws its army at a defended Town and is usually taken in the counterattack. With Standard at 30:00, before Easy, Standard's wins against Easy stay at 27/40; Hard 30:00 < Easy 33:00 < Standard 36:00 gives Standard 39/40. This is not a general rule: against Standard, Hard goes all-in first and still wins 17/20 at `rules_version` 30, 7 of them after tick 36000. In the flip, Hard's wins at 200 come mostly before its all-in (above), and with Easy's all-in at 30:00, before Hard's pressure has failed, Hard wins at 300 too (31/40 and 30/40: the flip fails). The shipped batch X0 reads Hard 32/40 and 32/40 at 200, and 5/40 and 1/40 at 300 (seeds 1..40, both seat orders). +- **Line 2's first clause depends on Easy's all-in coming before Standard's; this is a deviation from decision 12 pending the owner** (below, "Deviations pending the owner"). +- **Standard's launch gate wins the first clause.** Without it Standard beats Easy 11/40 (its waves feed Easy's home army); at 1300 it costs about 4 games against Hard; 800 keeps both. +- **Pressure without the gates is not enough.** Reinforcement, commit, no retreat, lure and cohesion together moved line 2 from 9 and 8 to 13-15 and 15-16 of 20 on seeds 1..20 and about 57 per cent over 40 seeds. +- **Measured and not shipped:** larger Standard waves (no change), an Easy home guard of 10 (worse for line 2's first clause, Standard 8 against 11-12 of 20, and for the flip at 200; the second clause unchanged, since an Easy knob cannot touch Standard against Hard), two extra Easy border Watchtowers (Easy becomes a fortress: Standard 5/40), border-tower site noise (towers move no territory; more Draws), a standoff where the siege shells from range while the escorts hold (worse against Easy; removed), and three `rules.ron` levers (Mangonel against buildings 6000, Watchtower damage 10, `annexation_ds` 300: within 2 games of the base on line 2, since the waves die before they shell or annex; Watchtower damage 10 did move the flip, Hard as A at 200 from 25 to 32 and Hard as B at 300 from 0 to 3). +- **The bonus converts under pressure but is too small a lever alone:** with Standard's bundle doubled or tripled (shares 292 and 381) Standard beats Easy 18 and 20 of 20, but the 25 s against 30 s gap is only 1.2 times the bonus. + +The sweep (line 2's clauses count seat B's wins; the flip is Hard's wins as A and as B at 200 and at 300 with Easy's wins in brackets; shares are the S-H run's Standard / Hard per mille): + +| Batch | Data | Seeds | Standard beats Easy | Hard beats Standard | Flip at 200 | Flip at 300 | Shares | +| --- | --- | --- | --- | --- | --- | --- | --- | +| base | `rules_version` 28 | 1..20 | 9 (5 Draws) | 8 | 18, 16 | 10, 6 (12 Draws) | 177 / 213 | +| p3 | Standard and Hard reinforce, commit, no retreat, lure | 1..20 | 11 | 15 | 19, 17 | 15, 10 | | +| s1 | p3 + cohesion + Standard launch gate 1000, Easy without the handicap | 1..40 | 23 | 29 | 36, 31 | 15 (21), 10 (21) | | +| J0 | s1 at gate 1300 + Easy Statecraft II | 1..40 | 28 | 27 | 34, 31 | 15 (25), 1 (39) | | +| L0 | J0 + the cohesion fix | 1..40 | 25 | 33 | 25, 25 | 7 (33), 0 (40) | | +| M1 | L0 + Standard and Hard assault gate 1300 | 1..40 | 27 | 28 | 30, 31 | 9 (31), 3 (37) | | +| N0 | M1 + Easy all-in 30:00 | 1..40 | 39 | 28 | 36, 36 | 29 (11), 31 (9) | | +| R3 | M1, Standard without the assault gate, all-ins 30 / 33 / 36 min | 1..40 | 40 | 29 | 29, 32 | 8 (32), 3 (37) | | +| U1 | R3 + Standard launch gate 800 | 1..40 | 39 | 32 | 29, 32 | 8 (32), 3 (37) | | +| FINAL1 | U1 + tower Statecraft II + one armed unit stays home | 1..40 | 39 | 33 | 27, 32 | 7 (33), 3 (37) | | +| X0 (shipped, 30) | FINAL1 + Hard interval 800 | 1..40 | 39 | 32 | 32, 32 | 5 (35), 1 (39) | 187 / 209 | +| Z0 (review) | X0 + Standard and Hard retreat 1000, Easy all-in 40:00 | 1..20 | 3 (17 cap games) | 13 | 14, 14 | 12 (8), 2 (18) | 184 / 214 | +| Z5 (review) | Z0 with Easy's all-in never, the all-in held to the gates (experiment), Standard's all-in 30:00 | 1..20 | 3 (17 cap games) | 15 | 14, 14 | 12 (8), 2 (18) | | +| Z2 (review) | Z5 with Easy's all-in at 33:00 (after Standard's 30:00) | 1..20 | 16 | 15 | 19, 19 | 20 (0), 15 (5) | | +| V1 (shipped, 31) | X0 + Standard retreat 1000 | 1..40 | 39 | 30 | 32, 32 | 5 (35), 1 (39) | 187 / 211 | +| V3 (review) | V1 + Hard retreat 500 | 1..40 | 39 | 28 (12 on 1..20) | 32, 34 | 18 (22), 6 (34) | | + +FINAL1's flip read 13/20 with Hard as A at 200 on seeds 1..20 (the rally group fix, which `m5b_pop_never_deadlocks` needed, changes every game), so b16 swept Hard's interval: 750 gives 34 against Standard but 8 at 300; 850 gives 28 at 200; 800 was shipped. + +**After (`rules_version` 31, the 4-vCPU VM, 2026-10-30)**, the literal commands (at 30 the same rows read: line 1 19 decisive, median 26796.5, 12 past; line 2 19/20 and 17/20; seed 7 `0x5d56c36dc4ba164e`): + +| ROADMAP M6 line | Result | Margin target | +| --- | --- | --- | +| 1 (`play-bots --seeds 1..20` with the three personalities) | 20 winners, 19 decisive before 48000, median 26481, 12 past 24000, one tiebreak, two runs identical; seeds 21..40: 20, 19, 10 past (median 24602.5); 41..60: 20, 18, 12 past | passes on all three seed sets; 21..40 sits at the 10 bar | +| 2, first clause | Standard 19/20 (three cap games: two Standard tiebreaks with an Easy Town taken, and Easy's one win, an untouched 2664-2496 tiebreak; no Draw); seeds 21..40 20/20, 41..60 19/20 | met, but see "Deviations pending the owner": it rides on Easy's all-in | +| 2, second clause | Hard 16/20, all decisive; seeds 21..40 14/20, 41..60 16/20 with one Draw | met on the line's seeds; 21..40 is one short | +| 3 | seed 7 twice: identical CSVs, `0x293a97cb1b22256f` (b, 26823); Hard-rush against Easy-tower `0x6993967af0e04396` (unchanged) | | +| 4, first clause | 3/3 from seat B, with every Random style and from seat A (12003-12733; rush 5843) | | +| 4, second clause | 16/20 and 16/20 at 200; 2/20 (Easy 18) and 0/20 (Easy 20) at 300; no Draw, no untouched cap game; 21..40 passes; 41..60 fails at 200 from seat B (9/20) | met on the line's seeds | + +Pooled bonus shares (decision 14) over line 2's two runs and the flip's base runs: Standard 174, Hard 207, Easy 161 per mille; Standard 189 in line 1 and 179 in the default mirror (seeds 1..60). Single seats still pass 200 (mostly losing seats with low income); decision 14 reads the ceiling pooled. -The M6 acceptance text names the file `hard.ron`; the crate layout names `data/ai/difficulty.ron`. Either one file with three entries or one file per level is acceptable as long as `data-check` validates it and `rules_hash` covers it. Decide at M6 and update both this table and [../DATA_FORMAT.md](../DATA_FORMAT.md). +Capture ticks of line 2's first clause over seeds 1..60 (Easy's all-in starts at tick 39600, Standard's at 43200): Standard wins 58, Easy 2. Ten wins come before Easy's all-in (22120 to 28484), none between 39600 and 50000, 46 from 50373 to 53608 and 2 on territory at the cap. Stopped at Easy's all-in (`--max-ticks 39600`, seeds 1..20), Standard has taken no Town in 17 games and has not damaged an Easy Town in 15 of them. + +#### Deviations pending the owner (review of the retune, 2026-10-30) + +These are not decided by the owner; they are recorded here for the owner's call before PR 1 merges (M6 owner decision 13). The staged ROADMAP ticks for lines 2 and 4 wait on the first one. + +1. **Easy's all-in comes before Standard's.** Decision 12 reads "Easy defensive: smaller, later waves". Easy's regular waves are smaller and later (after Masonry, at most 14, 1.5 times as far apart), but its all-in, every armed unit and never recalled, moved from 40:00 (10 minutes after Standard's 30:00) to 33:00, 3 minutes before Standard's 36:00. Line 2's first clause rides on that order: Standard's siege pressure does not take a defended Easy's Towns before the all-ins (above), and Standard's wins come after Easy throws its army at a waiting Standard. Measured in decision 12's shape (review batches z1 and z3, `m6rt/tuning_log.txt` in the session scratchpad; seeds 1..20, Standard retreating), line 2's first clause fails every time: Easy's all-in at 40:00 or never, Standard's at 36:00: Standard 3/20 (17 cap games Easy wins on its Statecraft II territory); the all-in held to the levels' launch and assault gates (an experiment, not shipped) with Standard's all-in at 30:00 and Easy's never: 3/20; adding Standard's assault gate 1300, 4/20; then Easy's waves 1.5 times as often, 6/20; or instead Standard's waves 1.5 times larger with a launch gate of 1300, 9/20. The gated all-in with Standard at 30:00 and Easy at 33:00 (Easy still after Standard) passes line 2 (16 and 20/20) only through Easy's all-in again, and fails the flip (Hard wins at 300, 20/20 and 15/20). The owner's choices: accept the order (Easy's one all-in between Hard's and Standard's) as decision 12's "later waves" for the regular waves only, or a different lever (a larger Standard bonus was declined in decision 12; an Easy whose standing army is smaller; a ruleset change that weakens the defender). +2. **Hard never retreats.** M6 plan 2a #2 keeps retreat equal at every level; Hard's `pressure.retreat_ratio_permille` is 0 (a Hard raid or wave fights to the end unless its siege is committed, which it then is anyway). Restoring it fails line 2's second clause: at 500 Hard beats Standard 12/20 (seeds 1..20), at 1000 13/20, where the flip's 200 clause also drops to 14 and 14. Standard's retreat is back since `rules_version` 31. +3. **Proxies that miss their M6 plan 4.2 targets** (`rules_version` 31, "The fun gate in CI" below): F1 wiped/waves is 58 per cent over line 1's 60 games (rush 151/231, tower 59/129, boom 11/24; 68 per cent at 30, 48 at 28) and 61 and 49 per cent in the rush and tower mirrors (25/41, 37/76), and above it in 1 of the 6 standard pairings (rush against Standard, 15/22) and 3 of the 6 distinct ones (boom/rush and rush/boom 15/20, rush/tower 44/70), against the 50 per cent target; F2's "the loser pressed a Town below 500 per mille" is 0 or 1 of 5 in every pairing, against 40 per cent; the balance band (every personality 30 to 70 per cent of its non-mirror games over 60 seeds) is missed at both ends: boom 2/39 (5 per cent, 24 waves), tower 30/39 (77), rush 28/42 (67); at `rules_version` 28 boom was 8/39 (21) and tower 26/39 (67). +4. **The level ordering is measured for the Balanced personality only.** ROADMAP line 2's command has no personality flag, so it plays `standard` (Balanced). Under New Game's default Style Random (rush, boom or tower on both sides, seeds 1..30), Standard beats Easy 17/30 (one Draw) and Hard beats Standard 16/30; with `--mirrors` 17/30 (one Draw) and 16/30 (one Draw). The style matchup decides more than the level: Hard-boom loses to Standard-rush 4 to 1 and to Standard-tower 5 to 0, Standard-boom loses to Easy-rush 1 to 4 and to Easy-tower 0 to 5, and every boom seat against rush loses 5 to 0 at every level. Per pair (A's wins to B's), Easy against Standard: rush/boom 4:1, tower/boom 5:0, boom/rush 0:5, boom/tower 1:4, rush/tower 1:3 (one Draw), tower/rush 1:4; Standard against Hard: rush/boom 4:1, tower/boom 5:0, boom/rush 0:5, boom/tower 2:3, rush/tower 1:4, tower/rush 2:3. +5. **Same-level mirrors still build mirror-image sites** (the paragraph after the retune table): decision 12's mirror clause is met only between different research. + +The remaining risks: outcomes are chaotic per seed (seed parity, which seat plans first, moves a reading by about 25 percentage points, and one changed command reshuffles every later game), so a clause measured at 15-17 of 20 can drop by 2-3 with any change: line 2's second clause reads 14/20 on seeds 21..40 and the flip's 200 clause 9/20 from seat B on 41..60; line 1's `past 24000` is 10 of 20 on seeds 21..40, at the bar; the default Standard mirror favours seat B 35 to 23 over seeds 1..60 with 2 Draws (M5b's five-seed acceptance still has 5 winners); and Hard rush's 1200-tick bench still fails its exercise gate (unchanged since `rules_version` 28; the 1800-tick run passes). ## Influence maps and counter-weighted composition (M6) -`crates/sim/src/influence.rs` keeps, per player, two 32 x 32 grids of integer strength over 4 x 4 tile cells: own military strength and visible enemy military strength. Strength is the sum over units of a per-kind value from `units.ron` (a Mangonel counts more than a Skirmisher). The maps are recomputed every 10 ticks from the fog-filtered `SimView` (see [fog.md](fog.md)), so unseen enemies do not appear. They are derived state: unhashed, rebuilt in `restore()`. +As first designed (superseded in M6 slice 2: the maps live in `crates/ai`, built on the plan that reads them and never stored, and a siege target is scored by distance plus strength; see "As built (M6 slice 2)" below and [../DETERMINISM.md](../DETERMINISM.md), "Derived caches"): `crates/sim/src/influence.rs` keeps, per player, two 32 x 32 grids of integer strength over 4 x 4 tile cells: own military strength and visible enemy military strength. Strength is the sum over units of a per-kind value from `units.ron` (a Mangonel counts more than a Skirmisher). The maps are recomputed every 10 ticks from the fog-filtered `SimView` (see [fog.md](fog.md)), so unseen enemies do not appear. They are derived state: unhashed, rebuilt in `restore()`. The bot uses them for three decisions: - Retreat when losing (rule 7 above). -- Wave target: among known enemy Towns, prefer the one with the lowest enemy strength in its cell and neighbours, breaking ties by lowest `BuildingId`. +- Wave target: among known enemy Towns, prefer the one with the lowest enemy strength in its cell and neighbours, breaking ties by lowest `BuildingId` (as built, the distance counts too: "Targets" below). - Counter-weighted composition: the bot reads the visible enemy composition by kind and weights its next training choices by the counter table in [combat.md](combat.md) (Skirmisher > Bowman, Bowman > Shieldbearer, Shieldbearer > Outrider, Outrider > Bowman/Skirmisher/Mangonel, Mangonel > buildings). Weights are integers in permille from `personalities.ron`; the choice is the highest weight with the lowest `UnitKindId` as the tie-break. +### As built (M6 slice 2) + +**Where the maps live.** Not in the sim: `crates/ai/src/standard/influence.rs` builds them from the `SimView` at most once per plan, only when a rule asks (`Ctx::influence`), and drops them with the plan, so they are a pure function of hashed state like the rest of the plan's facts, need nothing on restore, and a rebuilt bot reads the same grids ([../DETERMINISM.md](../DETERMINISM.md), "Derived caches"). Cells are `influence_cell_tiles` (4) a side, 32 x 32 on `plains_1v1`. Each cell sums the current hit points and the damage per second times ten (`100 x damage / cooldown_ds`, with the owner's Arms rows, through a per-plan `(owner, kind)` cache) of three layers: the bot's own armed units, the enemy's armed units (through `facts::visible_enemies`, the fog seam) and the enemy's complete shooting buildings (Towns and Watchtowers). The strength of a block of cells (`influence_radius_cells` 1, so 3 x 3 cells) is Lanchester's `sum(hp) x sum(dps_x10)` in `i64`, and an integer square root turns it into an army scale where a score needs one. A plan that needs none of the three readers builds nothing; `bench --bots` counts the plans that did (`influence_plans=`). + +**Retreat (rule 7).** `retreat.rs` runs after the defence and before the build order. A wave out (staging or assaulting, not holding a ring, not in the all-in, with `retreat_ratio_permille` above 0) retreats when the strength of its living armed units is below `retreat_ratio_permille` (650) of the enemy units' strength in the 3 x 3 cells around its centroid (Towns and Watchtowers are left out, so a wave never flees from the building it is taking down), and it has lost at least `retreat_loss_permille` (300) of the armed units it launched with. It never retreats within `retreat_commit_ds` (20 s) of its assault order (a raid's launch), nor while its siege target is at or below `retreat_commit_town_hp_permille` (300) of its hit points. A retreat is one grouped `Move` of the wave's armed units and its Wain to the rally (not an `AttackMove`, so they disengage; at the rally they guard and re-acquire), ends the wave, counts in `BotMemory.retreats`, and holds the next launch for at least `retreat_cooldown_ds` (2 min). A wave that ends with fewer than `recall_below_units` (3) armed units left counts as wiped, so a wave that comes home with one or two survivors counts too (`waves::end`; `wave_ends_without_annexers` pins it). + +**Targets.** A siege wave scores each enemy Town by distance (1000 per tile) plus `target_strength_weight` (10) times the square root of the enemy strength around it, and takes the lowest; with `seat_last` it skips the Seat while the enemy holds another complete, uncontested Town, and the noise picks between the best two when they are within `noise_mix_permille` of each other. A raid scores the enemy's worked gathering buildings outside every enemy shooter's reach: `raid_worker_weight` (100) per worker there, less the square root of the enemy units' strength near it, less `raid_distance_permille` of its distance from the rally; with none it walks to the nearest enemy-owned tile out of reach. + +**Counters.** `compose.rs` weights the personality's `army_mix` by the visible enemy armed kinds and the `units.ron` counter table (`adv(k) = sum_e share_e x (c(k, e) - c(e, k)) / 1000`, factor `1000 + weight x adv / 1000` clamped to 250..4000 per mille), and a `counter_kinds` entry (the Outrider, the one counter to Mangonels) joins the mix while its factor reaches `counter_join_permille` (1500). With no visible enemy army the mix is the personality's own. Deficits are then trained as in M5b, ties to the lowest `UnitKindId`. + ## play-bots harness `sim-cli play-bots` runs seeded bot-versus-bot matches headlessly, one game per seed, seeds in parallel threads (`--jobs N`), each game fully independent, and prints one CSV row per game. It is the tool behind every AI acceptance line and the M6 fun gate. @@ -122,7 +306,7 @@ CSV columns: | `age_reached_tick` | tick at which player A reached the Masonry Age, or empty | | `first_attack_tick` | tick of player A's first `AttackMove` into enemy borders, or empty | -Additional flags: `--faction-a`/`--faction-b` (one faction in v0.1), `--personality-a`/`--personality-b` (comma-separated list cycled across seeds). A `--summary` mode printing win rates by personality is a `good first issue`. CI runs five seeded games on ubuntu from M5b and asserts that two runs of seed 7 print identical hashes on both ubuntu and macos from M6. +Additional flags: `--faction-a`/`--faction-b` (one faction in v0.1), `--personality-a`/`--personality-b` (comma-separated lists; since M6 two lists of several entries walk the ordered distinct pairs, below, and a single list is cycled across seeds). A `--summary` mode printing win rates by personality is a `good first issue`. CI runs five seeded games on ubuntu from M5b and asserts that two runs of seed 7 print identical hashes on both ubuntu and macos from M6. ### As built (M5b) @@ -143,7 +327,7 @@ The CSV goes to stdout in seed order and holds no wall time, so two runs of the | `rejected_a`, `rejected_b` | `CommandRejected` events by player | | `second_town_tick_b`, `tower_tick_b`, `bo_done_tick_b` | B's second Town, Watchtower and build order | -`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. The Standard bot attack-moves into enemy land only at a wave's assault: its stage point lies off enemy land with a margin for the group's spread, its defence attack-moves to threats on its own land, it clears defenders and focuses siege with `Attack`, holds the ring with `Move`, and recalls to its own rally. So the first such tick is the first assault order of any wave, in which every armed unit of that wave attack-moves to the target Town and its Wain, if it still has one, moves to the Wain point, all applied in one tick, and the units and Wains committed at that tick are that wave's. A wave that ends before its assault does not count: in Standard vs Standard at seed 1 (the `m5_first_match` game) P0's first wave clashes with P1's on the way and ends before its assault, and `first_attack_tick` 16993 is its second wave's assault, whose Wain was already lost (`first_attack_units` 8, `first_attack_wains` 0). Against Passive nothing stops the first wave, so there the column is the first wave's assault. +`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. At the M5b close the Standard bot attack-moved into enemy land only at a wave's assault: its stage point lies off enemy land with a margin for the group's spread, its defence attack-moves to threats on its own land, it clears defenders and focuses siege with `Attack`, holds the ring with `Move`, and recalls to its own rally. So the first such tick is the first assault order of any wave, in which every armed unit of that wave attack-moves to the target Town and its Wain, if it still has one, moves to the Wain point, all applied in one tick, and the units and Wains committed at that tick are that wave's. A wave that ends before its assault does not count: at the M5b close (`rules_version` 24), in Standard vs Standard at seed 1 (the `m5_first_match` game) P0's first wave clashed with P1's on the way and ended before its assault, and `first_attack_tick` 16993 was its second wave's assault, whose Wain was already lost (`first_attack_units` 8, `first_attack_wains` 0). Against Passive nothing stops the first wave, so there the column is the first wave's assault. Since M6 slice 2 a raid (rush, until it has a siege unit) and the difficulty's deadline raid (`first_raid`: Standard by 15:00, Hard by 10:00) issue one `AttackMove` straight onto enemy land with no stage, so for them `first_attack_tick` is the raid's launch and `first_attack_units` and `first_attack_wains` count the raiders, with no Wain; the `raids_a` and `raids_b` columns say whether one went out. At `rules_version` 28, seed 1 (Standard vs Standard, no raid) is a staged siege wave's assault at tick 18038 with 8 units and 1 Wain, and rush's first attack against Passive (tick 5738 at seed 1) is a raid's launch. At `rules_version` 30 seed 1's first assault is at tick 28438 with 20 units and 1 Wain. stderr gets one line per game as it ends, `game seed=1 ticks=… wall_s=… step_ms_mean=… ai_ms_mean=…` (`ai_ms_mean` is the time inside the bots' `think` per tick), and a summary, `play-bots: games=5 jobs=5 wall_s=… a=… b=… draw=… none=… panic=… decisive=… tiebreak=…`. The exit code is 0 when every game has a declared winner (`a` or `b`), 1 when any row is `draw`, `none` or `panic` (with `--allow-undecided` only a panic exits 1), and 2 for usage, rules or bot-id errors. `sim-cli record --scenario m5_first_match --seed N` records Standard against Standard at seed N to its outcome, and seed 1 is the `m5_first_match` golden. @@ -151,6 +335,36 @@ stderr gets one line per game as it ends, `game seed=1 ticks=… wall_s=… step `sim-cli bench --units 400 --ticks 1200 --bots` measures what the bots add per tick (ROADMAP M5b's third line): two Standard seats at Masonry with 2000 of each resource and 200 units a side (half at the own rally with 2 Mangonels and a Supply Wain, half 12 tiles north of the enemy Seat, on its land), a tick-0 stream at sequence numbers from `1 << 30`. It prints `ai_ms` (the time inside `think` per tick: mean, p95, max, the plans, the slowest plan and the commands), the whole step against `control_step_ms` (the same stream with Passive seats), and `waves_launched`, `defends`, `engaged`, `rejected`, `path_queue_max` and the final hash, and exits 1 only when the bots were not exercised (fewer than 200 plans, 2 waves or 1 defence), never on the budget. +### As built (M6 slice 1) + +`sim-cli play-bots --seeds [--a ] [--b ] [--personality-a ] [--personality-b ] [--mirrors] [--max-ticks ] [--jobs ] [--allow-undecided]`: + +- **Bots.** `--a` and `--b` take `passive` or any difficulty id (`easy`, `standard`, the default, `hard`), checked against `ai::bot_ids(rules)`; an unknown id exits 2 with the list (`unknown bot "nightmare" (known: passive, easy, hard, standard)`). +- **Pairing** (M6 owner decision 4). When both personality lists have more than one entry, the games walk the ordered distinct pairs, B outer and A inner, same ids skipped, repeating over the seeds: `rush,boom,tower` on both sides plays (boom,rush), (tower,rush), (rush,boom), (tower,boom), (rush,tower), (boom,tower), so seeds 1..20 give the first two pairs 4 games and the rest 3, and every pairing appears from both seats (ai.md's and PLAYTEST.md's "distinct personalities"). `--mirrors` adds the same-id pairs (all nine, A inner); mirrors measure seat bias, not fun. When either list has one entry, each list cycles over the seeds on its own, as in M5b. Two lists that name no distinct pair are a usage error that suggests `--mirrors`. +- **`random`.** A personality id `random` resolves per seed and seat with `ai::resolve_personality(rules, seed, player)`: a pick from the personalities with `random: true` in `personalities.ron`, by `sim::ai_noise` (never the sim's `Pcg32`), written into the header so a replay never resolves it again. Slice 1 shipped no personality in the pool; since slice 2 the pool is rush, boom and tower, and `random` is a usage error only with an empty pool. Adding a personality to the pool remaps the seeds. +- **Exit codes and stderr** are M5b's. + +The CSV keeps M5b's 28 columns first and in order, then appends 26 (`ai::harness::M6_COLUMNS`), each from the header, the bots' hashed memory, events or `sim::MatchStats` samples taken on whole seconds of match time, so two runs still `diff` empty at any `--jobs`: + +| Column | Meaning | +| --- | --- | +| `bot_a`, `personality_a`, `bot_b`, `personality_b` | the header's seats (`random` already resolved; a `passive` seat has an empty personality) | +| `waves_a`, `waves_b`, `raids_a`, `raids_b`, `retreats_a`, `retreats_b`, `wiped_a`, `wiped_b` | from `ai::bot_stats` (the memory, read outside `think`); empty for a `passive` seat. Slice 2 writes the raids, retreats and wiped waves (a wave that ended with fewer than `recall_below_units`, 3, armed units) | +| `towns_taken_a`, `towns_taken_b` | `TownAnnexed` to the side | +| `bonus_a`, `bonus_b` | total bonus resources received (`BonusIncome`), every resource | +| `income_a`, `income_b` | total credited gathering and base yields in whole resources, from the once-a-second samples of `RateInfo::credited` (not the bonus) | +| `cap_s_a`, `cap_s_b` | seconds with any resource at its Yield Cap (`RateInfo::at_cap`, the HUD's amber readout) | +| `first_contact_tick` | the first tick a hit landed between the two players' entities | +| `max_quiet_ticks` | the longest span with no cross-player hit after first contact, up to the outcome; empty before any contact | +| `first_on_land_tick_a`, `first_on_land_tick_b` | the first tick an armed unit of the side stood on the other's land ("reached the borders") | +| `min_own_town_hp_permille_a`, `_b` | the lowest hit points, per mille, of any complete Town the side started with or founded, while it owned it; 0 once one was annexed from it; Towns it annexed never count (a flipped Town comes back at 250), so the loser's pressure shows; 1000 when untouched | + +`sim-cli match-log [--date D] [--commit C]` re-simulates a replay with the bots its header names, through `sim::verify_observed` (the `verify_with` order: version, rules, `controller_for`, every recorded hash), feeds `MatchStats` after every step, and prints the docs/PLAYTEST.md match-log row (`| | | | |