feat(engine): update auto-play building selection logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-15 18:08:04 -07:00
parent 3097c3f982
commit e6f83fd5f0

View file

@ -802,136 +802,135 @@ func _manage_production(city: Variant) -> void:
func _next_building(city: Variant, player: Variant, city_count: int, has_settler: bool) -> String:
## Score candidate productions from current game state; return highest.
## No prescriptive ordering — priorities emerge from threat/economy/growth signals.
## Score candidates from current state; return highest. See plan: cosmic-questing-allen.md.
## 14 factors — priorities emerge from circumstances, not prescriptive order.
var tech_req: Dictionary = {
"brewery": "brewing", "library": "scholarship", "barracks": "military_doctrine",
"monument": "ancestor_rites", "castle": "fortification",
}
var candidates: Array[String] = [
"warrior", "forge", "walls", "marketplace", "brewery",
"library", "barracks", "monument", "castle", "settler", "worker",
]
var units_set: Array[String] = ["warrior", "settler", "worker"]
var scores: Dictionary = {}
var mil: int = 0
var workers: int = 0
for u: Variant in player.units:
if not u.is_alive():
for cid: String in candidates:
if not (cid in units_set) and city.has_building(cid):
continue
if u.get("can_found_city") == true:
if cid in tech_req and not player.has_tech(tech_req[cid]):
continue
if u.get("can_build_improvements") == true:
workers += 1
else:
mil += 1
scores[cid] = 0.0
# State gathering
var intel: Dictionary = _get_enemy_intel()
var enemy_mil: int = int(intel.get("military", 0))
var enemy_cities: int = int(intel.get("cities", 0))
var mil_ratio: float = float(mil) / maxf(1.0, float(enemy_mil))
var gpt: int = int(player.get("gold_per_turn")) if player.get("gold_per_turn") != null else 0
# Nearest enemy unit distance from this city
var near_enemy_dist: int = 99
var own_mil: int = 0
var own_workers: int = 0
for u: Variant in player.units:
if not u.is_alive() or u.get("can_found_city") == true:
continue
if u.get("can_build_improvements") == true:
own_workers += 1
else:
own_mil += 1
var gpt: int = int(player.gold_per_turn) if player.get("gold_per_turn") != null else 0
var happy: int = int(player.happiness) if player.get("happiness") != null else 0
var gold_now: int = int(player.gold) if player.get("gold") != null else 0
var max_pop: int = 0
for oc: Variant in player.cities:
if int(oc.population) > max_pop:
max_pop = int(oc.population)
var near_enemy: int = 99
var any_siege: bool = false
for p: Variant in GameState.players:
if p.index == player.index:
continue
for eu: Variant in p.units:
if eu.is_alive() and eu.get("can_found_city") != true:
var d: int = HexUtilsScript.hex_distance(city.position, eu.position)
if d < near_enemy_dist:
near_enemy_dist = d
# Worked tiles with no improvement → worker has real work
var unimproved_worked: int = 0
for tp: Vector2i in city.get_worked_tiles():
var gm: RefCounted = GameState.get_game_map()
if not eu.is_alive() or eu.get("can_found_city") == true:
continue
var d_self: int = HexUtilsScript.hex_distance(city.position, eu.position)
if d_self < near_enemy:
near_enemy = d_self
for oc2: Variant in player.cities:
if HexUtilsScript.hex_distance(oc2.position, eu.position) <= 3:
any_siege = true
var gm: RefCounted = GameState.get_game_map()
var base_prod: int = 0
if gm != null:
var cy: Dictionary = city.get_yields(BuildableHelperScript.build_tile_yields_json(city, gm))
base_prod = int(cy.get("production", 0))
var unimproved_food: int = 0
var food_starved: bool = false
for tp: Vector2i in city.owned_tiles:
var tl: Resource = gm.get_tile(tp) if gm != null else null
if tl != null and str(tl.get("improvement")) == "":
unimproved_worked += 1
if tl == null:
continue
if str(tl.get("improvement")) == "" and tl.biome_id in ["grassland", "plains"]:
unimproved_food += 1
for tp2: Vector2i in city.get_worked_tiles():
var tl2: Resource = gm.get_tile(tp2) if gm != null else null
if tl2 != null and int(tl2.get_yields(player.index).get("food", 0)) == 0:
food_starved = true
# ── Warrior: threat + deficit ───────────────────────────────────────
var warrior_score: float = 0.0
if mil < enemy_mil:
warrior_score += 6.0 * (float(enemy_mil - mil)) # outnumbered: catch up fast
if mil_ratio >= 1.5 and enemy_cities > 0:
warrior_score += 4.0 # press advantage to attack
if near_enemy_dist <= 6:
warrior_score += 5.0 + float(6 - near_enemy_dist) # close threat
if mil < 2:
warrior_score += 3.0 # minimum garrison
scores["warrior"] = warrior_score
# ── Forge: universal production multiplier, front-loaded ────────────
# 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:
_score_add(scores, "warrior", 5.0)
if any_siege:
_score_add(scores, "walls", 10.0); _score_add(scores, "warrior", 3.0)
if own_mil == 0 and _turn_count <= 30:
_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 and int(city.population) >= 2:
_score_add(scores, "worker", 7.0)
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"):
scores["forge"] = 5.0 + (3.0 if mil >= 1 else 0.0)
# 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"):
_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)
_score_add(scores, "warrior", 1.0); _score_add(scores, "forge", 1.0)
# ── Walls: city threatened ──────────────────────────────────────────
if not city.has_building("walls"):
var wall_score: float = 0.0
if near_enemy_dist <= 5:
wall_score += 8.0
elif near_enemy_dist <= 10:
wall_score += 3.0
if mil < enemy_mil:
wall_score += 2.0
scores["walls"] = wall_score
# ── Marketplace: gold dragging ──────────────────────────────────────
if not city.has_building("marketplace"):
var mkt: float = 2.0
if gpt <= 0:
mkt += 6.0
if city_count >= 2:
mkt += 2.0
scores["marketplace"] = mkt
# ── Granary: growth-blocked small city ──────────────────────────────
if not city.has_building("granary") and city.population < 4:
scores["granary"] = 4.0 + float(4 - city.population)
# ── Worker: unimproved worked tiles ─────────────────────────────────
if workers < city_count:
var worker_score: float = 2.0 + 2.0 * float(unimproved_worked)
if workers == 0:
worker_score += 3.0
scores["worker"] = worker_score
# ── Settler: safe expansion when ahead ──────────────────────────────
if city_count < 3 and not has_settler and mil >= 2 and near_enemy_dist >= 8:
scores["settler"] = 4.0 + float(3 - city_count) * 2.0
# ── Tech-gated econ (only if tech researched and not built) ─────────
var gated: Array[Array] = [
["library", "scholarship", 3.0],
["barracks", "military_doctrine", 3.0 + (2.0 if near_enemy_dist <= 8 else 0.0)],
["brewery", "brewing", 2.0],
["monument", "ancestor_rites", 2.0],
["castle", "fortification", 3.0 + (3.0 if near_enemy_dist <= 5 else 0.0)],
]
for entry: Array in gated:
var bid: String = entry[0]
var tech: String = entry[1]
var base: float = entry[2]
if city.has_building(bid):
continue
if not player.has_tech(tech):
continue
scores[bid] = base
# Pick max; log top-3 every 20 turns
var best_id: String = "warrior"
var best_score: float = -INF
for k: String in scores:
var s: float = scores[k]
if s > best_score:
best_score = s
best_id = k
if _turn_count % 20 == 0:
# 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] %s near_enemy=%d mil=%d/%d gpt=%+d -> %s" % [
city.city_name, near_enemy_dist, mil, enemy_mil, gpt, ", ".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"
var best_score: float = 0.0
for k: String in scores:
var s: float = scores[k]
if s > best_score:
best_score = s
best_id = k
return best_id
func _score_add(scores: Dictionary, key: String, amount: float) -> void:
if key in scores:
scores[key] = float(scores[key]) + amount
func _command_unit(unit: Variant, player: RefCounted, game_map: RefCounted) -> void:
if unit.get("can_build_improvements") == true:
_command_worker(unit, player, game_map)