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:
parent
fced6f4322
commit
c2ec2a53d5
1 changed files with 45 additions and 10 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue