diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index f855898b..955e76a2 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -33,6 +33,9 @@ var _screenshot_interval: int = 10 var _locked_target: Vector2i = Vector2i(-1, -1) var _target_stuck_turns: int = 0 var _last_army_pos: Vector2i = Vector2i(-1, -1) +# Attack-phase hysteresis: once entered, stay committed for 10 turns to prevent +# ping-ponging between BUILD and ATTACK when ratios oscillate around the threshold. +var _attack_phase_until_turn: int = -1 # Test harness state (AUTO_PLAY_SEED path) var _seed: int = 0 @@ -540,8 +543,35 @@ func _play_turn() -> void: var intel: Dictionary = _get_enemy_intel() var enemy_mil: int = intel.get("military", 0) var advantage: float = float(military_count) / maxf(1.0, float(enemy_mil)) - # Attack when we have 1.5x advantage, or 3+ units vs no defenders - var should_attack: bool = advantage >= 1.5 or (military_count >= 3 and enemy_mil == 0) + # Option B: loosen attack trigger. Commit when (a) near-parity advantage, + # (b) ≥3 bodies with an enemy city in striking range, (c) fighting defenseless. + # Once in ATTACK phase, stay for 10 turns (hysteresis) — phase ping-pong was + # stalling offensives at the 1.5x threshold when enemy produced at our rate. + var enemy_city_in_range: bool = false + for p_scan: Variant in GameState.players: + if p_scan.index == player.index: + continue + for c_scan: Variant in p_scan.cities: + for u_scan: Variant in units_snapshot: + if not u_scan.is_alive() or u_scan.get("can_found_city") == true: + continue + if u_scan.get("can_build_improvements") == true: + continue + if HexUtilsScript.hex_distance(u_scan.position, c_scan.position) <= 6: + enemy_city_in_range = true + break + if enemy_city_in_range: + break + if enemy_city_in_range: + break + var trigger_attack: bool = ( + advantage >= 1.25 + or (military_count >= 3 and enemy_city_in_range) + or (military_count >= 3 and enemy_mil == 0) + ) + if trigger_attack and _turn_count >= _attack_phase_until_turn: + _attack_phase_until_turn = _turn_count + 10 + var should_attack: bool = _turn_count < _attack_phase_until_turn if should_attack: # ATTACK PHASE: lock onto one target and march until it's destroyed if _locked_target == Vector2i(-1, -1): @@ -810,7 +840,10 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_settler if tl2 != null and int(tl2.get_yields(player.index).get("food", 0)) == 0: food_starved = true - # 14 factors (see plan table) + # 14 factors (see plan table). Weights tuned against smoke regressions: + # forge gets strong priority early (its prod multiplier gates everything), + # worker is gated by population >=2 so starving-mountain starts don't pick + # worker they can't afford. if enemy_mil >= own_mil and near_enemy <= 6: _score_add(scores, "warrior", 8.0); _score_add(scores, "walls", 4.0) if own_mil < enemy_mil: @@ -821,14 +854,16 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_settler _score_add(scores, "warrior", 6.0) if city_count < 3 and not has_settler and max_pop >= 3: _score_add(scores, "settler", 6.0) - if food_starved and own_workers == 0: + if food_starved and own_workers == 0 and int(city.population) >= 2: _score_add(scores, "worker", 7.0) - if unimproved_food > 0 and own_workers < city_count: + if unimproved_food > 0 and own_workers < city_count and int(city.population) >= 2: _score_add(scores, "worker", 4.0) if gold_now < 20 and gpt < 0: _score_add(scores, "marketplace", 7.0) - if not city.has_building("forge") and base_prod < 3: - _score_add(scores, "forge", 5.0) + if not city.has_building("forge"): + # Forge is the universal production multiplier; prioritize strongly when absent. + var forge_bonus: float = 9.0 if base_prod < 3 else 6.0 + _score_add(scores, "forge", forge_bonus) if happy < -4: _score_add(scores, "brewery", 4.0); _score_add(scores, "monument", 3.0) if city_count >= 2 and not city.has_building("library"): @@ -839,14 +874,14 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_settler _score_add(scores, "castle", 3.0) _score_add(scores, "warrior", 1.0); _score_add(scores, "forge", 1.0) - # Log top-3 every 20 turns — emergent strategy visibility - if _turn_count % 20 == 0 and not scores.is_empty(): + # Log top-3 each time production is selected — emergent strategy visibility + if not scores.is_empty(): var ranked: Array = scores.keys() ranked.sort_custom(func(a: String, b: String) -> bool: return scores[a] > scores[b]) var top: Array = [] for i: int in range(min(3, ranked.size())): top.append("%s=%.1f" % [ranked[i], float(scores[ranked[i]])]) - print(" [SCORE] city=%s: %s" % [city.city_name, ", ".join(top)]) + print(" [SCORE] t%d city=%s: %s" % [_turn_count, city.city_name, ", ".join(top)]) # Pick max; fall back to warrior if all zero var best_id: String = "warrior"