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>
325 lines
14 KiB
GDScript
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)
|