saumwacht-godot/scripts/ai/simple_ai.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

213 lines
8.4 KiB
GDScript

extends RefCounted
## Deterministic finite planning; no access to the opponent's current orders.
const MAX_WORKERS_LEARNING := 4
const MAX_ROSTER_LEARNING := 12
func plan(team: int, wave: int, opponent_snapshot: Dictionary, last_report: Dictionary,
level: int = GameState.AI_STANDARD) -> void:
if not EconomyManager.build_open or EconomyManager.current_wave != wave:
return
if wave == 1:
_order(team, {"type": "build", "type_id": "g03", "cell": Vector2i(2, 0)})
_order(team, {"type": "build", "type_id": "g05", "cell": Vector2i(2, 2)})
_order(team, {"type": "build", "type_id": "g01", "cell": Vector2i(3, 0)})
return
if level == GameState.AI_LEARNING:
_plan_learning(team, wave)
return
var natural_count: int = maxi(1, int(last_report.get("natural_count", 1)))
var leak_ratio: float = float(last_report.get("natural_leaks", 0)) / float(natural_count)
var weak_front: bool = float(last_report.get("front_lifetime", 90.0)) < 12.0 or leak_ratio > 0.10
var own: Dictionary = EconomyManager.get_team(team)
if own.is_empty():
return
# Leave at least 60 gold for defense; newly ordered workers cannot produce now.
var cost: int = EconomyManager.worker_cost(team)
if not weak_front and wave <= DataCatalog.wave_count() - 3 and int(own["workers"]) + int(own["pending_workers"]) < 8:
if int(own["gold"]) >= cost + 60 and _front_count(own["roster"]) >= 2:
_order(team, {"type": "worker"})
var armor: int = _dominant_wave_armor(wave)
# Fixed upper bound also makes pathological or full boards terminate immediately.
for _step: int in 16:
own = EconomyManager.get_team(team)
var roster: Array = own["roster"]
var front: int = _front_count(roster)
var desired_front: int = maxi(2, ceili(float(roster.size()) * 0.35))
var purchased: bool = false
if front < desired_front:
purchased = _build(team, "g03" if front % 2 == 0 else "g01", own)
elif roster.size() >= 3:
purchased = _upgrade(team, own, armor, weak_front)
if not purchased:
var preferred: Array[String] = _buy_preferences(roster, armor, weak_front)
for id: String in preferred:
if _build(team, id, own):
purchased = true
break
if not purchased:
break
_plan_sends(team, wave, opponent_snapshot)
func _plan_learning(team: int, wave: int) -> void:
# Fixed, readable behaviour: no reactive counters, no saved-up attacks.
var own: Dictionary = EconomyManager.get_team(team)
if own.is_empty():
return
var cost: int = EconomyManager.worker_cost(team)
if wave <= DataCatalog.wave_count() - 3 and int(own["workers"]) + int(own["pending_workers"]) < MAX_WORKERS_LEARNING:
if int(own["gold"]) >= cost + 60 and _front_count(own["roster"]) >= 2:
_order(team, {"type": "worker"})
var rotation: Array[String] = ["g03", "g01", "g05", "g07", "g09"]
var upgrade_order: Array[String] = ["g03", "g05", "g01", "g07", "g09"]
for _step: int in 16:
own = EconomyManager.get_team(team)
var roster: Array = own["roster"]
var purchased: bool = false
if roster.size() >= 6:
purchased = _upgrade_any(team, own, upgrade_order)
if not purchased and roster.size() < MAX_ROSTER_LEARNING:
purchased = _build(team, rotation[roster.size() % rotation.size()], own)
if not purchased:
break
_plan_simple_sends(team, wave)
func _upgrade_any(team: int, own: Dictionary, order: Array[String]) -> bool:
for id: String in order:
var upgraded: Dictionary = DataCatalog.get_unit(DataCatalog.UNITS[DataCatalog.for_team(id, team)]["upgrade_id"])
if int(own["gold"]) < int(upgraded["cost"]):
continue
for entry: Dictionary in own["roster"]:
if entry["type_id"] == DataCatalog.for_team(id, team):
return _order(team, {"type": "upgrade", "uid": entry["uid"]})
return false
func _plan_simple_sends(team: int, wave: int) -> void:
# Sends only in every second allowed wave; the budget is spent immediately.
if wave < 3 or wave % 2 == 0:
return
for _step: int in 8:
var own: Dictionary = EconomyManager.get_team(team)
var reserved: int = EconomyManager.reserved_sparks(team)
var available: int = mini(int(own["sparks"]) - reserved, EconomyManager.send_budget(wave) - reserved)
if own["send_queue"].size() >= 8 or available < 20:
break
var send_id: String = "s01" if available < 40 else "s02"
if not _order(team, {"type": "queue_send", "type_id": send_id}):
break
func _buy_preferences(roster: Array, armor: int, weak_front: bool) -> Array[String]:
var front: int = _front_count(roster)
if front < 2 or (weak_front and front * 2 < roster.size()):
return ["g03", "g01", "g05"]
var support: int = _count_types(roster, ["g09", "g10"])
if roster.size() >= 4 and support * 4 < roster.size() and not weak_front:
return ["g09", "g05", "g07", "g01"]
if armor == 2 or _count_types(roster, ["g07", "g08"]) == 0:
return ["g07", "g05", "g01"]
return ["g05", "g07", "g01"]
func _upgrade(team: int, own: Dictionary, armor: int, weak_front: bool) -> bool:
var order: Array[String] = ["g05", "g03", "g07", "g01", "g09"]
if weak_front:
order = ["g03", "g01", "g05", "g07", "g09"]
elif armor == 2:
order = ["g07", "g05", "g03", "g01", "g09"]
for id: String in order:
var upgraded: Dictionary = DataCatalog.get_unit(DataCatalog.UNITS[DataCatalog.for_team(id, team)]["upgrade_id"])
if int(own["gold"]) < int(upgraded["cost"]):
continue
for entry: Dictionary in own["roster"]:
if entry["type_id"] == DataCatalog.for_team(id, team):
return _order(team, {"type": "upgrade", "uid": entry["uid"]})
return false
func _build(team: int, id: String, own: Dictionary) -> bool:
var definition: Dictionary = DataCatalog.get_unit(DataCatalog.for_team(id, team))
if int(own["gold"]) < int(definition["cost"]):
return false
var cell: Vector2i = _free_cell(own["roster"], id)
if cell.x < 0:
return false
return _order(team, {"type": "build", "type_id": id, "cell": cell})
func _free_cell(roster: Array, id: String) -> Vector2i:
var rows: Array[int] = [2, 3, 4, 5, 6, 7, 1, 0]
if id in ["g01", "g03"]:
rows = [0, 1, 2, 3, 4, 5, 6, 7]
elif id == "g09":
rows = [1, 2, 3, 4, 5, 6, 7, 0]
for row: int in rows:
for column: int in [2, 3, 1, 4, 0, 5]:
var candidate: Vector2i = Vector2i(column, row)
var occupied: bool = false
for entry: Dictionary in roster:
if entry["cell"] == candidate:
occupied = true
break
if not occupied:
return candidate
return Vector2i(-1, -1)
func _plan_sends(team: int, wave: int, opponent_snapshot: Dictionary) -> void:
if wave < 3:
return
var public_roster: Array = opponent_snapshot.get("roster", [])
var upgraded: int = 0
var pierce_damage: float = 0.0
for entry: Dictionary in public_roster:
var definition: Dictionary = DataCatalog.get_unit(str(entry.get("type_id", "")))
if definition.is_empty():
continue
if str(definition["upgrade_id"]).is_empty():
upgraded += 1
if int(definition["attack_type"]) == 1:
pierce_damage += float(definition["damage"])
for _step: int in 8:
var own: Dictionary = EconomyManager.get_team(team)
var reserved: int = EconomyManager.reserved_sparks(team)
var available: int = mini(int(own["sparks"]) - reserved, EconomyManager.send_budget(wave) - reserved)
if own["send_queue"].size() >= 8 or available < 20:
break
var send_id: String = "s01"
if wave >= 11 and available >= 200 and _count_types(public_roster, ["g10"]) > 0:
send_id = "s05"
elif wave >= 6 and available >= 130 and _front_count(public_roster) >= 3:
send_id = "s04"
elif available >= 80 and upgraded >= 2 and public_roster.size() <= 6:
send_id = "s03"
elif available >= 40 and pierce_damage < 40.0:
send_id = "s02"
if not _order(team, {"type": "queue_send", "type_id": send_id}):
break
func _dominant_wave_armor(wave: int) -> int:
var armor_weight: Array[float] = [0.0, 0.0, 0.0]
for enemy: Dictionary in DataCatalog.get_attackers(wave):
armor_weight[int(enemy["armor_type"])] += float(enemy["max_hp"])
var result: int = 0
for index: int in range(1, 3):
if armor_weight[index] > armor_weight[result]:
result = index
return result
func _front_count(roster: Array) -> int:
return _count_types(roster, ["g01", "g02", "g03", "g04"])
func _count_types(roster: Array, ids: Array) -> int:
var count: int = 0
for entry: Dictionary in roster:
var id: String = str(entry.get("type_id", ""))
if id.begins_with("w"):
id = "g" + id.substr(1)
if ids.has(id):
count += 1
return count
func _order(team: int, command: Dictionary) -> bool:
if command.has("type_id"):
command["type_id"] = DataCatalog.for_team(str(command["type_id"]), team)
return bool(EconomyManager.apply_command(team, command)["ok"])