fix(@projects): 🐛 resolve ai asymmetry in simple_heuristic_ai

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-15 23:31:18 -07:00
parent 7af041b97b
commit 12ec4f3dba
4 changed files with 55 additions and 8 deletions

View file

@ -14,3 +14,7 @@
2026-04-16 04:05 iter 8 FINAL BATCH: 2/3 victories (66%, hits stop criterion numerically) BUT median turn=68.5 indicates OVERSHOT — siege buff made capture trivial, and p1 AI collapses in 2/3 seeds (0 cities, 0 mil in seed 2; 1 city lost turn 68 in seed 1; only seed 3 has functional p1). Victory rate is synthetic, not from "real 4X game". Need iter 9 to: (a) rebalance siege (wall penalty 0.85→0.80 midpoint), (b) fix enemy AI production loss (likely caused by add_to_queue bool-reject swallowing failed tech-gated attempts). Game is NOT 100% complete despite numerical metrics.
2026-04-16 04:45 iter 9: siege rebalance (walls penalty 0.85→0.80, castle 0.75→0.65, siege bonus 2.0→1.7) + simple_heuristic_ai production fix (can_build pre-filter, fallback military, emergency Priority 0 garrison, fixed MILITARY_COMBAT_TYPES never-matched bug where AoD uses unit_type:military not combat_type:melee). Seed 3 acceptance met (p1 3 cities 5 mil). Seeds 1-2 still end t68-69 because p0 uses auto_play's aggressive 14-factor scoring while p1 uses simple_heuristic_ai — AI asymmetry makes p0 dominate. iter 10: fix AI matchup so both players play same AI OR equalize auto_play aggression to simple_heuristic_ai pacing.
2026-04-16 05:49 iter 10 COMPLETE: AI MATCHUP PARITY. Root cause: p0 uses auto_play.gd (14-factor aggressive scoring, rush-buy), p1 uses simple_heuristic_ai.gd (passive, walls-first). Fix: added `_enemy_military_threat()` (in-range ≤8 + total-count) + `_rush_buy_defenders()` + production preemption + enemy-military-scaled mil_target to simple_heuristic_ai.gd. Trigger extended: rush-buy fires on `threatens_city OR total_count > our_mil`. Batch (3 seeds, 150t): median p1_cities=1 (was 0 in iter 9), seeds [0,1,2] p1_cities. Seed 1: t106 victory (vs t68 iter 9, +38t). Seeds 2+3: max_turns, p1 holds cities. Victories 1/3 (33%, down from 2/3 iter 9 but REAL games not collapses). 0 invariants. Files=1 (simple_heuristic_ai.gd, +87 net). Per CLAUDE.md AI exception, SimpleHeuristicAi stays GDScript. DEBT iter 11: seed 1 economic death spiral (pop 2↔3 oscillation, walls block 70t, late forge) — need food/growth or build-order fix; p0_pop_peak median=5 (target ≥8).
2026-04-16 06:15 task #4 STRATEGIC RESOURCES (resources-verify-dev): VERIFICATION task. Iter 8 #7 already shipped Rust QueueError::MissingResource + full wiring through api-gdext → city.gd.enqueue_item(available_resources) → auto_play/city_buildable_helper player_owns_resource gate. Only test coverage gap. Added GUT test `test_enqueue_rejects_when_strategic_resource_missing` in test_city_bridge.gd mirroring the Rust test via GdCity bridge (+45 lines, gdlint clean). `cargo test -p mc-city` 27/27 green. Smoke seed 1/150 victory t118; resource-gated units (cavalry, spearmen) not in AI candidate list so no runtime rejection logs but gate is wired. DEBT: ProductionFilter._unit_allowed() doesn't check requires_resource — theoretical bypass only; external callers filter first.
2026-04-16 06:25 task #6 LUXURY + FAUNA (resources-verify-dev): VERIFICATION task (two 4X checklist items bundled). Target A (luxury→happiness) FULLY WIRED: 25 luxury deposit JSONs at public/resources/deposits/ with category=luxury; mc-happiness pool.rs LUXURY_HAPPINESS=4; happiness.gd counts unique luxuries across player.cities[*].owned_tiles vs 22-id LUXURY_DEPOSITS const; smoke evidence iter10 seed1-3 p0 happiness varies 6-9 distinct values per seed, luxuries counted up to 2. Added GUT test `test_luxury_count_adds_happiness_via_rust` in test_happiness_turn.gd drives GdHappiness.calculate directly with 0 vs 2 luxuries asserting +8 delta (+26 lines, gdlint clean). Target B (fauna loot) RUST CORRECT (mc-combat/src/loot.rs 75/75 tests incl. 3 real-JSON integration tests for dire_wolf/frostfang_alpha/garden_snail) + GDScript wiring complete (item_system.gd:134 → combat_utils.gd:90 → EventBus.loot_dropped). BLOCKER: zero loot_dropped events in iter10 because `loading_screen.gd:79 TurnManager.set_wild_creature_ai(null)` means no wild creatures spawn in auto_play — every unit_destroyed is player-owned so owner==-1 gate never trips. Fix needs separate ticket (likely >50 lines, config+integration). Files touched=1 (test_happiness_turn.gd, +26 lines). `cargo test --workspace` all green on apricot.
2026-04-16 06:19 Task #1 POP GROWTH: FOOD_PER_POP 1.5→1.2 (mc-city/src/city.rs), seed 1 smoke p0_pop_peak 5→9, starvation events 6→1, outcome victory T106. cargo test 27/27 mc-city, workspace green. (pop-growth-dev)
2026-04-16 06:19 Task #6 LUXURY + FAUNA (split): Luxury happiness WIRED (mc-happiness/src/pool.rs LUXURY_HAPPINESS=4 + happiness.gd LUXURY_DEPOSITS counting); smoke confirms 6-9 distinct happiness values/seed + luxuries ≤2. Fauna loot pipeline proven correct (75 mc-combat tests incl. real-JSON integration for dire_wolf/frostfang_alpha/garden_snail) but ROOT-CAUSE BLOCKED: loading_screen.gd:79 passes null to TurnManager.set_wild_creature_ai → no wilds spawn in auto_play → owner==-1 gate never trips. Added test_luxury_count_adds_happiness_via_rust (+26 lines). (resources-verify-dev)

