feat(scenes): Add hysteresis logic to stabilize auto-play phase transitions between BUILD and ATTACK phases

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-15 18:11:49 -07:00
parent fced6f4322
commit c2ec2a53d5

View file

@ -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"