saumwacht-godot/scripts/luneraria/core/lune_match.gd
Martin Stickelbroeck b565546c37 M1: autoritativer Drei-Lane-Kern (Karte, Kampf, Partie, Wellen, Bots)
LuneMap spiegelsymmetrisch mit 16 Türmen und 2 Ancients, LuneCombat mit
reihenfolgeunabhängigem Zweiphasenschritt und Gebäudesperren für jede
Schadensquelle, LuneMatch mit Planungsentwürfen, atomarer Übernahme,
Einmal-Belohnung und Kernsieg/Unentschieden, endlose LuneWaves, Bots über
dieselbe Befehlsschnittstelle. 87 Kernprüfungen bestanden.

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

325 lines
14 KiB
GDScript

class_name LuneMatch
extends RefCounted
## Authoritative match: six player slots in two teams, persistent ownership, planning
## drafts that are validated and committed atomically, combat, settlement and victory.
## A match ends only when an Ancient falls. There is no round limit and no HP decision.
enum Phase { PLANNING, COMBAT, OVER }
const ONGOING := -2
const DRAW := -1
const BOT_NAMES: Array[String] = ["Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt"]
const ROLES: Dictionary = {"g01": "melee", "g03": "front", "g05": "ranged", "g07": "splash", "g09": "support",
"w01": "melee", "w03": "front", "w05": "ranged", "w07": "splash", "w09": "support"}
const ROW_ORDER: Array[String] = ["front", "melee", "splash", "ranged", "support"]
var balance: LuneBalance
var map: LuneMap
var rng := RandomNumberGenerator.new()
var seed_value: int = 0
var round_number: int = 1
var phase: int = Phase.PLANNING
var winner: int = ONGOING
var end_note: String = ""
var players: Array[Dictionary] = []
var building_hp: Dictionary = {}
var combat: LuneCombat
var last_settlement: Dictionary = {}
var history: Array[Dictionary] = []
var _next_uid: int = 1
var _reward_rotation: int = 0
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
map = LuneMap.new(balance)
## options: seed, human_slot (-1 for six bots), bunds (Array of six), names.
func setup(options: Dictionary = {}) -> void:
seed_value = int(options.get("seed", 1))
rng.seed = seed_value
round_number = 1
phase = Phase.PLANNING
winner = ONGOING
end_note = ""
history.clear()
last_settlement = {}
_next_uid = 1
_reward_rotation = 0
players.clear()
var human: int = int(options.get("human_slot", 1))
var bunds: Array = options.get("bunds", [])
for slot: int in range(6):
var bund: String = str(bunds[slot]) if slot < bunds.size() else ("glutpakt" if (slot + seed_value) % 2 == 0 else "wurzelbund")
var player := {"slot": slot, "team": slot / 3, "lane": LuneMap.LANES[slot % 3], "is_human": slot == human,
"name": "Du" if slot == human else BOT_NAMES[(slot + seed_value) % BOT_NAMES.size()], "bund": bund,
"gold": balance.start_gold, "roster": [], "ready": slot != human, "earned": 0, "spent": 0}
player.draft = _snapshot(player)
player.undo = []
players.append(player)
building_hp.clear()
for definition: Dictionary in map.buildings:
building_hp[definition.id] = float(definition.max_hp)
func _snapshot(player: Dictionary) -> Dictionary:
return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true)}
func slot_of(team: int, lane: String) -> int:
return team * 3 + LuneMap.LANES.find(lane)
func base_ids(bund: String) -> Array[String]:
return DataCatalog.faction_ids(bund)
# ---------------------------------------------------------------- planning commands
func submit(slot: int, command: Dictionary) -> Dictionary:
if phase != Phase.PLANNING:
return _result(false, "Befehle sind nur in der Planung möglich.")
if slot < 0 or slot >= players.size():
return _result(false, "Unbekannter Spieler.")
var player: Dictionary = players[slot]
var draft: Dictionary = player.draft
var before: Dictionary = draft.duplicate(true)
var result: Dictionary = _apply(player, draft, command)
if result.ok:
(player.undo as Array).append(before)
return result
func undo(slot: int) -> Dictionary:
var player: Dictionary = players[slot]
if phase != Phase.PLANNING or (player.undo as Array).is_empty():
return _result(false, "Nichts rückgängig zu machen.")
player.draft = (player.undo as Array).pop_back()
return _result(true, "Letzte Planung zurückgenommen.")
func _find(roster: Array, uid: String) -> int:
for index: int in range(roster.size()):
if str(roster[index].uid) == uid:
return index
return -1
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
var roster: Array = draft.roster
match str(command.get("type", "")):
"buy":
var type_id: String = str(command.get("type_id", ""))
if not base_ids(player.bund).has(type_id):
return _result(false, "Diese Truppe gehört nicht zu deinem Bund.")
if roster.size() >= balance.supply_cap:
return _result(false, "Versorgungsgrenze erreicht (%d Truppen)." % balance.supply_cap)
var cost: int = int(DataCatalog.get_unit(type_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
var lane: String = str(command.get("lane", player.lane))
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.gold = int(draft.gold) - cost
roster.append({"uid": "p%d-%d" % [player.slot, _next_uid], "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
_next_uid += 1
return _result(true, "%s gekauft." % DataCatalog.get_unit(type_id).display_name)
"upgrade":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
var upgrade_id: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade_id.is_empty():
return _result(false, "Bereits vollständig aufgewertet.")
var cost: int = int(DataCatalog.get_unit(upgrade_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
entry.type_id = upgrade_id
entry.invested = int(entry.invested) + cost
return _result(true, "Zu %s aufgewertet." % DataCatalog.get_unit(upgrade_id).display_name)
"sell":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
# A purchase from this planning is cancelled at full price; committed troops sell at the refund rate.
var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * balance.sell_refund)
draft.gold = int(draft.gold) + refund
roster.remove_at(index)
return _result(true, "Für %d Gold zurückgegeben." % refund)
"assign":
var index: int = _find(roster, str(command.get("uid", "")))
var lane: String = str(command.get("lane", ""))
if index < 0 or not LuneMap.LANES.has(lane):
return _result(false, "Ungültige Zuweisung.")
roster[index].lane = lane
return _result(true, "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
return _result(false, "Unbekannter Befehl.")
func _result(ok: bool, message: String) -> Dictionary:
return {"ok": ok, "message": message}
func set_ready(slot: int, value: bool = true) -> void:
if phase == Phase.PLANNING:
players[slot].ready = value
func all_ready() -> bool:
for player: Dictionary in players:
if not bool(player.ready):
return false
return true
# ---------------------------------------------------------------- phase changes
## Validates every draft again and commits all of them at once, then starts combat.
func begin_combat() -> bool:
if phase != Phase.PLANNING:
return false
for player: Dictionary in players:
if not _draft_valid(player):
player.draft = _snapshot(player)
for player: Dictionary in players:
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(player.draft.gold))
player.gold = int(player.draft.gold)
player.roster = (player.draft.roster as Array).duplicate(true)
for entry: Dictionary in player.roster:
entry.erase("fresh")
player.undo = []
combat = LuneCombat.new(map, balance, round_number)
for definition: Dictionary in map.buildings:
if float(building_hp[definition.id]) > 0.0:
combat.add_building(definition, float(building_hp[definition.id]))
_spawn_waves()
_spawn_armies()
phase = Phase.COMBAT
return true
func _draft_valid(player: Dictionary) -> bool:
var draft: Dictionary = player.draft
if int(draft.gold) < 0 or (draft.roster as Array).size() > balance.supply_cap:
return false
var allowed: Array[String] = base_ids(player.bund)
for entry: Dictionary in draft.roster:
var id: String = str(entry.type_id)
var base: String = id
for candidate: String in allowed:
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
base = candidate
if not allowed.has(base) or not LuneMap.LANES.has(str(entry.lane)):
return false
return true
func _spawn_waves() -> void:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
for team: int in range(2):
for lane: String in LuneMap.LANES:
for index: int in range(wave.size()):
var row: int = index / 3
var lateral: float = (float(index % 3) - 1.0) * 1.3
combat.spawn("monster", team, lane, wave[index].duplicate(true), 11.0 - float(row) * 1.4, lateral)
func _spawn_armies() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
# The lane owner's troops form the first rows; supporting players follow behind.
var owner: int = slot_of(team, lane)
var ordered: Array = []
for slot: int in [owner] + [team * 3, team * 3 + 1, team * 3 + 2].filter(func(s: int) -> bool: return s != owner):
var entries: Array = (players[slot].roster as Array).filter(func(e: Dictionary) -> bool: return e.lane == lane)
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var ra: int = ROW_ORDER.find(role_of(a.type_id))
var rb: int = ROW_ORDER.find(role_of(b.type_id))
return str(a.uid) < str(b.uid) if ra == rb else ra < rb)
for entry: Dictionary in entries:
ordered.append([slot, entry])
for index: int in range(ordered.size()):
var slot: int = ordered[index][0]
var entry: Dictionary = ordered[index][1]
var row: int = index / 4
var lateral: float = (float(index % 4) - 1.5) * 1.2
var definition: Dictionary = DataCatalog.get_unit(entry.type_id)
combat.spawn("troop", team, lane, definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, slot, str(entry.uid))
func role_of(type_id: String) -> String:
var id: String = type_id
for base: String in ROLES:
if base == id or str(DataCatalog.get_unit(base).get("upgrade_id", "")) == id:
return ROLES[base]
return "melee"
func step_combat() -> void:
if phase != Phase.COMBAT or combat == null:
return
combat.step()
if combat.finished:
_settle()
## Runs the current combat phase to its end (tests, bots-only simulations).
func finish_combat() -> void:
while phase == Phase.COMBAT:
step_combat()
func _settle() -> void:
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
var lane_kills: Dictionary = {}
var buildings_lost: Array[String] = []
for event: Dictionary in combat.events:
var dying: int = int(event.team)
var killer: int = 1 - dying
var bounty: int = int(event.bounty)
if event.kind in ["monster", "champion", "boss"] and bounty > 0 and event.zone == "lane":
var owner: int = slot_of(killer, str(event.lane))
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
gains[owner] += lane_part
_split(gains, killer, bounty - lane_part)
lane_kills[event.lane] = int(lane_kills.get(event.lane, 0)) + 1
elif event.kind in ["tower", "core"]:
buildings_lost.append(str(event.building_id))
_split(gains, killer, bounty)
for unit: LuneUnit in combat.units:
if unit.is_building():
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
var income: int = balance.round_income(round_number)
for player: Dictionary in players:
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
player.gold = int(player.gold) + total
player.earned = int(player.earned) + total
last_settlement = {"round": round_number, "gains": gains, "income": income, "buildings_lost": buildings_lost,
"end_reason": combat.end_reason, "combat_time": combat.elapsed, "lane_kills": lane_kills}
history.append({"round": round_number, "end_reason": combat.end_reason, "combat_time": combat.elapsed,
"buildings": _alive_count(), "gold": players.map(func(p: Dictionary) -> int: return int(p.gold)),
"lost": buildings_lost.duplicate()})
if not combat.destroyed_cores.is_empty():
winner = DRAW if combat.destroyed_cores.size() >= 2 else 1 - combat.destroyed_cores[0]
end_note = "Beide Ancients fielen im selben Schritt." if winner == DRAW else "Ancient von %s zerstört." % ("Blau" if combat.destroyed_cores[0] == 0 else "Rot")
phase = Phase.OVER
return
round_number += 1
phase = Phase.PLANNING
for player: Dictionary in players:
player.draft = _snapshot(player)
player.undo = []
player.ready = not bool(player.is_human)
## Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.
func _split(gains: Array[int], team: int, amount: int) -> void:
if amount <= 0:
return
var share: int = amount / 3
var rest: int = amount % 3
for offset: int in range(3):
gains[team * 3 + offset] += share
for index: int in range(rest):
gains[team * 3 + (_reward_rotation + index) % 3] += 1
_reward_rotation = (_reward_rotation + rest) % 3
func _alive_count() -> Array[int]:
var counts: Array[int] = [0, 0]
for id: String in building_hp:
if float(building_hp[id]) > 0.0:
counts[0 if id.begins_with("blau") else 1] += 1
return counts
func building_alive(id: String) -> bool:
return float(building_hp.get(id, 0.0)) > 0.0
func towers_alive(team: int, lane: String) -> int:
var prefix: String = "blau" if team == 0 else "rot"
return int(building_alive("%s_%s_outer" % [prefix, lane])) + int(building_alive("%s_%s_inner" % [prefix, lane]))
func core_hp(team: int) -> float:
return float(building_hp["blau_ancient" if team == 0 else "rot_ancient"])
func gold_of(slot: int) -> int:
return int(players[slot].draft.gold) if phase == Phase.PLANNING else int(players[slot].gold)