feat(@projects/@magic-civilization): add siege combat and tech gates

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-15 22:28:21 -07:00
parent 21323a8f9c
commit b944058ddd
5 changed files with 206 additions and 67 deletions

View file

@ -9,3 +9,7 @@
2026-04-16 01:34 iter 6 COMPLETE: CITY SITING. Wired _score_site() into settler decisions via new _decide_settler(). 3-seed smoke (250-300t): p0 pop_peak 6/9/8 (iter 5: 2), first_pop_4 at 77/25/43, 0 invariants. Seed 1 no longer mountain-locked (founds at (25,-1) not (24,-3)). Found + fixed real root cause: _try_found_city() was the greedy founder, not _command_unit() settler branch (different call site). Also fixed 4 bugs in _score_site() (deep_ocean typo, wrong food-zero biomes incl. missing boreal_forest/volcano/snow, dead river branch, impassable not rejected). Files=1 (auto_play.gd, +80/-22). Terrain-auditor agent provided exact biome constants.
2026-04-16 01:55 iter 6 VERIFICATION: 2/3 victories (66%), median turn-to-victory 382, 0 invariants. STOP CRITERION #1 MET (1st consecutive success). Seed 2: domination t=382, p0 pop14, 94 kills. Seed 3: domination t=315, p0 pop11, 77 kills. Seed 1: max_turns t=400 (132 kills but 372 combats — siege attrition, no capture). Median p0_pop_peak=11. Running 2nd-consecutive confirmation batch now.
2026-04-16 02:30 iter 7 COMPLETE (5 parallel agents): All debt items from FINAL_BATCH_REPORT cleared. (1) Scout food bias: _explore() weights fog*2+food*3+settler_proximity. (2) Attack scoring: attack_score vs consolidate_score replaces prescriptive thresholds. (3) settler→founder rename across 6 files. (4) Hunting grounds improvement type (forest/tundra). (5) Happiness buildings: brewery→temple/colosseum (real buildings with real effects). Merged smoke seed 1 200t: pop 6, happiness -9 (improved from -15+), 63 combats, 0 invariants. Files=~10 across all 5 tasks.
2026-04-13 (task #10) TECH GATE VERIFICATION: JSON has tech_required on cavalry/spearmen/pikeman/wyvern_riders and 0 buildings (all null). Rust QueueError::TechLocked only guards item queue (enqueue_item), NOT building/unit queue. GDScript gap: city_buildable_helper populate_* called city.can_build() which did not exist → UI filter silently skipped via has_method guard. ProductionFilter defined but had zero callers (dead code). Auto_play _next_building has its own hardcoded tech_req map covering 4 buildings; candidate unit list is warrior/founder/worker only (none tech-gated), so no smoke-observable violation today but gate was hypothetical. FIX (city.gd +~16 lines, inlined _instantiate_gd_city & _parse_json_dict to stay ≤500): added City.can_build() delegating to ProductionFilter.is_unit_buildable/is_building_buildable; City.add_to_queue() now rejects gated items (returns bool). This wires the existing UI filter and closes the GDScript-path gap mirroring rust-resource-dev's pattern. DEBT: building/unit completion still GDScript-side; Rust-side enforcement symmetry with item queue (QueueError::TechLocked) remains future work.
2026-04-16 03:55 iter 8 COMPLETE (5 waves of agents, 10 tasks): VICTORY RATE IMPROVED (seed 1 dom victory t=94 from military-dev), plus 9 other gaps addressed. Tasks: #1 siege math (Rust mc-combat wall penalties + bug: city.city_hp→city.hp), #2 strategic resource filter (GDScript UI), #3 luxury tracking in player_stats, #5 RNG state serialization (RandomNumberGenerator.state), #6 parse errors (TechWeb stubs, null school_affinity, PackedFloat32Array), #7 Rust resource enforcement (mc-city production, 27 tests), #8 military sustain (auto_play stack+hysteresis), #9 happiness buildings verified + 2 new (ale_hall, bathhouse), #10 tech gate activation (ProductionFilter had zero callers — now wired), #4 fauna loot drops (with 7 subfixes including JSON float→u32). Test scaffold from #4 gated behind AUTO_PLAY_TEST_LOOT_SCAFFOLD env var to not bias normal batches.
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.

View file

@ -19,6 +19,7 @@ const ImprovementManagerScript = preload(
)
const HappinessScript = preload("res://engine/src/modules/empire/happiness.gd")
const ItemSystemScript = preload("res://engine/src/modules/management/item_system.gd")
const SaveManagerScript = preload("res://engine/src/core/save_manager.gd")
var _improvement_manager: RefCounted = null
@ -38,6 +39,10 @@ var _last_army_pos: Vector2i = Vector2i(-1, -1)
# Attack commitment: while > 0 we stay in ATTACK. Scoring should flip less
# often than raw thresholds, so 5 turns is enough hysteresis.
var _attack_commitment_turns: int = 0
# Stack sustain tracking — count of own military within 8 hex of _locked_target.
# Recomputed each turn during _play_turn, read by _next_building + rush-buy.
var _active_attack_mil_count: int = 0
var _in_attack_phase: bool = false
# Test harness state (AUTO_PLAY_SEED path)
var _seed: int = 0
@ -107,6 +112,7 @@ func _ready() -> void:
EventBus.unit_destroyed.connect(_on_unit_destroyed)
EventBus.improvement_started.connect(_on_improvement_started)
EventBus.improvement_completed.connect(_on_improvement_completed)
EventBus.city_building_completed.connect(_on_city_building_completed)
EventBus.loot_dropped.connect(_on_loot_dropped)
_improvement_manager = ImprovementManagerScript.new()
@ -232,6 +238,42 @@ func _on_unit_destroyed(unit: Variant, _killer: Variant) -> void:
"player": idx,
"unit": str(unit.get("type_id")) if unit.get("type_id") != null else "",
})
_maybe_queue_siege_replacement(unit, idx)
func _maybe_queue_siege_replacement(unit: Variant, idx: int) -> void:
# Siege sustain: if a military unit belonging to the currently-attacking
# player dies mid-siege and the stack is at or below 3, prepend a warrior
# onto the nearest-city production queue so we can replace losses without
# waiting for score-based scheduling next turn.
if not _in_attack_phase or idx < 0:
return
if unit.get("can_found_city") == true or unit.get("can_build_improvements") == true:
return
var current: RefCounted = GameState.get_current_player()
if current == null or current.index != idx or current.cities.is_empty():
return
if _active_attack_mil_count > 3:
return
var target_city: RefCounted = _nearest_city_to_target(current)
if target_city == null:
return
if target_city.production_queue.size() > 0:
var head: Dictionary = target_city.production_queue[0]
if str(head.get("id", "")) == "warrior" and str(head.get("type", "")) == "unit":
return
var udata: Dictionary = DataLoader.get_unit("warrior")
var wcost: int = int(udata.get("cost", 0))
target_city.production_queue.insert(
0, {"type": "unit", "id": "warrior", "cost": wcost}
)
target_city.production_progress = 0
print(
(
" [STACK] turn=%d replacement_queued city=%s (stack=%d)"
% [_turn_count, target_city.city_name, _active_attack_mil_count]
)
)
func _on_improvement_started(tile: Vector2i, type: String, turns: int) -> void:
@ -253,6 +295,17 @@ func _on_improvement_completed(tile: Vector2i, type: String) -> void:
})
func _on_city_building_completed(city: Variant, building_id: String) -> void:
var owner_idx: int = int(city.owner) if city != null and city.get("owner") != null else -1
var city_name: String = str(city.city_name) if city != null and city.get("city_name") != null else ""
_append_event({
"type": "city_building_completed",
"player": owner_idx,
"city": city_name,
"building_id": building_id,
})
func _on_loot_dropped(player: Variant, creature_type: String, drops: Array) -> void:
var p_idx: int = int(player.get("index")) if player != null and player.get("index") != null else -1
_append_event({
@ -392,6 +445,73 @@ func _process(_delta: float) -> void:
_frame = 0
func _teleport_scout_near_lair() -> void:
# Test scaffold: move player 0's scout adjacent to the nearest lair to
# guarantee lair-clearing is exercised. Without this, scouts rarely
# cross the 5+ hex min_distance_from_start gap to reach a lair.
var game_map: RefCounted = GameState.get_game_map()
if game_map == null or GameState.players.is_empty():
return
var player: RefCounted = GameState.players[0]
var scout: RefCounted = null
for u: RefCounted in player.units:
if u.get("can_found_city") != true and u.is_alive():
scout = u
break
if scout == null:
return
# Build tier lookup from wilds config so we can prefer low-tier lairs
# (a tier-7 Volcanic Fissure annihilates a fresh scout every attempt).
var wilds_cfg: Dictionary = DataLoader.get_wilds_config()
var tier_by_type: Dictionary = {}
for lt_entry: Dictionary in wilds_cfg.get("lair_types", []):
tier_by_type[lt_entry.get("id", "")] = int(lt_entry.get("base_tier", 4))
var lair_positions: Array[Vector2i] = []
for axial: Vector2i in game_map.tiles:
var tile: Resource = game_map.tiles[axial]
if tile != null and tile.lair_type != "":
lair_positions.append(axial)
if lair_positions.is_empty():
return
# Pick the lowest-tier lair (break ties by distance).
var target_lair: Vector2i = lair_positions[0]
var target_tile: Resource = game_map.get_tile(target_lair)
var target_tier: int = int(tier_by_type.get(
target_tile.lair_type if target_tile != null else "", 99))
var target_dist: int = HexUtilsScript.hex_distance(scout.position, target_lair)
for lp: Vector2i in lair_positions:
var lt_tile: Resource = game_map.get_tile(lp)
var tier: int = int(tier_by_type.get(
lt_tile.lair_type if lt_tile != null else "", 99))
var d: int = HexUtilsScript.hex_distance(scout.position, lp)
if tier < target_tier or (tier == target_tier and d < target_dist):
target_tier = tier
target_dist = d
target_lair = lp
var water_biomes: Array = ["ocean", "coast", "deep_ocean", "lake", "inland_sea", "reef"]
for n: Vector2i in HexUtilsScript.get_neighbors(target_lair):
var norm: Vector2i = HexUtilsScript.normalize_position(
n, game_map.width, game_map.height, game_map.wrap_mode
)
var tile: Resource = game_map.get_tile(norm)
if tile == null or tile.biome_id in water_biomes:
continue
var from: Vector2i = scout.position
scout.position = norm
# Test scaffold: buff the scout enough to clear a low-tier lair.
# Without this, tier 5-7 wild creatures annihilate a base scout
# and the loot path never fires.
scout.max_hp = 200
scout.hp = 200
scout.attack = 40
scout.defense = 20
_recalc_vision(player, game_map)
print("AutoPlay: teleported scout from %s to %s (lair %s at %s, buffed)" % [
from, norm, tile.lair_type if tile != null else "?", target_lair
])
return
func _count_lairs_on_map() -> void:
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
@ -526,13 +646,48 @@ func _play_turn() -> void:
if player.researching.is_empty():
_pick_research(player)
# 0b. Gold rush-buy warriors — spawn at city nearest to attack target
# Refresh attack-phase signals and stack-sustain telemetry for this turn.
# _attack_commitment_turns reflects prior-turn commitment; rush-buy and
# building scoring both key off it so they respond mid-siege.
_in_attack_phase = _attack_commitment_turns > 0 and _locked_target != Vector2i(-1, -1)
_active_attack_mil_count = 0
if _in_attack_phase:
for u_stk: RefCounted in player.units:
if not u_stk.is_alive():
continue
if u_stk.get("can_found_city") == true:
continue
if u_stk.get("can_build_improvements") == true:
continue
if HexUtilsScript.hex_distance(u_stk.position, _locked_target) <= 8:
_active_attack_mil_count += 1
if _turn_count % 10 == 0:
print(
(
" [STACK] turn=%d at_target=%d locked=%s commit=%d"
% [
_turn_count,
_active_attack_mil_count,
str(_locked_target),
_attack_commitment_turns,
]
)
)
# 0b. Gold rush-buy warriors — spawn at city nearest to attack target.
# During active siege, lower the threshold so we can replace losses fast.
# Stack critical (<=1 near target) drops the threshold further.
var mil_pre: int = 0
for u_pre: RefCounted in player.units:
if u_pre.is_alive() and u_pre.get("can_found_city") != true:
mil_pre += 1
var rush_cost: int = 120 # 3x warrior production cost
while player.gold >= rush_cost and mil_pre < city_count * 2:
var rush_cost: int = 120
if _in_attack_phase:
rush_cost = 50 if _active_attack_mil_count <= 1 else 80
var mil_cap: int = city_count * 2
if _in_attack_phase and _active_attack_mil_count < 3:
mil_cap = maxi(mil_cap, mil_pre + (3 - _active_attack_mil_count))
while player.gold >= rush_cost and mil_pre < mil_cap:
if not player.cities.is_empty():
var spawn_pos: Vector2i = _nearest_city_to_target(player).position
var unit_script: GDScript = load("res://engine/src/entities/unit.gd")
@ -831,7 +986,8 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_founder
}
var candidates: Array[String] = [
"warrior", "forge", "walls", "marketplace", "temple",
"colosseum", "library", "barracks", "monument", "castle", "founder", "worker",
"colosseum", "ale_hall", "bathhouse", "library", "barracks", "monument",
"castle", "founder", "worker",
]
var units_set: Array[String] = ["warrior", "founder", "worker"]
var scores: Dictionary = {}
@ -927,12 +1083,20 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_founder
_score_add(scores, "forge", forge_bonus)
if happy < -4:
_score_add(scores, "temple", 5.0); _score_add(scores, "colosseum", 4.0)
_score_add(scores, "ale_hall", 3.5); _score_add(scores, "bathhouse", 4.5)
if happy < -8:
_score_add(scores, "ale_hall", 1.5); _score_add(scores, "bathhouse", 1.5)
if city_count >= 2 and not city.has_building("library"):
_score_add(scores, "library", 3.0)
if own_mil >= 4 and not city.has_building("barracks"):
_score_add(scores, "barracks", 3.0)
if city.has_building("walls") and _turn_count > 150 and city_count >= 3:
_score_add(scores, "castle", 3.0)
# Siege sustain: while committed to ATTACK, missing stack slots near the
# target dominate everything else — +15 per missing warrior below 3.
if _in_attack_phase and _active_attack_mil_count < 3:
var missing: int = 3 - _active_attack_mil_count
_score_add(scores, "warrior", 15.0 * float(missing))
_score_add(scores, "warrior", 1.0); _score_add(scores, "forge", 1.0)
# Log top-3 each time production is selected — emergent strategy visibility
@ -1311,7 +1475,7 @@ func _try_attack_adjacent_lair(unit: Variant, game_map: RefCounted) -> void:
hash(unit.id),
hash(norm),
)
elif not attacker_alive:
elif attacker_killed:
print(" LAIR ATTACK FAILED: %s killed at %s" % [unit.type_id, norm])
return
@ -1741,7 +1905,7 @@ func _save_turn_snapshot() -> void:
if not _seed_set:
return
var save_path: String = _game_dir.path_join("saves/turn_%04d.save" % _turn_count)
var err: Error = SaveManager.save_to_path(save_path)
var err: Error = SaveManagerScript.save_to_path(save_path)
if err != OK:
push_error("AutoPlay: save failed (%s): %s" % [error_string(err), save_path])

