saumwacht-godot/tests/luneraria/test_lune_m2.gd
Martin Stickelbroeck f53859a298 M2: Helden, Items, Talente, Angriffschampions und Bossgrotte
Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen,
drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall,
ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das
Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt
aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73,
M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:33:11 +02:00

344 lines
20 KiB
GDScript

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")