saumwacht-godot/scripts/luneraria/core/lune_match.gd
Martin Stickelbroeck f53859a298 M2: Helden, Items, Talente, Angriffschampions und Bossgrotte
Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen,
drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall,
ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das
Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt
aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73,
M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert.

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

621 lines
29 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 GROTTO := "grotto"
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 HERO_ROLES: Dictionary = {"held_ringborke": "front", "held_quellgeflecht": "support", "held_funkenlanze": "ranged"}
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] = []
## Public boss state. claim = {team, won_round, deploy_round, chooser_slot, lane} or {}.
var boss: Dictionary = {}
var boss_log: Array[Dictionary] = []
## Remainder rotation per team so both teams follow the same distribution pattern.
var _reward_rotation: Array[int] = [0, 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).
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 = {}
_reward_rotation = [0, 0]
boss = {"next_available_round": 1, "claim": {}}
boss_log.clear()
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": [], "hero": {}, "bookings": [], "ready": slot != human,
"earned": 0, "spent": 0, "next_seq": 1}
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), "next_seq": int(player.next_seq),
"hero": (player.hero as Dictionary).duplicate(true), "new_bookings": [], "boss_lane": ""}
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)
func grotto_available() -> bool:
return round_number >= int(boss.next_available_round)
# ---------------------------------------------------------------- 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 _next_seq(player: Dictionary, draft: Dictionary) -> int:
var seq: int = int(draft.get("next_seq", player.next_seq))
draft.next_seq = seq + 1
return seq
func _valid_destination(lane: String) -> bool:
return LuneMap.LANES.has(lane) or (lane == GROTTO and grotto_available())
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
var roster: Array = draft.roster
var hero: Dictionary = draft.hero
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
# Per-player numbering keeps both teams' ids in the same order; seq sorts numerically.
var seq: int = _next_seq(player, draft)
roster.append({"uid": "p%d-%d" % [player.slot, seq], "seq": seq, "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
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 uid: String = str(command.get("uid", ""))
var lane: String = str(command.get("lane", ""))
if not _valid_destination(lane):
return _result(false, "Die Grotte ist bis Runde %d gesperrt." % int(boss.next_available_round) if lane == GROTTO else "Ungültige Zuweisung.")
if not hero.is_empty() and str(hero.uid) == uid:
hero.lane = lane
else:
var index: int = _find(roster, uid)
if index < 0:
return _result(false, "Ungültige Zuweisung.")
roster[index].lane = lane
return _result(true, "In die Bossgrotte entsandt." if lane == GROTTO else "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
"hero_buy":
var hero_id: String = str(command.get("hero_id", ""))
if not LuneContent.HEROES.has(hero_id):
return _result(false, "Unbekannter Held.")
if not hero.is_empty():
return _result(false, "Pro Spieler ist ein aktiver Held erlaubt.")
var cost: int = int(LuneContent.HEROES[hero_id].cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
var seq: int = _next_seq(player, draft)
draft.hero = {"uid": "h%d-%d" % [player.slot, seq], "seq": seq, "hero_id": hero_id, "lane": player.lane, "xp": 0,
"items": [], "talents": [], "invested": cost, "fresh": true, "fresh_items": []}
return _result(true, "%s angeworben." % LuneContent.HEROES[hero_id].name)
"hero_cancel":
if hero.is_empty() or not bool(hero.get("fresh", false)):
return _result(false, "Nur ein Held aus dieser Planung lässt sich zurückgeben.")
var refund: int = int(hero.invested)
for item_id: String in hero.items:
refund += int(LuneContent.ITEMS[item_id].cost)
draft.gold = int(draft.gold) + refund
draft.hero = {}
return _result(true, "Held zurückgegeben (%d Gold)." % refund)
"item_buy":
var item_id: String = str(command.get("item_id", ""))
if command.has("uid") and (hero.is_empty() or str(command.uid) != str(hero.uid)):
return _result(false, "Normale Truppen tragen keine Items.")
if hero.is_empty():
return _result(false, "Items benötigen einen Helden.")
if not LuneContent.ITEMS.has(item_id):
return _result(false, "Unbekanntes Item.")
var item: Dictionary = LuneContent.ITEMS[item_id]
if (hero.items as Array).has(item_id):
return _result(false, "%s trägt dein Held bereits; Items sind einzigartig." % item.name)
if (hero.items as Array).size() >= LuneContent.ITEM_SLOTS:
return _result(false, "Alle %d Itemplätze sind belegt." % LuneContent.ITEM_SLOTS)
if LuneContent.level_for(int(hero.xp)) < int(item.get("min_level", 1)):
return _result(false, "%s erfordert Stufe %d." % [item.name, int(item.min_level)])
if int(draft.gold) < int(item.cost):
return _result(false, "Es fehlen %d Gold." % (int(item.cost) - int(draft.gold)))
draft.gold = int(draft.gold) - int(item.cost)
(hero.items as Array).append(item_id)
(hero.fresh_items as Array).append(item_id)
return _result(true, "%s ausgerüstet." % item.name)
"item_sell":
var item_id: String = str(command.get("item_id", ""))
if hero.is_empty() or not (hero.items as Array).has(item_id):
return _result(false, "Dein Held trägt dieses Item nicht.")
var cost: int = int(LuneContent.ITEMS[item_id].cost)
var fresh: bool = (hero.fresh_items as Array).has(item_id)
var refund: int = cost if fresh else floori(float(cost) * LuneContent.ITEM_REFUND)
(hero.items as Array).erase(item_id)
(hero.fresh_items as Array).erase(item_id)
draft.gold = int(draft.gold) + refund
return _result(true, "Item für %d Gold verkauft." % refund)
"talent":
if hero.is_empty():
return _result(false, "Talente benötigen einen Helden.")
var tier: int = int(command.get("tier", -1))
var choice: int = int(command.get("choice", -1))
var talents: Array = hero.talents
if tier != talents.size() or tier < 0 or tier >= LuneContent.TALENT_LEVELS.size() or choice < 0 or choice > 1:
return _result(false, "Dieses Talent ist jetzt nicht wählbar.")
if LuneContent.level_for(int(hero.xp)) < LuneContent.TALENT_LEVELS[tier]:
return _result(false, "Talentstufe %d erfordert Heldenstufe %d." % [tier + 1, LuneContent.TALENT_LEVELS[tier]])
var option: Dictionary = LuneContent.TALENTS[hero.hero_id][tier][choice]
talents.append(option.id)
return _result(true, "Talent %s gewählt." % option.name)
"champion_book":
var champion_id: String = str(command.get("champion_id", ""))
var lane: String = str(command.get("lane", player.lane))
if not LuneContent.CHAMPIONS.has(champion_id) or not LuneMap.LANES.has(lane):
return _result(false, "Ungültige Buchung.")
if (draft.new_bookings as Array).size() >= LuneContent.MAX_BOOKINGS:
return _result(false, "Höchstens %d Angriffschampions pro Runde." % LuneContent.MAX_BOOKINGS)
var cost: int = int(LuneContent.CHAMPIONS[champion_id].cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
var seq: int = _next_seq(player, draft)
(draft.new_bookings as Array).append({"uid": "c%d-%d" % [player.slot, seq], "seq": seq, "champion_id": champion_id, "lane": lane, "cost": cost})
return _result(true, "%s für die nächste Runde auf %s gebucht." % [LuneContent.CHAMPIONS[champion_id].name, LuneMap.LANE_NAMES[lane]])
"champion_cancel":
var uid: String = str(command.get("uid", ""))
var bookings: Array = draft.new_bookings
for index: int in range(bookings.size()):
if str(bookings[index].uid) == uid:
draft.gold = int(draft.gold) + int(bookings[index].cost)
bookings.remove_at(index)
return _result(true, "Buchung zurückgenommen.")
return _result(false, "Nur Buchungen aus dieser Planung lassen sich zurücknehmen.")
"boss_lane":
var claim: Dictionary = boss.claim
var lane: String = str(command.get("lane", ""))
if claim.is_empty() or int(claim.deploy_round) != round_number or int(claim.chooser_slot) != int(player.slot):
return _result(false, "Du bestimmst die Boss-Lane nicht.")
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.boss_lane = lane
return _result(true, "Der Boss greift über %s an." % 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
## Public information for everyone: champion bookings and the boss deployment of this round.
func announcements() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for player: Dictionary in players:
for booking: Dictionary in player.bookings:
if int(booking.deploy_round) == round_number:
result.append({"kind": "champion", "team": player.team, "slot": player.slot, "lane": booking.lane, "champion_id": booking.champion_id})
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.deploy_round) == round_number:
result.append({"kind": "boss", "team": claim.team, "slot": claim.chooser_slot, "lane": claim.lane,
"bounty": LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * round_number})
return result
# ---------------------------------------------------------------- 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:
var draft: Dictionary = player.draft
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(draft.gold))
player.gold = int(draft.gold)
player.next_seq = int(draft.get("next_seq", player.next_seq))
player.roster = (draft.roster as Array).duplicate(true)
for entry: Dictionary in player.roster:
entry.erase("fresh")
player.hero = (draft.hero as Dictionary).duplicate(true)
if not (player.hero as Dictionary).is_empty():
player.hero.erase("fresh")
player.hero.fresh_items = []
for booking: Dictionary in draft.new_bookings:
var committed: Dictionary = booking.duplicate()
committed.deploy_round = round_number + 1
(player.bookings as Array).append(committed)
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.chooser_slot) == int(player.slot) and not str(draft.boss_lane).is_empty():
claim.lane = draft.boss_lane
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_champions()
_spawn_deployed_boss()
_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 _valid_destination(str(entry.lane)):
return false
var hero: Dictionary = draft.hero
if not hero.is_empty():
var items: Array = hero.items
if items.size() > LuneContent.ITEM_SLOTS or not _valid_destination(str(hero.lane)):
return false
for index: int in range(items.size()):
if items.find(items[index]) != index or not LuneContent.ITEMS.has(items[index]):
return false
if (hero.talents as Array).size() > LuneContent.TALENT_LEVELS.size():
return false
return (draft.new_bookings as Array).size() <= LuneContent.MAX_BOOKINGS
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_champions() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
var index: int = 0
for slot: int in range(team * 3, team * 3 + 3):
for booking: Dictionary in players[slot].bookings:
if int(booking.deploy_round) == round_number and booking.lane == lane:
var definition: Dictionary = LuneContent.champion_definition(booking.champion_id, round_number, balance)
combat.spawn("champion", team, lane, definition, 13.0, (float(index % 3) - 1.0) * 1.4, slot, str(booking.uid))
index += 1
func _spawn_deployed_boss() -> void:
var claim: Dictionary = boss.claim
if claim.is_empty() or int(claim.deploy_round) != round_number:
return
var definition: Dictionary = LuneContent.boss_definition(round_number, balance, true)
combat.spawn("boss", int(claim.team), str(claim.lane), definition, 14.0, 0.0, int(claim.chooser_slot), "boss-%d" % round_number)
boss_log.append({"round": round_number, "event": "deployed", "team": claim.team, "lane": claim.lane})
func _army_entries(slot: int) -> Array:
var player: Dictionary = players[slot]
var entries: Array = []
for entry: Dictionary in player.roster:
entries.append({"slot": slot, "uid": entry.uid, "seq": int(entry.seq), "lane": entry.lane, "role": role_of(entry.type_id),
"invested": int(entry.invested), "definition": _troop_definition(entry.type_id), "kind": "troop"})
var hero: Dictionary = player.hero
if not hero.is_empty():
var invested: int = int(hero.invested)
for item_id: String in hero.items:
invested += int(LuneContent.ITEMS[item_id].cost)
var definition: Dictionary = LuneContent.hero_definition(hero)
definition.role = HERO_ROLES[hero.hero_id]
entries.append({"slot": slot, "uid": hero.uid, "seq": int(hero.seq), "lane": hero.lane, "role": definition.role,
"invested": invested, "definition": definition, "kind": "hero"})
return entries
func _troop_definition(type_id: String) -> Dictionary:
var definition: Dictionary = DataCatalog.get_unit(type_id)
definition.role = role_of(type_id)
return definition
func _sorted(entries: Array) -> Array:
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var ra: int = ROW_ORDER.find(a.role)
var rb: int = ROW_ORDER.find(b.role)
return a.seq < b.seq if ra == rb else ra < rb)
return entries
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):
ordered.append_array(_sorted(_army_entries(slot).filter(func(e: Dictionary) -> bool: return e.lane == lane)))
for index: int in range(ordered.size()):
var entry: Dictionary = ordered[index]
var row: int = index / 4
var lateral: float = (float(index % 4) - 1.5) * 1.2
combat.spawn(entry.kind, team, lane, entry.definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, entry.slot, str(entry.uid))
# Grotto expeditions leave their lanes for the whole combat phase.
var expedition: bool = false
for team: int in range(2):
var ordered: Array = []
for slot: int in range(team * 3, team * 3 + 3):
ordered.append_array(_sorted(_army_entries(slot).filter(func(e: Dictionary) -> bool: return e.lane == GROTTO)))
var entry_point: Vector2 = map.grotto_entry(team)
var inward: Vector2 = (map.grotto_boss_point() - entry_point).normalized()
var across := Vector2(-inward.y, inward.x) * (1.0 if team == 0 else -1.0)
for index: int in range(ordered.size()):
var entry: Dictionary = ordered[index]
var at: Vector2 = entry_point - inward * float(index / 3) * 1.2 + across * (float(index % 3) - 1.0) * 1.1
combat.spawn_at(entry.kind, team, "grotto", entry.definition, at, entry.slot, str(entry.uid))
expedition = true
if expedition:
combat.add_grotto_boss(LuneContent.boss_definition(round_number, balance, false))
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] = []
var xp_gain: Dictionary = {}
for event: Dictionary in combat.events:
var dying: int = int(event.team)
var killer: int = 1 - dying if dying < 2 else -1
var bounty: int = int(event.bounty)
if event.kind == "boss" and event.zone == "lane" and bounty > 0 and killer >= 0:
# The defenders of a deployed boss share its bounty evenly.
_split(gains, killer, bounty)
elif event.kind in ["monster", "champion"] and bounty > 0 and event.zone == "lane" and killer >= 0:
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)
var heroes: Array = event.get("xp_heroes", [])
if not heroes.is_empty():
var share: int = maxi(1, int(event.xp) / heroes.size())
for uid: String in heroes:
xp_gain[uid] = int(xp_gain.get(uid, 0)) + share
for unit: LuneUnit in combat.units:
if unit.is_building():
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
elif unit.kind == "hero":
xp_gain[unit.persistent_uid] = int(xp_gain.get(unit.persistent_uid, 0)) + LuneContent.XP_PARTICIPATION + LuneContent.XP_PER_ROUND * round_number
for player: Dictionary in players:
var hero: Dictionary = player.hero
if not hero.is_empty() and xp_gain.has(hero.uid):
hero.xp = int(hero.xp) + int(xp_gain[hero.uid])
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
# Deployed bookings are consumed; they never return as property.
player.bookings = (player.bookings as Array).filter(func(b: Dictionary) -> bool: return int(b.deploy_round) > round_number)
# The claim is decided from this round's expedition before its units go home.
var boss_result: String = _settle_boss()
for player: Dictionary in players:
# An expedition is an order for one round: units return to their owner's lane.
for entry: Dictionary in player.roster:
if entry.lane == GROTTO:
entry.lane = player.lane
if not (player.hero as Dictionary).is_empty() and player.hero.lane == GROTTO:
player.hero.lane = player.lane
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, "xp": xp_gain, "boss": boss_result}
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(), "boss": boss_result,
"hero_levels": players.map(func(p: Dictionary) -> int: return 0 if (p.hero as Dictionary).is_empty() else LuneContent.level_for(int(p.hero.xp)))})
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)
## Boss bookkeeping at settlement. Returns a short outcome label for reports.
## Idempotent: a repeated settlement signal for the same combat changes nothing.
func _settle_boss() -> String:
var fought: int = combat.round_number
if int(boss.get("settled_round", 0)) == fought:
return "repeat"
boss.settled_round = fought
var result: String = "none"
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.deploy_round) == fought:
# The deployment happens exactly once; afterwards the claim is gone.
boss.claim = {}
result = "deployed"
if combat.grotto_outcome == "success" and (boss.claim as Dictionary).is_empty() and fought >= int(boss.next_available_round):
var team: int = combat.grotto_claim_team
var chooser: int = _boss_chooser(team)
boss.claim = {"team": team, "won_round": fought, "deploy_round": fought + 1, "chooser_slot": chooser,
"lane": players[chooser].lane}
boss.next_available_round = fought + 1 + LuneContent.BOSS_LOCK_ROUNDS
boss_log.append({"round": fought, "event": "won", "team": team, "chooser": chooser})
result = "won"
elif combat.grotto_outcome == "failed":
boss_log.append({"round": fought, "event": "failed"})
result = "failed" if result == "none" else result
return result
## Contributing lane player of the winning team with the highest army value in the grotto;
## ties go to the lower slot.
func _boss_chooser(team: int) -> int:
var best: int = team * 3
var best_value: int = -1
for slot: int in range(team * 3, team * 3 + 3):
var value: int = 0
for entry: Dictionary in _army_entries(slot):
if entry.lane == GROTTO:
value += int(entry.invested)
if value > best_value:
best = slot
best_value = value
return best
## 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 or team < 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[team] + index) % 3] += 1
_reward_rotation[team] = (_reward_rotation[team] + 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
## Live state for displays: during combat the combat instance is authoritative.
func building_alive_now(id: String) -> bool:
if phase == Phase.COMBAT and combat != null and combat.buildings.has(id):
return (combat.buildings[id] as LuneUnit).alive
return building_alive(id)
func towers_alive(team: int, lane: String, live: bool = false) -> int:
var prefix: String = "blau" if team == 0 else "rot"
var probe: Callable = building_alive_now if live else building_alive
return int(probe.call("%s_%s_outer" % [prefix, lane])) + int(probe.call("%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)