extends Node ## Rules tests for the Champions of Luneraria core (no rendering). Legacy Saumwacht tests ## stay in tests/test_runner.gd. Run: godot --headless --path . res://tests/luneraria/test_lune_core.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("LUNE_FAIL: ", label) func _ready() -> void: call_deferred("run") func new_match(seed: int = 7, human: int = -1) -> LuneMatch: var state := LuneMatch.new() state.setup({"seed": seed, "human_slot": human, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]}) return state func run() -> void: _test_structure() _test_symmetry() _test_gates() _test_victory_and_draw() _test_endless_waves() _test_commands_and_ownership() _test_rewards() _test_persistence_and_restart() _test_full_match() _test_mirror_fairness() var report := {"checks": checks, "failures": failures} var file := FileAccess.open("res://docs/luneraria/logs/core-tests.json", FileAccess.WRITE) file.store_string(JSON.stringify(report, "\t")) file.close() print("LUNE_CORE_%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_structure() -> void: var state := new_match() check(state.players.size() == 6, "six player slots") var teams: Dictionary = {} var roles: Dictionary = {} for player: Dictionary in state.players: teams[player.team] = int(teams.get(player.team, 0)) + 1 roles["%d-%s" % [player.team, player.lane]] = true check(teams.size() == 2 and teams[0] == 3 and teams[1] == 3, "exactly two teams of three") check(roles.size() == 6, "six distinct owner roles (team x lane)") check(LuneMap.LANES == ["top", "mid", "bot"] and state.map.lanes.size() == 3, "three lanes") var towers: int = 0 var cores: int = 0 var per_team_lane: Dictionary = {} var base_towers: Array[int] = [0, 0] for definition: Dictionary in state.map.buildings: if definition.tier == "core": cores += 1 else: towers += 1 if definition.tier == "base": base_towers[definition.team] += 1 else: per_team_lane["%d-%s" % [definition.team, definition.lane]] = int(per_team_lane.get("%d-%s" % [definition.team, definition.lane], 0)) + 1 check(towers == 16 and cores == 2, "sixteen towers and two Ancients") check(per_team_lane.size() == 6 and per_team_lane.values().all(func(v: int) -> bool: return v == 2), "two lane towers per team and lane") check(base_towers == [2, 2], "two base towers per team") func _test_symmetry() -> void: var map := LuneMap.new() for lane: String in LuneMap.LANES: var path: LuneMap.LanePath = map.lanes[lane] var worst: float = 0.0 for i: int in range(21): var s: float = path.length * float(i) / 20.0 worst = maxf(worst, LuneMap.mirror(path.point_at(s)).distance_to(path.point_at(path.length - s))) check(worst < 0.001, "%s lane is its own mirror image (team fairness)" % lane) check(absf(map.lanes["top"].length - map.lanes["bot"].length) < 0.001, "top and bottom lanes have equal length") for definition: Dictionary in map.buildings: if definition.team == 0: var twin: Dictionary = map.building_def(str(definition.id).replace("blau", "rot")) check(LuneMap.mirror(definition.position).distance_to(twin.position) < 0.001 and is_equal_approx(float(definition.max_hp), float(twin.max_hp)), "mirrored building " + str(definition.id)) for lane: String in LuneMap.LANES: check(map.lane_point(1, lane, 7.0, 1.2).distance_to(LuneMap.mirror(map.lane_point(0, lane, 7.0, 1.2))) < 0.001, "mirrored spawn points on " + lane) check(LuneMap.mirror(LuneMap.GROTTO).distance_to(LuneMap.GROTTO) < 0.001, "grotto equidistant for both teams") var mid: LuneMap.LanePath = map.lanes["mid"] var grotto_gap: float = mid.point_at(mid.project(LuneMap.GROTTO)).distance_to(LuneMap.GROTTO) check(grotto_gap > 8.0, "grotto lies beside, not on, the mid lane (%.1f m)" % grotto_gap) func _test_gates() -> void: var map := LuneMap.new() var alive: Dictionary = {} for definition: Dictionary in map.buildings: alive[definition.id] = true var gate := func(id: String) -> bool: return LuneMap.is_attackable(map.building_def(id), alive) check(gate.call("rot_top_outer") and not gate.call("rot_top_inner"), "outer before inner tower") check(not gate.call("rot_base_1") and not gate.call("rot_ancient"), "base and Ancient protected at start") alive["rot_top_outer"] = false check(gate.call("rot_top_inner") and not gate.call("rot_base_1"), "inner opens after outer; base still closed") alive["rot_mid_outer"] = false alive["rot_bot_outer"] = false check(not gate.call("rot_base_1"), "outer towers of all lanes do not open the base") alive["rot_top_inner"] = false check(gate.call("rot_base_1") and gate.call("rot_base_2") and not gate.call("rot_ancient"), "one broken lane opens the base, not the Ancient") check(gate.call("rot_mid_inner") and gate.call("rot_bot_inner"), "towers of other lanes keep working and stay attackable in order") alive["rot_base_1"] = false check(not gate.call("rot_ancient"), "Ancient needs both base towers down") alive["rot_base_2"] = false check(gate.call("rot_ancient"), "Ancient vulnerable after both base towers") # Gate applies to every damage source inside combat, including splash. var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1) var inner: LuneUnit = combat.add_building(map.building_def("rot_top_inner"), 1000.0) combat.add_building(map.building_def("rot_top_outer"), 1000.0) var gates: Dictionary = combat.alive_map() combat._queue_damage(inner, 500.0, gates) check(not combat._pending_damage.has(inner.id), "locked inner tower ignores queued damage (ranged/splash/DoT)") func _duel(core_hp_a: float, core_hp_b: float) -> LuneCombat: var map := LuneMap.new() var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1) for definition: Dictionary in map.buildings: if definition.tier == "core": combat.add_building(definition, core_hp_a if definition.team == 0 else core_hp_b) else: combat.add_building(definition, 0.0) return combat func _attacker(combat: LuneCombat, team: int, damage: float) -> LuneUnit: var definition: Dictionary = DataCatalog.get_unit("g05").duplicate(true) definition.damage = damage var unit: LuneUnit = combat.spawn("troop", team, "mid", definition, 1.0, 0.0) unit.position = combat.map.core_position(1 - team) + Vector2(2.5, 0).rotated(team) unit.reached_base = true return unit func _test_victory_and_draw() -> void: var combat := _duel(12000.0, 30.0) _attacker(combat, 0, 200.0) for i: int in range(200): combat.step() if combat.finished: break check(combat.finished and combat.end_reason == "core" and combat.destroyed_cores == [1], "destroying an Ancient ends the combat with a core result") var draw := _duel(30.0, 30.0) _attacker(draw, 1, 200.0) _attacker(draw, 0, 200.0) for i: int in range(200): draw.step() if draw.finished: break check(draw.destroyed_cores.size() == 2, "both Ancients in the same step are both recorded") var state := new_match() state.begin_combat() state.combat.destroyed_cores = [0, 1] state.combat.finished = true state.combat.end_reason = "core" state._settle() check(state.phase == LuneMatch.Phase.OVER and state.winner == LuneMatch.DRAW, "simultaneous Ancient loss is a draw") # Unit order must not decide: reversing the spawn order gives the same result. var reversed := _duel(30.0, 30.0) _attacker(reversed, 0, 200.0) _attacker(reversed, 1, 200.0) for i: int in range(200): reversed.step() if reversed.finished: break check(reversed.destroyed_cores.size() == 2 and reversed.steps == draw.steps, "processing order gives no advantage") var timeout := LuneMatch.new() timeout.setup({"seed": 3, "human_slot": -1}) timeout.begin_combat() timeout.combat.elapsed = timeout.balance.combat_time_limit timeout.step_combat() check(timeout.combat.end_reason == "timeout" and timeout.phase == LuneMatch.Phase.PLANNING and timeout.winner == LuneMatch.ONGOING, "combat time window ends the phase, never the match") check(timeout.core_hp(0) == timeout.balance.core_hp and timeout.core_hp(1) == timeout.balance.core_hp, "time window causes no hidden core damage") func _test_endless_waves() -> void: var balance := LuneBalance.default_balance() var previous: float = 0.0 for round_number: int in [1, 5, 8, 20, 21, 50, 200, 5000, 1000000]: var wave: Array[Dictionary] = LuneWaves.build(balance, round_number) var total: float = 0.0 var finite: bool = true for monster: Dictionary in wave: total += float(monster.max_hp) + float(monster.damage) * 10.0 finite = finite and is_finite(float(monster.max_hp)) and is_finite(float(monster.damage)) and int(monster.bounty) > 0 check(finite and wave.size() <= balance.wave_max_count and wave.size() >= 1, "wave %d finite and capped (%d monsters)" % [round_number, wave.size()]) check(total > previous, "wave %d stronger than the previous sample" % round_number) previous = total var state := new_match(11) state.round_number = 21 state.begin_combat() var monsters: int = 0 for unit: LuneUnit in state.combat.units: if unit.kind == "monster": monsters += 1 check(monsters == 6 * LuneWaves.monster_count(balance, 21), "round 21 spawns ordinary waves; no final wave logic") state.finish_combat() check(state.phase != LuneMatch.Phase.OVER or state.winner != LuneMatch.ONGOING, "no match end without a core result") check(state.round_number == 22 or state.phase == LuneMatch.Phase.OVER, "round counter continues after 20") func _test_commands_and_ownership() -> void: var state := new_match(5, 1) var gold: int = state.gold_of(1) check(not state.submit(1, {"type": "buy", "type_id": "g01"}).ok, "buying another Bund's troop is rejected") var bought: Dictionary = state.submit(1, {"type": "buy", "type_id": "w01"}) check(bought.ok and state.gold_of(1) == gold - 50, "buy costs exactly the catalogue price") var uid: String = str(state.players[1].draft.roster[0].uid) check(not state.submit(0, {"type": "upgrade", "uid": uid}).ok, "foreign unit id is rejected") check(not state.submit(1, {"type": "upgrade", "uid": "p1-9999"}).ok, "unknown unit id is rejected") check(state.players[1].roster.is_empty(), "draft purchases are not committed before the phase change") check(state.submit(1, {"type": "assign", "uid": uid, "lane": "top"}).ok and not state.submit(1, {"type": "assign", "uid": uid, "lane": "grotte"}).ok, "lane assignment validated") state.submit(1, {"type": "sell", "uid": uid}) check(state.gold_of(1) == gold, "cancelling a purchase in the same planning refunds fully") state.submit(1, {"type": "buy", "type_id": "w01"}) state.players[1].draft.gold = -5 state.begin_combat() check(state.players[1].roster.is_empty() and state.players[1].gold == gold, "invalid draft is rejected atomically at the phase change") state.finish_combat() var cap := new_match(6, 0) cap.players[0].draft.gold = 100000 for i: int in range(cap.balance.supply_cap + 3): cap.submit(0, {"type": "buy", "type_id": "g01"}) check(cap.players[0].draft.roster.size() == cap.balance.supply_cap, "supply cap enforced") check(not cap.submit(0, {"type": "buy", "type_id": "g01"}).ok and cap.phase == LuneMatch.Phase.PLANNING, "no purchases beyond the cap") cap.begin_combat() check(not cap.submit(0, {"type": "buy", "type_id": "g03"}).ok, "no commands during combat") var bots := new_match(9) var before: Array = bots.players.map(func(p: Dictionary) -> int: return int(p.gold)) for slot: int in range(6): LuneBot.plan(bots, slot) var spent_ok: bool = true for slot: int in range(6): var invested: int = 0 for entry: Dictionary in bots.players[slot].draft.roster: invested += int(entry.invested) spent_ok = spent_ok and invested + int(bots.players[slot].draft.gold) == int(before[slot]) check(spent_ok, "bots spend exactly their own gold, no extra resources") func _test_rewards() -> void: var state := new_match(4) state.begin_combat() var combat: LuneCombat = state.combat combat.events.clear() combat.events.append({"serial": 1, "team": 1, "lane": "top", "zone": "lane", "kind": "monster", "bounty": 101}) combat.events.append({"serial": 2, "team": 1, "lane": "top", "zone": "lane", "kind": "monster", "bounty": 101}) combat.events.append({"serial": 3, "team": 0, "lane": "mid", "zone": "lane", "kind": "tower", "bounty": 151, "building_id": "blau_mid_outer"}) combat.events.append({"serial": 4, "team": 0, "lane": "bot", "zone": "lane", "kind": "troop", "bounty": 0}) combat.finished = true combat.end_reason = "cleared" var gold_before: Array = state.players.map(func(p: Dictionary) -> int: return int(p.gold)) state._settle() var gains: Array = state.last_settlement.gains check(gains[0] + gains[1] + gains[2] == 202, "each monster death pays exactly once, total 2 x 101 to Blau") check(gains[0] >= 50 + 50 and gains[0] - gains[1] >= 90, "Top lane player of the killing team gets the lane share") check(gains[3] + gains[4] + gains[5] == 151, "tower bounty exactly once to Rot") check(abs(gains[3] - gains[4]) <= 1 and abs(gains[4] - gains[5]) <= 1, "tower bounty split evenly with integer remainder") var income: int = state.last_settlement.income var paid: bool = true for slot: int in range(6): paid = paid and int(state.players[slot].gold) == int(gold_before[slot]) + gains[slot] + income check(paid, "settlement credits gains plus income, nothing else") check(gains[3] + gains[4] + gains[5] + gains[0] + gains[1] + gains[2] == 353, "persistent troop deaths pay nothing") func _test_persistence_and_restart() -> void: var state := new_match(8, 0) state.submit(0, {"type": "buy", "type_id": "g03"}) state.set_ready(0) var uid: String = str(state.players[0].draft.roster[0].uid) state.begin_combat() state.combat.buildings["blau_mid_outer"].hp -= 1234.0 var fallen: LuneUnit = null for unit: LuneUnit in state.combat.units: if unit.persistent_uid == uid: fallen = unit fallen.hp = 0.0 fallen.alive = false state.finish_combat() check(state.phase == LuneMatch.Phase.PLANNING and state.round_number == 2, "round advances to planning") check(state.players[0].roster.size() == 1 and state.players[0].roster[0].uid == uid, "fallen persistent troop returns to the roster") check(is_equal_approx(state.building_hp["blau_mid_outer"], state.balance.tower_outer_hp - 1234.0) or state.building_hp["blau_mid_outer"] < state.balance.tower_outer_hp - 1234.0, "building damage persists between rounds") state.begin_combat() var healed: bool = false for unit: LuneUnit in state.combat.units: if unit.persistent_uid == uid: healed = unit.hp == unit.max_hp and unit.effects.is_empty() and unit.dots.is_empty() check(healed, "returning troop starts the next combat healed and without effects") state.finish_combat() var fresh := new_match(8, 0) check(fresh.players[0].roster.is_empty() and fresh.round_number == 1 and fresh.building_hp["blau_mid_outer"] == fresh.balance.tower_outer_hp, "new match starts without previous progress") func _test_full_match() -> void: var state := new_match(2) var limit: int = 80 while state.phase != LuneMatch.Phase.OVER and state.round_number <= limit: for slot: int in range(6): LuneBot.plan(state, slot) state.begin_combat() state.finish_combat() check(state.phase == LuneMatch.Phase.OVER, "a six-bot match reaches a regular Ancient result (seed 2)") check(state.winner in [0, 1, LuneMatch.DRAW], "result is Blau, Rot or draw") var cores_down: int = int(state.core_hp(0) <= 0.0) + int(state.core_hp(1) <= 0.0) check(cores_down >= 1, "the result follows an actually destroyed Ancient") print("LUNE_FULL_MATCH: seed 2 -> %s after %d rounds" % ["draw" if state.winner == LuneMatch.DRAW else ("Blau" if state.winner == 0 else "Rot"), state.history.size()]) func _test_mirror_fairness() -> void: # Identical Bund and deterministic bot profile on all six slots: the match must stay an # exact mirror image. Any drift would reveal an order- or id-dependent team advantage. for bund: String in ["glutpakt", "wurzelbund"]: var state := LuneMatch.new() state.setup({"seed": 3, "human_slot": -1, "bunds": [bund, bund, bund, bund, bund, bund]}) var mirrored: bool = true for round_index: int in range(12): for slot: int in range(6): LuneBot.plan(state, slot, "verteidigung") state.begin_combat() state.finish_combat() for definition: Dictionary in state.map.buildings: if definition.team == 0: var twin: String = str(definition.id).replace("blau", "rot") mirrored = mirrored and is_equal_approx(float(state.building_hp[definition.id]), float(state.building_hp[twin])) for slot: int in range(3): mirrored = mirrored and int(state.players[slot].gold) == int(state.players[slot + 3].gold) if state.phase == LuneMatch.Phase.OVER: break check(mirrored, "%s mirror match stays exactly symmetric for 12 rounds (buildings and gold)" % bund) check(state.phase != LuneMatch.Phase.OVER or state.winner == LuneMatch.DRAW, "%s mirror match never produces a one-sided winner" % bund)