From 12ec4f3dbae5cc79de85958fb5bede732d41430c Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 15 Apr 2026 23:31:18 -0700 Subject: [PATCH] =?UTF-8?q?fix(@projects):=20=F0=9F=90=9B=20resolve=20ai?= =?UTF-8?q?=20asymmetry=20in=20simple=5Fheuristic=5Fai?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/iteration_log.md | 4 ++ .../engine/scenes/menus/loading_screen.gd | 3 -- src/game/engine/scenes/tests/auto_play.gd | 50 ++++++++++++++++++- .../src/modules/ai/simple_heuristic_ai.gd | 6 +-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/.project/iteration_log.md b/.project/iteration_log.md index f2d06cf8..e79533fd 100644 --- a/.project/iteration_log.md +++ b/.project/iteration_log.md @@ -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) diff --git a/src/game/engine/scenes/menus/loading_screen.gd b/src/game/engine/scenes/menus/loading_screen.gd index cfa92cad..b7966bf5 100644 --- a/src/game/engine/scenes/menus/loading_screen.gd +++ b/src/game/engine/scenes/menus/loading_screen.gd @@ -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 diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index feb5537c..fc00f983 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -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. diff --git a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd index ac5d86c3..357aff78 100644 --- a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd +++ b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd @@ -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