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)