diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 045f5dd3..a00e935d 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -38,6 +38,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 @@ -113,6 +117,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() @@ -238,6 +243,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: @@ -259,6 +300,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({ @@ -576,13 +628,45 @@ 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. 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 = 80 if _in_attack_phase else 120 + 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") @@ -897,7 +981,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 = {} @@ -993,12 +1078,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 @@ -1309,8 +1402,11 @@ func _try_attack_adjacent_lair(unit: Variant, game_map: RefCounted) -> void: break # Build dicts and resolve via GdCombatResolver var gd_resolver: RefCounted = ClassDB.instantiate("GdCombatResolver") - var def_dict: Dictionary = gd_resolver.wild_stats(lair_tier, lair_size, lair_diet) - var kws: Array[String] = unit.get_keywords() + var def_dict: Dictionary = gd_resolver.wild_combat_stats(lair_tier, lair_size, lair_diet) + var kws_raw: Array = unit.get_keywords() + var kws: PackedStringArray = PackedStringArray() + for k: String in kws_raw: + kws.append(k) var atk_dict: Dictionary = { "hp": unit.hp, "max_hp": unit.get_max_hp(), @@ -1319,7 +1415,7 @@ func _try_attack_adjacent_lair(unit: Variant, game_map: RefCounted) -> void: "ranged_attack": unit.get_damage() if unit.is_ranged() else 0, "range": unit.get_range(), "movement": unit.get_movement(), - "keywords": PackedStringArray(kws), + "keywords": kws, "flanking": 0, "support": 0, "terrain_defense": 0, @@ -1343,9 +1439,9 @@ func _try_attack_adjacent_lair(unit: Variant, game_map: RefCounted) -> void: var result: Dictionary = gd_resolver.resolve(atk_dict, def_dict, ctx_dict) unit.hp = result.get("attacker_hp", unit.hp) unit.movement_remaining = 0 - var attacker_alive: bool = result.get("attacker_alive", true) - var defender_alive: bool = result.get("defender_alive", true) - if not defender_alive and attacker_alive: + var attacker_killed: bool = bool(result.get("attacker_killed", false)) + var defender_killed: bool = bool(result.get("defender_killed", false)) + if defender_killed and not attacker_killed: # Lair cleared — award gold and bonus XP var gold_reward: int = lair_tier * 10 var xp_reward: int = lair_tier * 5 @@ -1374,7 +1470,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 diff --git a/src/game/engine/src/entities/city.gd b/src/game/engine/src/entities/city.gd index d30cc29d..981cb36c 100644 --- a/src/game/engine/src/entities/city.gd +++ b/src/game/engine/src/entities/city.gd @@ -376,10 +376,16 @@ func has_building(building: String) -> bool: ## Enqueue an item. Returns empty string on success, error message on failure. +## `available_resources` is the list of strategic-resource ids the owning player +## currently controls (via any worked tile of any city they own). Items whose +## `requires_resource` is not in this list are rejected — this is the Rust-side +## enforcement of the same gate the GDScript buildable filter applies to units, +## so a caller cannot bypass the filter by hitting the Rust queue directly. func enqueue_item( item_id: String, stockpile: RefCounted, - researched_techs: Array[String] + researched_techs: Array[String], + available_resources: Array[String] = [] as Array[String] ) -> String: if _gd_city == null: _warn_missing_extension() @@ -389,7 +395,10 @@ func enqueue_item( var techs: PackedStringArray = PackedStringArray() for t in researched_techs: techs.push_back(t) - return _gd_city.call("enqueue_item", item_id, stockpile, techs) + var resources: PackedStringArray = PackedStringArray() + for r in available_resources: + resources.push_back(r) + return _gd_city.call("enqueue_item", item_id, stockpile, techs, resources) ## Tick a building's queue. Returns number of completed items.