View file

@ -7,9 +7,6 @@ const WORLD_MAP_SCENE: String = "res://engine/scenes/world_map/world_map.tscn"
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const WildCreatureAIScript: GDScript = preload("res://engine/src/modules/ai/wild_creature_ai.gd")
const PersonalityAssignerScript: GDScript = preload(
"res://engine/src/modules/ai/personality_assigner.gd"
)
const TICK_DELAY: float = 0.04

View file

@ -1206,7 +1206,8 @@ func _decide_founder(unit: Variant, player: RefCounted, game_map: RefCounted) ->
func _command_worker(unit: Variant, player: RefCounted, game_map: RefCounted) -> void:
## Build an improvement on the current tile if possible; else walk toward a city.
## Build an improvement on the current tile if possible;
## else walk toward the nearest owned unimproved tile; else toward a city.
if _improvement_manager == null:
return
var buildable: Array[Dictionary] = _improvement_manager.get_buildable_improvements(
@ -1227,12 +1228,57 @@ func _command_worker(unit: Variant, player: RefCounted, game_map: RefCounted) ->
if not pick.is_empty():
_improvement_manager.start_improvement(unit, pick, player)
return
# Seek the nearest buildable tile within 4 hexes: owned or unclaimed, non-water,
# unimproved, no pending build. Unclaimed tiles work — they convert on build.
var best_target: Vector2i = Vector2i(-1, -1)
var best_dist: int = 9999
for dq: int in range(-4, 5):
for dr: int in range(-4, 5):
var tpos: Vector2i = unit.position + Vector2i(dq, dr)
var d: int = HexUtilsScript.hex_distance(unit.position, tpos)
if d == 0 or d > 4 or d >= best_dist:
continue
var t: Resource = game_map.get_tile(tpos)
if t == null or t.is_water() or str(t.improvement) != "":
continue
if t.owner != -1 and t.owner != player.index:
continue
if _improvement_manager.get_pending_at(tpos, player).size() > 0:
continue
best_dist = d
best_target = tpos
if best_target != Vector2i(-1, -1):
_worker_step_toward(unit, best_target, game_map)
return
if not player.cities.is_empty():
_move_toward(unit, player.cities[0].position, game_map)
_worker_step_toward(unit, player.cities[0].position, game_map)
else:
_explore(unit, player, game_map)
func _worker_step_toward(unit: Variant, target: Vector2i, game_map: RefCounted) -> void:
# Workers must step ONTO the target tile to build on it — do not use the
# attack-adjacent shortcut in _move_toward which skips movement entirely.
if unit.position == target:
return
var reachable: Dictionary = PathfinderScript.movement_range(
game_map, unit.position, unit.movement_remaining, "land"
)
if reachable.is_empty():
return
var best_pos: Vector2i = unit.position
var best_dist: int = HexUtilsScript.hex_distance(unit.position, target)
for pos: Vector2i in reachable:
if pos == unit.position:
continue
var dist: int = HexUtilsScript.hex_distance(pos, target)
if dist < best_dist:
best_dist = dist
best_pos = pos
if best_pos != unit.position:
_do_move(unit, best_pos, game_map)
func _nearest_city_to_target(player: RefCounted) -> RefCounted:
## Return the player's city closest to the locked attack target.
## Falls back to city[0] if no target is locked.

View file

@ -25,8 +25,8 @@ const FOUND_MIN_DIST_OWN: int = 4
## deadlock founders that spawned near each other (observed in arena
## smoke tests where start placement put both players on tile 0,0).
const FOUND_MIN_DIST_ENEMY: int = 1
const RETREAT_HP_FRACTION: float = 0.15
const DEFENSIVE_CHASE_RANGE: int = 8
const RETREAT_HP_FRACTION: float = 0.0
const DEFENSIVE_CHASE_RANGE: int = 12
const MILITARY_COMBAT_TYPES: Array[String] = [
"melee", "ranged", "cavalry", "siege",
]
@ -578,7 +578,7 @@ static func _decide_production(
# match enemy's FULL army when they're closing on us so we don't lose
# on parity once reserves arrive.
var enemy_mil: int = enemy_total if threatened else 0
var mil_target: int = maxi(4, city_count * 3)
var mil_target: int = maxi(2, city_count * 2)
if enemy_mil > 0:
mil_target = maxi(mil_target, enemy_mil + 1)
var want_military: bool = military_count < mil_target