extends Node ## M2 rules: heroes (XP, talents, three item slots), attack champions (book n -> fight n+1) ## and the boss grotto (contest, failure, success, one deployment, lock, bounty). ## Gold granted below is a test fixture on drafts only. ## Run: godot --headless --path . res://tests/luneraria/test_lune_m2.tscn var checks: int = 0 var failures: Array[String] = [] func check(ok: bool, label: String) -> void: checks += 1 if not ok: failures.append(label) printerr("M2_FAIL: ", label) func _ready() -> void: call_deferred("run") func new_match(seed: int = 21) -> LuneMatch: var state := LuneMatch.new() state.setup({"seed": seed, "human_slot": -1, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]}) return state func grant(state: LuneMatch, slot: int, gold: int) -> void: state.players[slot].draft.gold = int(state.players[slot].draft.gold) + gold func run_round(state: LuneMatch, planner: Callable = Callable()) -> void: if planner.is_valid(): planner.call(state) state.begin_combat() state.finish_combat() func run() -> void: _test_heroes_and_items() _test_xp_and_persistence() _test_champions() _test_grotto_withdrawal_and_contest() _test_boss_success_lock_and_deployment() _test_boss_failure_restores() _test_bots_m2() _test_no_legacy_economy() _test_lane_loss() var report := {"checks": checks, "failures": failures} var file := FileAccess.open("res://docs/luneraria/logs/m2-tests.json", FileAccess.WRITE) file.store_string(JSON.stringify(report, "\t")) file.close() print("LUNE_M2_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()]) get_tree().quit(0 if failures.is_empty() else 1) func _test_heroes_and_items() -> void: var state := new_match() grant(state, 0, 5000) var gold: int = state.gold_of(0) check(state.submit(0, {"type": "hero_buy", "hero_id": "held_ringborke"}).ok and state.gold_of(0) == gold - 260, "hero costs exactly 260") check(not state.submit(0, {"type": "hero_buy", "hero_id": "held_funkenlanze"}).ok, "only one active hero per player") state.submit(0, {"type": "buy", "type_id": "g01"}) var troop_uid: String = state.players[0].draft.roster[0].uid check(not state.submit(0, {"type": "item_buy", "item_id": "glutklinge", "uid": troop_uid}).ok, "normal troops carry no items") check(not state.players[0].draft.roster[0].has("items"), "troop entries have no inventory") var hero_uid: String = state.players[0].draft.hero.uid check(state.submit(0, {"type": "item_buy", "item_id": "glutklinge", "uid": hero_uid}).ok, "hero buys an item") check(not state.submit(0, {"type": "item_buy", "item_id": "glutklinge"}).ok, "items are unique per hero") check(not state.submit(0, {"type": "item_buy", "item_id": "kristallsplitter"}).ok, "item requirement (level 3) enforced") check(state.submit(0, {"type": "item_buy", "item_id": "rankenmantel"}).ok and state.submit(0, {"type": "item_buy", "item_id": "basaltschild"}).ok, "second and third slot") check(not state.submit(0, {"type": "item_buy", "item_id": "funkenfokus"}).ok, "three item slots at most") check(not state.submit(0, {"type": "talent", "tier": 0, "choice": 0}).ok, "talent needs hero level 3") var definition: Dictionary = LuneContent.hero_definition(state.players[0].draft.hero) check(is_equal_approx(float(definition.max_hp), 2200.0 + 350.0) and is_equal_approx(float(definition.damage_bonus), .18) and is_equal_approx(float(definition.reduction_bonus), .10), "item bonuses stack into the hero definition") var gold_before_sell: int = state.gold_of(0) state.submit(0, {"type": "item_sell", "item_id": "basaltschild"}) check(state.gold_of(0) == gold_before_sell + 180, "item bought this planning refunds fully") state.submit(0, {"type": "item_buy", "item_id": "basaltschild"}) state.begin_combat() state.finish_combat() var hero: Dictionary = state.players[0].hero check(hero.items == ["glutklinge", "rankenmantel", "basaltschild"], "items persist after the round") gold_before_sell = state.gold_of(0) state.submit(0, {"type": "item_sell", "item_id": "basaltschild"}) check(state.gold_of(0) == gold_before_sell + 90, "committed item sells for 50 %") check(not state.submit(0, {"type": "hero_cancel"}).ok, "committed hero cannot be returned for gold") # Caps: two reduction sources plus a talent stay capped at 35 %. var probe := LuneUnit.new() probe.def = {"reduction_bonus": .40, "ability": ""} check(is_equal_approx(probe.damage_reduction(), .35), "damage reduction capped at 35 %") probe.def = {"attack_speed_bonus": .9} check(probe.attack_speed_factor() <= 1.5 + .0001, "attack tempo capped at +50 %") func _test_xp_and_persistence() -> void: var state := new_match(22) grant(state, 1, 3000) state.submit(1, {"type": "hero_buy", "hero_id": "held_quellgeflecht"}) for i: int in range(3): state.submit(1, {"type": "buy", "type_id": "w03"}) var hero_uid: String = state.players[1].draft.hero.uid state.begin_combat() var spawned: LuneUnit = null for unit: LuneUnit in state.combat.units: if unit.persistent_uid == hero_uid: spawned = unit check(spawned != null and spawned.kind == "hero" and spawned.lane == "mid", "hero fights on its lane as its own kind") state.finish_combat() var participation: int = LuneContent.XP_PARTICIPATION + LuneContent.XP_PER_ROUND * 1 var xp: int = int(state.players[1].hero.xp) check(xp > participation, "supporting hero near fights earns more than participation XP (%d > %d)" % [xp, participation]) # Kills are not needed: a hero far away from any death still earns the participation share. var lonely := new_match(23) grant(lonely, 0, 3000) lonely.submit(0, {"type": "hero_buy", "hero_id": "held_funkenlanze"}) lonely.begin_combat() for unit: LuneUnit in lonely.combat.units: if unit.kind == "hero": unit.position = Vector2(-34, -34) lonely.finish_combat() check(int(lonely.players[0].hero.xp) >= participation, "participation XP without any kill") # Level, talent and persistence across rounds. state.players[1].hero.xp = 290 state.players[1].draft = state._snapshot(state.players[1]) check(LuneContent.level_for(290) == 3 and state.submit(1, {"type": "talent", "tier": 0, "choice": 1}).ok, "talent tier 1 at level 3") check(not state.submit(1, {"type": "talent", "tier": 0, "choice": 0}).ok, "talent choice is final") check(not state.submit(1, {"type": "talent", "tier": 1, "choice": 0}).ok, "tier 2 needs level 6") state.begin_combat() var healthy: bool = false var talent_live: bool = false for unit: LuneUnit in state.combat.units: if unit.persistent_uid == hero_uid: healthy = unit.hp == unit.max_hp and int(unit.def.level) == 3 talent_live = unit.has_talent("weite_quelle") unit.hp = 0.0 state.finish_combat() check(healthy and talent_live, "hero starts healed with level and talent") check(state.players[1].hero.talents == ["weite_quelle"] and int(state.players[1].hero.xp) >= 290, "hero keeps XP and talents after dying") var fresh := new_match(22) check(fresh.players[1].hero.is_empty() and fresh.players[1].roster.is_empty(), "new match starts without heroes or XP") func _test_champions() -> void: var state := new_match(24) grant(state, 0, 1000) var gold: int = state.gold_of(0) check(state.submit(0, {"type": "champion_book", "champion_id": "ch_belagerung", "lane": "bot"}).ok and state.gold_of(0) == gold - 180, "booking costs the champion price") var booking_uid: String = state.players[0].draft.new_bookings[0].uid state.submit(0, {"type": "champion_book", "champion_id": "ch_druck", "lane": "top"}) check(not state.submit(0, {"type": "champion_book", "champion_id": "ch_schutz", "lane": "top"}).ok, "at most two bookings per round") state.submit(0, {"type": "champion_cancel", "uid": state.players[0].draft.new_bookings[1].uid}) check(state.players[0].draft.new_bookings.size() == 1, "fresh booking can be cancelled") state.begin_combat() var in_round_one: int = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion").size() check(in_round_one == 0, "a booking never fights in the round it was bought") state.finish_combat() check(state.round_number == 2 and state.announcements().any(func(a: Dictionary) -> bool: return a.kind == "champion" and a.lane == "bot" and a.team == 0), "booking is announced publicly for round 2") check(not state.submit(0, {"type": "champion_cancel", "uid": booking_uid}).ok, "committed booking cannot be moved or refunded") state.begin_combat() var champions: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion") check(champions.size() == 1 and champions[0].team == 0 and champions[0].lane == "bot" and str(champions[0].def.champion_id) == "ch_belagerung", "round 2 fields the champion for Blau on Bottom") # Kill it for a clean bounty check. var champion: LuneUnit = champions[0] champion.hp = 0.0 state.combat.step() var bounty: int = LuneContent.CHAMPION_BOUNTY + LuneContent.CHAMPION_BOUNTY_PER_ROUND * 2 var events: Array = state.combat.events.filter(func(e: Dictionary) -> bool: return e.kind == "champion") check(events.size() == 1 and int(events[0].bounty) == bounty, "champion death recorded once with its bounty") var before: Array = state.players.map(func(p: Dictionary) -> int: return int(p.gold)) state.finish_combat() var rot_gain: int = 0 for slot: int in range(3, 6): rot_gain += int(state.last_settlement.gains[slot]) check(rot_gain >= bounty, "defenders receive the champion bounty") check(state.players[0].bookings.is_empty(), "champion is consumed after its round") state.begin_combat() check(state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion").is_empty(), "no champion in round 3") state.finish_combat() # Siege focus: a siege champion next to a tower and a monster attacks the tower. var map := LuneMap.new() var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1) var tower: LuneUnit = combat.add_building(map.building_def("rot_mid_outer"), 6000.0) var siege: LuneUnit = combat.spawn("champion", 0, "mid", LuneContent.champion_definition("ch_belagerung", 1, LuneBalance.default_balance()), 1.0, 0.0) siege.position = tower.position + Vector2(-3.0, 0) var monster: LuneUnit = combat.spawn("monster", 1, "mid", DataCatalog.ENEMIES["e01"].duplicate(true), 1.0, 0.0) monster.position = siege.position + Vector2(-1.5, 0) # Targetless units search on their staggered step; four steps cover every offset. for i: int in range(LuneCombat.RETARGET): combat.step() check(siege.target == tower, "siege champion prefers the attackable tower") func _expedition(state: LuneMatch, slot: int, types: Array, hero: String = "") -> void: grant(state, slot, 6000) var ids: Array[String] = state.base_ids(state.players[slot].bund) for type_index: int in types: state.submit(slot, {"type": "buy", "type_id": ids[type_index]}) var entry: Dictionary = state.players[slot].draft.roster[-1] state.submit(slot, {"type": "upgrade", "uid": entry.uid}) state.submit(slot, {"type": "assign", "uid": entry.uid, "lane": "grotto"}) if not hero.is_empty(): state.submit(slot, {"type": "hero_buy", "hero_id": hero}) state.submit(slot, {"type": "assign", "uid": state.players[slot].draft.hero.uid, "lane": "grotto"}) func _test_grotto_withdrawal_and_contest() -> void: var state := new_match(25) _expedition(state, 1, [1, 1, 0, 2]) state.submit(1, {"type": "buy", "type_id": "w01"}) _expedition(state, 4, [1]) state.begin_combat() var grotto_units: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.zone == "grotto" and u.team != 2) var mid_troops: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "troop" and u.team == 0 and u.lane == "mid") check(grotto_units.size() == 5 and mid_troops.size() == 1, "grotto troops leave the Mid lane (4 in grotto for Blau, 1 on Mid)") check(state.combat.grotto_boss != null and state.combat.grotto_boss.dormant, "boss waits while two expeditions are present") var boss_hp: float = state.combat.grotto_boss.hp var woke_step: int = -1 var zones_stable: bool = true while state.phase == LuneMatch.Phase.COMBAT: state.combat.step() for unit: LuneUnit in grotto_units: zones_stable = zones_stable and unit.zone == "grotto" if woke_step < 0 and not state.combat.grotto_boss.dormant: woke_step = state.combat.steps var rot_alive: bool = state.combat.units.any(func(u: LuneUnit) -> bool: return u.alive and u.zone == "grotto" and u.team == 1) check(not rot_alive and is_equal_approx(state.combat.grotto_boss.hp, boss_hp), "boss wakes untouched once only one expedition survives") if state.combat.finished: state._settle() check(woke_step > 0 and zones_stable, "expedition stays in the grotto for the whole combat phase") func _test_boss_success_lock_and_deployment() -> void: var state := new_match(26) for i: int in range(5): run_round(state) check(state.round_number == 6, "reached round 6") _expedition(state, 1, [1, 1, 1, 0, 2, 2, 2], "held_ringborke") state.begin_combat() state.finish_combat() check(state.last_settlement.boss == "won", "strong expedition conquers the boss in round 6") var claim: Dictionary = state.boss.claim check(claim.team == 0 and int(claim.won_round) == 6 and int(claim.deploy_round) == 7 and int(claim.chooser_slot) == 1 and claim.lane == "mid", "claim for Blau: deploy in 7, chooser is the contributing Mid player") check(int(state.boss.next_available_round) == 10, "grotto available again from round 10") var repeated: String = state._settle_boss() check(repeated == "repeat" and int(state.boss.next_available_round) == 10 and int(state.boss.claim.get("deploy_round", -1)) == 7, "repeating the settlement signal creates no second claim and keeps the first") check(state.announcements().any(func(a: Dictionary) -> bool: return a.kind == "boss" and a.team == 0 and a.lane == "mid"), "deployment announced with lane in the next planning") check(not state.submit(4, {"type": "boss_lane", "lane": "top"}).ok and not state.submit(0, {"type": "boss_lane", "lane": "top"}).ok, "only the chooser may redirect the boss") check(state.submit(1, {"type": "boss_lane", "lane": "top"}).ok, "chooser redirects the boss to Top") var entry_uid: String = state.players[1].roster[0].uid check(not state.submit(1, {"type": "assign", "uid": entry_uid, "lane": "grotto"}).ok, "grotto locked in round 7") state.submit(1, {"type": "assign", "uid": entry_uid, "lane": "mid"}) state.begin_combat() var bosses: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss") check(bosses.size() == 1 and bosses[0].team == 0 and bosses[0].lane == "top" and bosses[0].zone == "lane", "round 7: exactly one boss marches for Blau on Top") var start_progress: float = bosses[0].progress for i: int in range(200): state.combat.step() check(bosses[0].progress > start_progress + 5.0 or not bosses[0].alive, "the boss advances towards Rot's Ancient") bosses[0].hp = 0.0 state.combat.step() var bounty: int = LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * 7 state.finish_combat() var gains: Array = state.last_settlement.gains var rot_boss_part: bool = gains[3] + gains[4] + gains[5] >= bounty and abs(gains[3] - gains[4]) <= 1 + 60 and bounty > 10 * 30 check(rot_boss_part, "Rot receives the high boss bounty (%d), split in the team" % bounty) check(state.boss.claim.is_empty(), "claim consumed after the deployment round") for locked_round: int in [8, 9]: check(state.round_number == locked_round and not state.grotto_available(), "grotto locked in round %d" % locked_round) # Fixture: keep all buildings standing so the strong test army cannot end the match early. for id: String in state.building_hp: state.building_hp[id] = float(state.map.building_def(id).max_hp) run_round(state) check(state.round_number == 10 and state.grotto_available(), "grotto open again in round 10") state.begin_combat() check(state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss").is_empty(), "no second deployment") state.finish_combat() func _test_boss_failure_restores() -> void: var state := new_match(27) grant(state, 2, 200) state.submit(2, {"type": "buy", "type_id": "g01"}) state.submit(2, {"type": "assign", "uid": state.players[2].draft.roster[0].uid, "lane": "grotto"}) state.begin_combat() var first_hp: float = state.combat.grotto_boss.max_hp state.finish_combat() check(state.last_settlement.boss == "failed" and state.boss.claim.is_empty() and state.grotto_available(), "weak expedition fails without reward or lock") state.submit(2, {"type": "assign", "uid": state.players[2].roster[0].uid, "lane": "grotto"}) state.begin_combat() check(state.combat.grotto_boss.hp == state.combat.grotto_boss.max_hp and state.combat.grotto_boss.max_hp >= first_hp, "next attempt faces a fully restored boss") state.finish_combat() var no_gold: bool = true for event: Dictionary in state.combat.events: if event.zone == "grotto": no_gold = no_gold and int(event.bounty) == 0 check(no_gold, "grotto deaths pay no gold") func _test_bots_m2() -> void: var state := new_match(28) var heroes: int = 0 var bookings: int = 0 var grotto: int = 0 for round_index: int in range(12): if state.phase == LuneMatch.Phase.OVER: break for slot: int in range(6): LuneBot.plan(state, slot) for player: Dictionary in state.players: bookings += (player.draft.new_bookings as Array).size() for entry: Dictionary in player.draft.roster: grotto += int(entry.lane == "grotto") state.begin_combat() state.finish_combat() for player: Dictionary in state.players: heroes += int(not (player.hero as Dictionary).is_empty()) check(heroes >= 3, "bots recruit heroes (%d)" % heroes) check(bookings >= 1, "bots book attack champions (%d)" % bookings) check(grotto >= 1, "a bot sends an expedition to the grotto (%d troop assignments)" % grotto) func _test_no_legacy_economy() -> void: # Working assumption E: the Saumwacht worker/spark economy must not run in this mode. var phase_before: int = GameState.phase var legacy_before: Dictionary = EconomyManager.get_team(0) var state := new_match(29) for i: int in range(3): for slot: int in range(6): LuneBot.plan(state, slot) state.begin_combat() state.finish_combat() WaveManager.advance(1.0) check(GameState.phase == phase_before and EconomyManager.get_team(0) == legacy_before, "legacy phase, workers and sparks stay untouched") var has_spark_fields: bool = false for player: Dictionary in state.players: has_spark_fields = has_spark_fields or player.has("sparks") or player.has("workers") check(not has_spark_fields, "Luneraria players have gold only, no sparks or workers") func _test_lane_loss() -> void: # A player whose lane is broken is not eliminated: they keep planning, defending and helping. var state := new_match(30) state.building_hp["blau_top_outer"] = 0.0 state.building_hp["blau_top_inner"] = 0.0 state.players[0].draft = state._snapshot(state.players[0]) check(state.submit(0, {"type": "buy", "type_id": "g03"}).ok, "lane-less player can still buy") check(state.submit(0, {"type": "assign", "uid": state.players[0].draft.roster[0].uid, "lane": "mid"}).ok, "and support another lane") state.submit(0, {"type": "buy", "type_id": "g01"}) state.begin_combat() var top: int = 0 var mid: int = 0 for unit: LuneUnit in state.combat.units: if unit.owner_slot == 0: top += int(unit.lane == "top") mid += int(unit.lane == "mid") check(top == 1 and mid == 1, "troops fight on the broken lane and on the supported lane") check(LuneMap.is_attackable(state.map.building_def("blau_base_1"), state.combat.alive_map()), "the broken lane opens Blau's base towers") state.finish_combat() check(state.phase != LuneMatch.Phase.OVER or state.winner != LuneMatch.ONGOING, "the match continues or ends only by an Ancient") check(state.players[0].roster.size() == 2 and int(state.last_settlement.income) > 0, "lane-less player still owns troops and earns income")