saumwacht-godot/tests/luneraria/test_lune_core.gd
Martin Stickelbroeck c27a0097c3 M1: spielbarer Drei-Lane-Kern mit Karte, HUD, Menü und Abnahme
Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die
alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm,
HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden
aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller
(Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch.
Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:37:23 +02:00

321 lines
17 KiB
GDScript

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)