saumwacht-godot/tests/luneraria/test_lune_core.gd
Martin Stickelbroeck b565546c37 M1: autoritativer Drei-Lane-Kern (Karte, Kampf, Partie, Wellen, Bots)
LuneMap spiegelsymmetrisch mit 16 Türmen und 2 Ancients, LuneCombat mit
reihenfolgeunabhängigem Zweiphasenschritt und Gebäudesperren für jede
Schadensquelle, LuneMatch mit Planungsentwürfen, atomarer Übernahme,
Einmal-Belohnung und Kernsieg/Unentschieden, endlose LuneWaves, Bots über
dieselbe Befehlsschnittstelle. 87 Kernprüfungen bestanden.

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

297 lines
16 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()
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()])