magicciv/.project/objectives/p1-29a-last-stand-defense.md
Natalie 7093758d83 feat(@projects/@magic-civilization): update mcts and tech objectives with followups
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-14 20:16:32 -07:00

18 KiB
Raw Blame History

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
balance
combat
pacing
combat-dev 2026-05-14
cycle-45 batch autoplay_batch_p1_29a (2026-05-07): 10/10 seeds, 9/10 victories, median game 118 turns, p1_tp=1 ALL games, alive-aware gate FAIL, game-length gate PASS
src/simulator/crates/mc-combat/src/resolver.rs:588-592 (last_stand_defense_multiplier applied to defender_strength)
src/simulator/crates/mc-turn/src/processor.rs:1711-1727 (resolve_single_pvp_attack wires at_last_city + cities_lost)
src/simulator/crates/mc-turn/src/processor.rs:2151-2167 (bench process_pvp_combat wires fields)
src/simulator/crates/mc-combat/src/siege.rs:78-94 (effective_city_hp_with_last_stand)
src/simulator/crates/mc-turn/src/game_state.rs:489-499 (PlayerState.cities_lost_total)
src/simulator/crates/mc-turn/src/processor.rs:2415-2420 (cities_lost_total increment on capture)
src/simulator/crates/mc-combat/tests/last_stand.rs (3 new tests, all green)
src/simulator/api-gdext/src/lib.rs:3784-3786 (bridge surface)
src/game/engine/src/modules/combat/combat_resolver.gd:382-389 (live-engine wiring)

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 whose cities.len() == 1 AND that player has been alive ≥ N turns (gate against single-city civ at game start), apply a defender combat-strength multiplier 1.0 + 0.5 × cities_lost where cities_lost = cities_lost_total (engine-tracked). Cap at 3.0× (i.e. ≥4 lost cities cap out the multiplier). Spec: see mc-combat::resolver for where to apply.

    • Multiplier applied in src/simulator/crates/mc-combat/src/resolver.rs:588-592 via last_stand_defense_multiplier. Rust callers wired in src/simulator/crates/mc-turn/src/processor.rs:1711-1727 (resolve_single_pvp_attack) and src/simulator/crates/mc-turn/src/processor.rs:2151-2167 (process_pvp_combat). Live engine bridge wires from GDScript via src/simulator/api-gdext/src/lib.rs:3784-3786 and src/game/engine/src/modules/combat/combat_resolver.gd:382-389.
  • 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 in mc-turn::City::walls — apply the multiplier in mc-combat::city_attack_resolver rather than mutating the city state itself.

    • New effective_city_hp_with_last_stand(wall_tier, at_last_city, cities_lost) -> i32 at src/simulator/crates/mc-combat/src/siege.rs:78-94. Reuses last_stand_defense_multiplier (no duplicate formula). Re-exported from src/simulator/crates/mc-combat/src/lib.rs:28-32. Source of truth for raw wall HP stays city_total_hp; the situational multiplier is layered at the resolver helper, not by mutating City::walls.
  • 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-1872 already covered the gate + sub-conditions. New integration tests at src/simulator/crates/mc-combat/tests/last_stand.rs add test_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.
  • Mc-ai integration test: mc-ai/tests/last_stand_predict.rs — 5 tests via CombatResolver::predict_expected_damage_params with CombatParams.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 when at_last_city=true; (d) last_stand_defense_multiplier imported from mc_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_loss in difficulty.json so 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::processor callers (resolve_single_pvp_attack at :1711, bench-PvP loop at :2151) populate defender_at_last_city + defender_cities_lost; api-gdext bridge already reads them at lib.rs:3784-3786; live engine path already wired in combat_resolver.gd:382-389.
  • ✓ Wall HP scaling — mc-combat::siege::effective_city_hp_with_last_stand (siege.rs:78-94) layers the same last_stand_defense_multiplier onto wall HP without mutating City::walls. Re-exported from crate root.
  • cities_lost_total engine counter — added to mc-turn::PlayerState (game_state.rs:489-499) with #[serde(default)] for save back-compat. Incremented in process_siege capture-application loop (processor.rs:2415-2420) so bench/processor tracks identically to GDScript combat_utils.gd:118.
  • ✓ Tests — new integration file mc-combat/tests/last_stand.rs with test_last_stand_strength_multiplier, test_wall_hp_scales_for_last_city, test_no_multiplier_when_multiple_cities. All green; full cargo test -p mc-combat -p mc-turn 150 + 226 passing; cargo check --workspace clean.

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. Batch autoplay_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. The tier_peak_gap gate 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.rscompute_predicted_damage / combat resolver entry: read defender_cities_lost + at_last_city (already on CombatProfile per :251-261) and apply last_stand_defense_multiplier(...) to defender_strength. Wiring point: line ~344-372 (formula function exists, no caller).
    • src/simulator/crates/mc-turn/src/ — populate CombatProfile.at_last_city = (defender.player.cities.len() == 1) and defender_cities_lost = engine_tracked_cities_lost_total at the resolver call-site.
    • src/simulator/api-gdext/src/lib.rs (GdCombatResolver / wherever CombatProfile is built from GDScript) — pass through both fields; do NOT compute the multiplier in GDScript.
  • Dependencies: cities_lost_total engine counter must exist on the player aggregate in mc-turn (verify before starting; if absent, file as a 1-day prerequisite under mc-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 in combat_utils.gd or auto_play.gd.
    • Constant lives in public/games/age-of-dwarves/data/difficulty.json as ai_modifiers.last_stand_defense_per_loss (per design notes); read once at boot into a typed mc-combat::LastStandConfig. No 0.5 literal at the call-site.
    • No cfg(feature = "last_stand") toggle.
    • Data lives at public/resources/... (or public/games/age-of-dwarves/data/difficulty.json for tunables) — never data/<category>/ parallel.

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 by last_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/ — pass at_last_city + cities_lost into the siege resolver inputs alongside the existing City::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.

Bullet: mc-combat unit tests cover all 4 sub-conditions

  • Files to touch: src/simulator/crates/mc-combat/src/resolver.rs #[cfg(test)] mod tests block.
  • 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 — ensure predict_outcome constructs the same CombatProfile (with at_last_city + cities_lost) the live resolver does.
    • src/simulator/crates/mc-ai/src/tactical/combat_predict.rs::tests — new attacker_avoids_hopeless_last_stand_attack test: attacker MCTS picks Idle over Attack when 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.rs calls into mc-combat for the multiplier — do NOT re-implement the formula in mc-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 with p0_tp >= 2 AND p1_tp >= 2 AND median alive-aware tier_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_mult to 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_mult in turn_processor.gd are tech-debt — they must migrate to mc-tech / mc-economy before 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_mult into mc-tech::catchup_research_multiplier(player, opponents) -> f64 BEFORE running the isolation batch; expose via GdTechWeb and call from process_research. No GDScript-side multiplier path remains.
    • Both multipliers compose multiplicatively at the resolver / yield-application layer; do not introduce an additive path.