feat(ai): Add validation checks to prevent invalid city production decisions in simple heuristic AI

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-15 21:33:10 -07:00
parent fb1046052c
commit 94a80efa16

View file

@ -368,7 +368,7 @@ static func _decide_production(
var city_count: int = player.cities.size()
# Priority 1: Build walls if city has none (defense first)
if not city.has_building("walls"):
if not city.has_building("walls") and city.can_build("walls", player):
var wdata: Dictionary = DataLoader.get_building("walls")
if not wdata.is_empty():
return _prod_building(city_index, "walls")
@ -376,41 +376,55 @@ static func _decide_production(
# Priority 2: Happiness building when unhappy
if player.happiness < 0:
var hb_id: String = _pick_happiness_building_id(city, player)
if not hb_id.is_empty():
if not hb_id.is_empty() and city.can_build(hb_id, player):
return _prod_building(city_index, hb_id)
# Priority 3: Expand — build founder if fewer than 3 cities and none in progress
if city_count < 3 and founder_count == 0 and city_index == 0:
if (
city_count < 3
and founder_count == 0
and city_index == 0
and city.can_build("founder", player)
):
return _prod_unit(city_index, "founder")
# Priority 4: Military — maintain 2 warriors per city
var want_military: bool = military_count < maxi(2, city_count * 2)
if want_military:
var unit_id: String = _pick_military_unit_id()
var unit_id: String = _pick_buildable_military_unit_id(city, player)
if not unit_id.is_empty():
return _prod_unit(city_index, unit_id)
# Priority 5: Production building (forge boosts future output)
if not city.has_building("forge"):
if not city.has_building("forge") and city.can_build("forge", player):
var fdata: Dictionary = DataLoader.get_building("forge")
if not fdata.is_empty():
return _prod_building(city_index, "forge")
# Priority 6: Castle (upgrades walls, enables bombard)
if city.has_building("walls") and not city.has_building("castle"):
if (
city.has_building("walls")
and not city.has_building("castle")
and city.can_build("castle", player)
):
var cdata: Dictionary = DataLoader.get_building("castle")
if not cdata.is_empty():
return _prod_building(city_index, "castle")
# Priority 7: Any other available building
var building_id: String = _pick_building_id(city)
var building_id: String = _pick_building_id(city, player)
if not building_id.is_empty():
return _prod_building(city_index, building_id)
# Fallback: more military
var fallback_unit: String = _pick_military_unit_id()
# Fallback: any buildable military unit (warrior is ungated by default).
var fallback_unit: String = _pick_buildable_military_unit_id(city, player)
if not fallback_unit.is_empty():
return _prod_unit(city_index, fallback_unit)
# Last-resort fallback: worker (ungated) — keeps production flowing so
# the city never stalls on an empty queue when exotic data states hide
# every combat unit behind tech gates.
if city.can_build("worker", player):
return _prod_unit(city_index, "worker")
return {}
@ -449,14 +463,28 @@ static func _pick_next_tech(player: Variant) -> String:
return best_id
static func _pick_military_unit_id() -> String:
var preferred: String = "warrior"
var data: Dictionary = DataLoader.get_unit(preferred)
if not data.is_empty():
return preferred
static func _pick_buildable_military_unit_id(
city: RefCounted, player: RefCounted
) -> String:
## Pick the first military unit the city is allowed to build, preferring
## warrior (ungated baseline). Walks the priority list and falls back to
## any melee unit whose tech/race/school gates the player already clears.
## Returns "" only if every combat unit is gated (extremely unlikely in
## Age of Dwarves, where warrior has no requirements).
var priority: Array[String] = [
"warrior", "spearmen", "pikeman", "archer", "cavalry",
]
for unit_id: String in priority:
if DataLoader.get_unit(unit_id).is_empty():
continue
if city.can_build(unit_id, player):
return unit_id
for u: Dictionary in DataLoader.get_all_units():
if String(u.get("combat_type", "")) == "melee":
return String(u.get("id", ""))
if String(u.get("combat_type", "")) != "melee":
continue
var uid: String = String(u.get("id", ""))
if not uid.is_empty() and city.can_build(uid, player):
return uid
return ""
@ -470,7 +498,9 @@ static func _pick_happiness_building_id(
var bid: String = str(b.get("id", ""))
if bid.is_empty() or bid in existing:
continue
if not _can_build(b, player):
if b.get("wonder_type") != null:
continue
if not city.can_build(bid, player):
continue
var happiness_value: int = _sum_effect(b, "happiness")
if happiness_value > best_happiness:
@ -479,18 +509,6 @@ static func _pick_happiness_building_id(
return best_id
static func _can_build(building_data: Dictionary, player: RefCounted) -> bool:
if building_data.get("wonder_type") != null:
return false
var tech_req: String = str(building_data.get("tech_required", ""))
if not tech_req.is_empty() and not player.has_tech(tech_req):
return false
var culture_req: String = str(building_data.get("culture_required", ""))
if not culture_req.is_empty():
return false
return true
static func _sum_effect(building_data: Dictionary, effect_type: String) -> int:
var total: int = 0
var effects: Array = building_data.get("effects", []) as Array
@ -503,7 +521,7 @@ static func _sum_effect(building_data: Dictionary, effect_type: String) -> int:
return total
static func _pick_building_id(city: RefCounted) -> String:
static func _pick_building_id(city: RefCounted, player: RefCounted) -> String:
var existing: Array = Array(city.buildings)
# Skip buildings handled by priority logic
var skip: Array = ["walls", "forge", "castle"]
@ -511,10 +529,10 @@ static func _pick_building_id(city: RefCounted) -> String:
var bid: String = str(b.get("id", ""))
if bid.is_empty() or bid in existing or bid in skip:
continue
if not str(b.get("tech_required", "")).is_empty():
continue
if b.get("wonder_type") != null:
continue
if not city.can_build(bid, player):
continue
return bid
return ""