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)