View file

@ -527,46 +527,26 @@ static func _decide_production(
continue
if u.get("can_found_city") == true:
founder_count += 1
elif u.unit_type in MILITARY_COMBAT_TYPES:
continue
# Anything with combat stats counts as military. We intentionally do
# not gate on MILITARY_COMBAT_TYPES here: AoD unit JSON populates
# `unit_type: "military"` rather than a weapon-shaped `combat_type`,
# so the old keyword check would drop every warrior/archer/pikeman
# and the "emergency garrison" / "maintain 2 warriors" priorities
# would never see a standing army.
if int(u.get("attack")) > 0 or int(u.get("ranged_attack")) > 0:
military_count += 1
var city: RefCounted = player.cities[city_index]
var city_count: int = player.cities.size()
var threat: Dictionary = _enemy_military_threat(player)
var threatened: bool = bool(threat.get("threatens_city", false))
var enemy_total: int = int(threat.get("total_count", 0))
# Threat preemption: when an enemy stack is closing on a city, force
# military production over walls/happiness/founders until we can field
# at least enemy_total + 1 defenders (matches opponent's full army, not
# just the in-range slice — in-range saturates while reserves escalate).
if threatened and military_count < maxi(3, enemy_total + 1):
var rush_unit: String = _pick_buildable_military_unit_id(city, player)
if not rush_unit.is_empty():
return _prod_unit(city_index, rush_unit)
# Priority 0: Early military floor — maintain 4 warriors during the
# first 80 turns before committing to walls/happiness/founder. Early
# combat attrition (p0 harasses) was dropping mil to 1-2 by T75; this
# keeps the replacement pipeline ahead of losses so we hit mil≥4 by
# T100. After T80 the standard Priority 4 target takes over.
var early_mil_floor: int = 4 if GameState.turn_number <= 80 else 0
if military_count < maxi(1, early_mil_floor):
# Capital walls interject: non-threatened 1-city capital with ≥1
# defender and age >20 takes walls instead of stacking a 4th warrior.
# Without this, walls are never built before T80 because the mil
# floor keeps firing. `not threatened` gates it so we don't slot
# walls while an enemy is actually closing.
var capital_age: int = GameState.turn_number - int(city.turn_founded)
var capital_needs_walls: bool = (
not threatened and city_count == 1 and city_index == 0
and military_count >= 1 and capital_age > 20
and not city.has_building("walls")
and city.can_build("walls", player)
)
if capital_needs_walls:
return _prod_building(city_index, "walls")
# Priority 0: Emergency garrison — no military at all means the next
# enemy stack wins uncontested before any wall is finished. A single
# warrior buys ~10 turns of breathing room at a fraction of walls'
# cost (20 vs 70). Only triggers when the player has zero military
# units across all cities, which in AoD arena matches is the turn-1
# state since starting roster is founder + scout (no combat unit).
if military_count == 0:
var emergency_unit: String = _pick_buildable_military_unit_id(
city, player
)
@ -586,26 +566,17 @@ static func _decide_production(
return _prod_building(city_index, hb_id)
# Priority 3: Expand — build founder if fewer than 3 cities and none in progress
if city_count < 3 and founder_count == 0 and city_index == 0:
if (
city_count < 3
and founder_count == 0
and city_index == 0
and city.can_build("founder", player)
):
return _prod_unit(city_index, "founder")
# Priority 4: Military — maintain 2 warriors per city, scaling up to
# match enemy's FULL army at all times (not only when imminent) so we
# don't get jumped when a distant stack closes the gap in 3-4 turns.
var mil_target: int = maxi(4, city_count * 2)
if enemy_total >= mil_target:
mil_target = enemy_total + 1
var want_military: bool = military_count < mil_target
# Production-heavy races (axis>=6) slot the forge before filling the
# full military quota — they out-build on yields instead of quantity.
# Guarded by military_count >= 2 so we don't skip the early floor.
var forge_first: bool = (
production_axis >= 6
and military_count >= 2
and not city.has_building("forge")
and city.can_build("forge", player)
)
if want_military and not forge_first:
# Priority 4: Military — maintain 2 warriors per city
var want_military: bool = military_count < maxi(2, city_count * 2)
if want_military:
var unit_id: String = _pick_buildable_military_unit_id(city, player)
if not unit_id.is_empty():
return _prod_unit(city_index, unit_id)

View file

@ -12,7 +12,7 @@ pub const BASE_CITY_HP: i32 = 200;
pub const WALL_HP_PER_TIER: i32 = 50;
/// Siege unit bonus vs city HP (applied as positive modifier to siege damage).
const SIEGE_CITY_BONUS: f32 = 2.00;
const SIEGE_CITY_BONUS: f32 = 1.70;
/// City heals this much HP per turn.
pub const CITY_HEAL_PER_TURN: i32 = 10;
@ -23,12 +23,12 @@ const RANGED_CITY_HP_FRACTION: f32 = 0.75;
/// Compute the penalty multiplier for melee attacks against a walled city.
/// Returns a value < 1.0 that the attacker's effective strength is multiplied by.
/// Scales by tier: 0=1.0, 1=0.85 (walls), 2=0.75 (castle).
/// Scales by tier: 0=1.0, 1=0.80 (walls), 2=0.65 (castle).
pub fn melee_wall_penalty(wall_tier: i32) -> f32 {
match wall_tier {
0 => 1.0,
1 => 0.85,
_ => 0.75,
1 => 0.80,
_ => 0.65,
}
}
@ -98,9 +98,9 @@ mod tests {
#[test]
fn melee_penalty_scales_by_tier() {
assert!((melee_wall_penalty(0) - 1.0).abs() < 0.001);
assert!((melee_wall_penalty(1) - 0.85).abs() < 0.001);
assert!((melee_wall_penalty(2) - 0.75).abs() < 0.001);
assert!((melee_wall_penalty(3) - 0.75).abs() < 0.001);
assert!((melee_wall_penalty(1) - 0.80).abs() < 0.001);
assert!((melee_wall_penalty(2) - 0.65).abs() < 0.001);
assert!((melee_wall_penalty(3) - 0.65).abs() < 0.001);
}
#[test]
@ -119,7 +119,7 @@ mod tests {
#[test]
fn siege_bonus() {
assert!((siege_city_bonus() - 2.00).abs() < 0.001);
assert!((siege_city_bonus() - 1.70).abs() < 0.001);
}
#[test]