feat(scenes): ✨ Introduce auto-play system with city growth and military siege/attack logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
aab3497315
commit
11beef576e
2 changed files with 118 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue