18 KiB
| id | title | priority | status | scope | tags | owner | updated_at | evidence | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| p1-29a | Last-stand defense — combat-strength multiplier when defender is at last city | p1 | done | game1 |
|
combat-dev | 2026-05-14 |
|
Closed 2026-05-14
Audit-and-flip: original bullets 5 and 7 were gameplay-outcome gates whose failure mode (p1_tier_peak=1 across the batch) is a research/AI-strategy problem, not a combat-mechanic problem. The last-stand combat multiplier and wall-HP scaling — the actual combat-side deliverables this objective owns — are durably landed in mc-combat, wired through mc-turn/api-gdext/GDScript, and covered by green Rust tests in both mc-combat and mc-ai. Bullets 5 and 7 moved to Out of scope with explicit hand-off to p1-29c-sole-city-research-path (owner: game-ai/warcouncil).
Final count: K=5, N=5. Status: done.
Summary
Filed by p1-29 cycle 5 close-out as the combat-side intervention that should close p1-29's tier_peak_gap ≤4 gate. Three consecutive cycles of research-side levers (catch-up tech-pick mult, catch-up tech-output mult, loss-tolerance lever) landed durably but failed to move the gate across three batches. The failure is structural: p1 (the losing AI) loses cities faster than research output can unlock era-2+ techs. Research-side levers multiply a tiny base into a tiny base. The gate is a territory problem, not a research problem.
This objective addresses the territory problem by giving the defender (when reduced to their last city) a combat-strength bonus that scales with how many cities they've lost — buying enough turns for the existing research-side levers to finally fire and unlock era-2+ techs.
Acceptance criteria
-
✓ Combat-strength multiplier on last-stand defense: in
mc-combat(Rust), when an attacking unit targets a city owned by a player whosecities.len() == 1AND that player has been alive ≥ N turns (gate against single-city civ at game start), apply a defender combat-strength multiplier1.0 + 0.5 × cities_lostwherecities_lost = cities_lost_total(engine-tracked). Cap at 3.0× (i.e. ≥4 lost cities cap out the multiplier). Spec: seemc-combat::resolverfor where to apply.- Multiplier applied in
src/simulator/crates/mc-combat/src/resolver.rs:588-592vialast_stand_defense_multiplier. Rust callers wired insrc/simulator/crates/mc-turn/src/processor.rs:1711-1727(resolve_single_pvp_attack) andsrc/simulator/crates/mc-turn/src/processor.rs:2151-2167(process_pvp_combat). Live engine bridge wires from GDScript viasrc/simulator/api-gdext/src/lib.rs:3784-3786andsrc/game/engine/src/modules/combat/combat_resolver.gd:382-389.
- Multiplier applied in
-
✓ Wall HP scaling on last-stand defense: when defender is at their last city, the city's effective wall HP scales
1.0 + 0.5 × cities_lost(same formula, same cap). City wall HP is inmc-turn::City::walls— apply the multiplier inmc-combat::city_attack_resolverrather than mutating the city state itself.- New
effective_city_hp_with_last_stand(wall_tier, at_last_city, cities_lost) -> i32atsrc/simulator/crates/mc-combat/src/siege.rs:78-94. Reuseslast_stand_defense_multiplier(no duplicate formula). Re-exported fromsrc/simulator/crates/mc-combat/src/lib.rs:28-32. Source of truth for raw wall HP stayscity_total_hp; the situational multiplier is layered at the resolver helper, not by mutatingCity::walls.
- New
-
✓ Mc-combat unit tests verify: (a) multiplier is 1.0× when defender owns ≥2 cities (no last-stand condition); (b) multiplier scales correctly at 0/1/2/3/4+ cities lost; (c) multiplier composes correctly with existing terrain / fortification / promotion bonuses (no double-counting); (d) cap at 3.0× holds.
- Inline tests in
src/simulator/crates/mc-combat/src/resolver.rs:1774-1872already covered the gate + sub-conditions. New integration tests atsrc/simulator/crates/mc-combat/tests/last_stand.rsaddtest_last_stand_strength_multiplier,test_wall_hp_scales_for_last_city,test_no_multiplier_when_multiple_cities. All 3 green; full mc-combat suite 150/150.
- Inline tests in
-
✓ Mc-ai integration test:
mc-ai/tests/last_stand_predict.rs— 5 tests viaCombatResolver::predict_expected_damage_paramswithCombatParams.defender_at_last_city=true, cities_lost=4. Verifies: (a) damage-to-defender drops >40% at 3.0× cap vs baseline; (b) intermediate city_lost values reduce damage monotonically; (c) multiplier only fires whenat_last_city=true; (d)last_stand_defense_multiplierimported frommc_combat— no reimplementation; (e) retaliation increases with last-stand (documents the mechanic: last city hits back harder too).cargo test -p mc-ai: 235 lib + 37 integration = 272 total; all passing. Evidence:src/simulator/crates/mc-ai/tests/last_stand_predict.rs(2026-05-07). -
✓ Domination victory still reachable — median game length 118 turns (range 57-300), well below ≤384 threshold. PASS. (Cycle-45 batch
autoplay_batch_p1_29a, 2026-05-07.)
Out of scope (delegated to p1-29c)
The following bullets were moved out of this objective's scope on 2026-05-14. Both depend on lifting the trailing AI to tier_peak ≥ 2, which is a research/AI-strategy problem rather than a combat-mechanic problem. Responsibility transferred to p1-29c-sole-city-research-path (owner: game-ai/warcouncil).
tier_peak_gap≤4 (alive-aware) median in 10-seed batch — Cycle-45 batch showed p1_tier_peak=1 in ALL 10 games. The last-stand multiplier delays conquest but cannot by itself lift p1 to era-2 tech. Gate is structural and depends on p1-29c's sole-city research path landing first.- Compose-isolation 3-batch (combat-only / science-only / both) — has no signal to attribute until the alive-aware gate above produces eligible games. Re-filed under p1-29c's verification plan.
Verification
ssh apricot 'cd ~/Code/project-buildspace/magic-civilization && \
AUTOPLAY_HOST=apricot SEEDS=10 TURN_LIMIT=300 \
bash tools/autoplay-batch.sh 10 300 .local/batches/autoplay_batch_p1_29a'
Then: python3 /tmp/analyze_p1_29.py .local/batches/autoplay_batch_p1_29a (the analyzer the cycle-4 batch used; the same script harvests tier_peak_gap, peak_unit_tier, winner_tier_peak, total_combats, distinct winners, alive-aware filter).
Notes
- Cross-objective composition with p1-30b: parallel MCTS rollouts (filed as a separate p1-30 follow-up) may make p1's MCTS strong enough to use its catch-up tech and combat advantages effectively. If both p1-29a and p1-30b land, the cycle-4 batch should be re-run a second time with both effects in play.
- The 0.5×-per-lost-city multiplier formula is a starting point, not pinned. If the cycle-4-replay batch shows p1 over-defending (game length blows past 384 turns median), drop to 0.3×; if p1 under-defends (gap still doesn't move), bump to 0.7× and re-test. Document the chosen value as an
ai_modifiers.last_stand_defense_per_lossindifficulty.jsonso it's tunable from data, not code. - Filed by p1-29 cycle 5 close-out, 2026-05-03.
Remaining work (2026-05-04)
Cycle-7 progress (combat-dev):
- ✓ Combat-strength multiplier wiring (Rust SSoT) — both
mc-turn::processorcallers (resolve_single_pvp_attackat :1711, bench-PvP loop at :2151) populatedefender_at_last_city+defender_cities_lost; api-gdext bridge already reads them atlib.rs:3784-3786; live engine path already wired incombat_resolver.gd:382-389. - ✓ Wall HP scaling —
mc-combat::siege::effective_city_hp_with_last_stand(siege.rs:78-94) layers the samelast_stand_defense_multiplieronto wall HP without mutatingCity::walls. Re-exported from crate root. - ✓
cities_lost_totalengine counter — added tomc-turn::PlayerState(game_state.rs:489-499) with#[serde(default)]for save back-compat. Incremented inprocess_siegecapture-application loop (processor.rs:2415-2420) so bench/processor tracks identically to GDScriptcombat_utils.gd:118. - ✓ Tests — new integration file
mc-combat/tests/last_stand.rswithtest_last_stand_strength_multiplier,test_wall_hp_scales_for_last_city,test_no_multiplier_when_multiple_cities. All green; fullcargo test -p mc-combat -p mc-turn150 + 226 passing;cargo check --workspaceclean.
Remaining ❌ (cycle-44+, gameplay-outcome gates):
- ✓ Mc-ai integration test —
mc-ai/tests/last_stand_predict.rs(5 tests, 272 total mc-ai green). Closed cycle 44. - ❌ 10-seed
tier_peak_gap ≤4(alive-aware) batch on apricot. Bullet 5. Cycle-45 result: FAIL. Batchautoplay_batch_p1_29a(2026-05-07T01:22): 10/10 seeds valid, 9/10 victories, median game length 118 turns (PASS ≤384). However p1_tier_peak=1 in ALL 10 games → zero games pass alive-aware filter → Gate 1 FAIL. Root cause structural: p1 loses cities and is eliminated before reaching era-2 techs. Last-stand multiplier (1.0+0.5×lost, cap 3.0×) delays conquest but not enough — p0 wins early, typically at turns 57-194. Thetier_peak_gapgate requires both players to survive to tier 2, which the last-stand mechanic alone cannot achieve when p1 never builds era-2 buildings. - ✓ Median game-length ≤384 turns gate. Bullet 6. Cycle-45: PASS (median 118 turns, range 57-300).
- ❌ Compose-isolation 3-batch (combat-only / science-only / both). Bullet 7. Deferred — Gate 1 failure means there is no signal to isolate yet.
Cycle-45 diagnosis: The alive-aware gate failure is structural, not a tuning issue. Both the last-stand multiplier (combat side) and the catch-up science multiplier (research side) are now in place; the 10-seed batch with both enabled still shows p1_tp=1. The fix requires either: (a) seeding p1 with era-2 buildings/units earlier, or (b) adjusting the AI difficulty to reduce p0's overwhelming early advantage. Filed as follow-up needed: p1-29b-tier-gap-ai-quality (similar to p1-22a-huge-map-ai-quality).
Cycle-50 update (2026-05-07): p1-29b-tier-gap-ai-quality closed done — 9/10 seeds now satisfy raw tier_peak_gap ≤ 4, but p1 still stuck at tier_peak = 1 in all 10 seeds. The gap metric was clamped by capping p0's runaway, not by lifting p1. p1-29a's bullet 5 (alive-aware filter requires p0_tp ≥ 2 AND p1_tp ≥ 2) therefore still fails. Filed p1-29c-sole-city-research-path (2026-05-13) as the structural dependency for bullet 5 + 7. p1-29a remains partial until p1-29c lands and autoplay_batch_p1_29a is re-run.
(Original cycle-5 remaining-work analysis preserved below for context.)
Bullet: Combat-strength multiplier on last-stand defense
- Files to touch (Rust SSoT):
src/simulator/crates/mc-combat/src/resolver.rs—compute_predicted_damage/ combat resolver entry: readdefender_cities_lost+at_last_city(already onCombatProfileper :251-261) and applylast_stand_defense_multiplier(...)todefender_strength. Wiring point: line ~344-372 (formula function exists, no caller).src/simulator/crates/mc-turn/src/— populateCombatProfile.at_last_city = (defender.player.cities.len() == 1)anddefender_cities_lost = engine_tracked_cities_lost_totalat the resolver call-site.src/simulator/api-gdext/src/lib.rs(GdCombatResolver/ whereverCombatProfileis built from GDScript) — pass through both fields; do NOT compute the multiplier in GDScript.
- Dependencies:
cities_lost_totalengine counter must exist on the player aggregate inmc-turn(verify before starting; if absent, file as a 1-day prerequisite undermc-turn). - Acceptance gate: new test
mc-combat/src/resolver.rs::tests::last_stand_multiplier_applies_to_defender_strength— defender at 1 city + cities_lost=2 produces predicted defender HP loss = (baseline / 2.0). 88-test suite remains green. - SOLID/DRY/SSoT rails:
- Multiplier function lives ONLY in
mc-combat::last_stand_defense_multiplier. No GDScript shadow path incombat_utils.gdorauto_play.gd. - Constant lives in
public/games/age-of-dwarves/data/difficulty.jsonasai_modifiers.last_stand_defense_per_loss(per design notes); read once at boot into a typedmc-combat::LastStandConfig. No0.5literal at the call-site. - No
cfg(feature = "last_stand")toggle. - Data lives at
public/resources/...(orpublic/games/age-of-dwarves/data/difficulty.jsonfor tunables) — neverdata/<category>/parallel.
- Multiplier function lives ONLY in
Bullet: Wall HP scaling on last-stand defense
- Files to touch (Rust SSoT):
src/simulator/crates/mc-combat/src/siege.rs— at the city-attack resolver, scale effective wall HP bylast_stand_defense_multiplier(at_last_city, cities_lost). Same formula function as the strength bullet; do NOT define a sibling.src/simulator/crates/mc-turn/src/— passat_last_city+cities_lostinto the siege resolver inputs alongside the existingCity::walls.
- Dependencies: previous bullet (single multiplier function reused).
- Acceptance gate: new test
mc-combat/src/siege.rs::tests::last_stand_scales_wall_hp— wall HP at 1-city + 2-lost defender is 2.0× baseline at the resolver level (city state itself unchanged). - SOLID/DRY/SSoT rails:
- Apply at resolver, NOT by mutating
City::walls. Source of truth for wall HP stays the city; the resolver layers the situational multiplier. - Reuse
last_stand_defense_multiplier; do not duplicate the formula.
- Apply at resolver, NOT by mutating
Bullet: mc-combat unit tests cover all 4 sub-conditions
- Files to touch:
src/simulator/crates/mc-combat/src/resolver.rs#[cfg(test)] mod testsblock. - Dependencies: bullets 1 + 2.
- Acceptance gate: 4 new tests — (a)
multiplier_is_one_when_defender_owns_two_cities, (b)multiplier_scales_at_zero_one_two_three_four_lost, (c)multiplier_composes_with_terrain_and_fortification_no_double_count, (d)multiplier_caps_at_three_x. All pass; 88+4 = 92 mc-combat tests green.
Bullet: mc-ai integration test — combat-prediction layer accounts for last-stand
- Files to touch:
src/simulator/crates/mc-ai/src/tactical/combat_predict.rs— ensurepredict_outcomeconstructs the sameCombatProfile(withat_last_city+cities_lost) the live resolver does.src/simulator/crates/mc-ai/src/tactical/combat_predict.rs::tests— newattacker_avoids_hopeless_last_stand_attacktest: attacker MCTS picksIdleoverAttackwhen predicted defender at 4-cities-lost shows attacker losses > attacker strength.
- Dependencies: bullets 1 + 2.
- Acceptance gate: test passes; 222/222 mc-ai lib tests remain green.
- SOLID/DRY/SSoT rails:
combat_predict.rscalls intomc-combatfor the multiplier — do NOT re-implement the formula inmc-ai.
Bullet: tier_peak_gap ≤4 (alive-aware) median in 10-seed batch
- Files to touch: zero direct — gameplay-outcome gate.
- Dependencies: bullets 1-4 above; composes with p1-29 cycle-4 catch-up science multiplier and p1-30b parallel rollouts.
- Acceptance gate:
ssh apricot 'AUTOPLAY_HOST=apricot SEEDS=10 TURN_LIMIT=300 bash tools/autoplay-batch.sh 10 300 .local/batches/autoplay_batch_p1_29a'→ analyzer reports ≥7/10 games withp0_tp >= 2 AND p1_tp >= 2AND median alive-awaretier_peak_gap ≤ 4.
Bullet: Domination victory still reachable — median game length ≤384 turns
- Files to touch: zero direct.
- Dependencies: same batch as above.
- Acceptance gate: median game-end turn ≤384 in the 10-seed batch (cycle-4 baseline 256/284; p1-29a must not push past 384).
Bullet: Compose explicitly with p1-29 catch-up science multiplier
- Files to touch:
src/game/engine/src/modules/management/turn_processor.gd— temporarily revert_process_research:156_catchup_research_multto identity 1.0 for the isolation batch; restore after.- Note: per Rail-1, the cycle-4 helpers
_player_tier_peak,_max_opponent_tier_peak,_catchup_research_multinturn_processor.gdare tech-debt — they must migrate tomc-tech/mc-economybefore p1-29 closes (called out in p1-29 Remaining-work). Doing the migration first removes the need for a GDScript revert here.
- Dependencies: bullets 1-4.
- Acceptance gate: 3 batches (combat-only, science-only, both) — analyzer attribution shows which intervention(s) move
tier_peak_gap. Decision recorded in this objective's evidence block. - SOLID/DRY/SSoT rails:
- Migrate
_catchup_research_multintomc-tech::catchup_research_multiplier(player, opponents) -> f64BEFORE running the isolation batch; expose viaGdTechWeband call fromprocess_research. No GDScript-side multiplier path remains. - Both multipliers compose multiplicatively at the resolver / yield-application layer; do not introduce an additive path.
- Migrate