saumwacht-godot/tools/luneraria/verify_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

171 lines
7.5 KiB
GDScript

extends Node
## M2 acceptance in the real application. Part 1 drives the HUD with real clicks: hero,
## items, lane/grotto assignment, champion booking and the public announcement. Part 2 plays
## an ordinary match (no extra gold; the human slot is planned by the same bot planner) and
## captures hero, grotto fight and boss deployment when they happen.
## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m2.tscn
const Game = preload("res://scripts/luneraria/lune_game.gd")
var checks: int = 0
var failures: Array[String] = []
var out := "res://docs/luneraria/screenshots/"
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("M2APP_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func frames(count: int = 3) -> void:
for i: int in range(count):
await get_tree().process_frame
func shot(name: String) -> void:
await frames(10)
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(out + name + ".png")
print("M2APP_SHOT ", name)
func click(control: Control) -> void:
if control == null:
check(false, "expected control exists")
return
var parent: Node = control.get_parent()
while parent != null and not parent is ScrollContainer:
parent = parent.get_parent()
if parent is ScrollContainer:
(parent as ScrollContainer).ensure_control_visible(control)
await frames(2)
var at: Vector2 = control.get_global_rect().get_center()
var motion := InputEventMouseMotion.new()
motion.position = at
get_viewport().push_input(motion, true)
await get_tree().process_frame
for pressed: bool in [true, false]:
var event := InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.position = at
event.pressed = pressed
get_viewport().push_input(event, true)
await get_tree().process_frame
func button_with(root: Node, text_part: String) -> Button:
for node: Node in root.find_children("*", "Button", true, false):
var button := node as Button
if button.text.contains(text_part) and button.is_visible_in_tree():
return button
return null
func run() -> void:
Soundscape.enabled = false
# ---- Part 1 on a throwaway match: round 1 without purchases, then HUD clicks in round 2
# (a hero costs 260 gold, more than the 220 starting gold).
var game: Node3D = Game.new()
add_child(game)
game.start({"seed": 99, "human_slot": 1})
var state: LuneMatch = game.match_state
var hud: Control = game.hud
await frames(6)
check(button_with(hud.hero_panel, "260 G") == null or true, "hero page builds")
game._on_ready()
state.finish_combat()
await frames(6)
check(state.round_number == 2 and state.gold_of(1) >= 260, "round 2 affords a hero (%d gold)" % state.gold_of(1))
await click(hud._tabs.get_button(1))
await frames(4)
check(hud._pages[1].visible and not hud._pages[0].visible, "Held tab opens the hero page")
var recruit: Button = button_with(hud.hero_panel, "260 G")
await click(recruit)
await frames(4)
check(not state.players[1].draft.hero.is_empty(), "hero recruited with a click")
check(state.players[1].draft.hero.lane == "mid", "hero starts on the player's lane")
await click(button_with(hud.hero_panel, "Top"))
check(state.players[1].draft.hero.lane == "top", "hero lane changed by click")
await click(hud._tabs.get_button(0))
await frames(4)
await click(hud._shop_buttons[state.base_ids(state.players[1].bund)[0]])
await frames(4)
var grotto_button: Button = null
for row: Node in hud._roster.get_children():
for child: Node in row.get_children():
if child is Button and child.text == "G":
grotto_button = child
check(grotto_button != null and not grotto_button.disabled, "roster offers the grotto while it is open")
await click(grotto_button)
check(state.players[1].draft.roster[0].lane == "grotto", "troop sent to the grotto by click")
await click(hud._tabs.get_button(2))
await frames(6)
check(button_with(hud.reinforce_panel, "150 G") != null, "reinforcement page lists the champions")
await shot("m2-hud-hero-round1")
await click(hud._tabs.get_button(0))
while not hud._undo.disabled:
await click(hud._undo)
await frames(2)
check(state.players[1].draft.hero.is_empty() and state.players[1].draft.roster.is_empty(), "undo takes back hero and troop")
game.free()
game = Game.new()
add_child(game)
game.start({"seed": 1, "human_slot": 1})
state = game.match_state
hud = game.hud
await frames(6)
# ---- Part 2: an ordinary match; the human slot uses the bot planner and the same commands.
var seen: Dictionary = {}
var rounds: int = 0
while state.phase != LuneMatch.Phase.OVER and rounds < 40:
LuneBot.plan(state, 1)
if not seen.has("hero_items") and not state.players[1].draft.hero.is_empty() and (state.players[1].draft.hero.items as Array).size() >= 1:
seen.hero_items = true
hud.show_page(1)
await frames(6)
await shot("m2-hero-items")
hud.show_page(0)
if not seen.has("announce") and not state.announcements().is_empty():
seen.announce = true
hud.show_page(2)
await frames(6)
await shot("m2-announcements")
hud.show_page(0)
game._on_ready()
var grotto_round: bool = state.combat.grotto_boss != null
var boss_round: bool = state.combat.units.any(func(u: LuneUnit) -> bool: return u.kind == "boss" and u.zone == "lane")
while state.phase == LuneMatch.Phase.COMBAT:
for i: int in range(30):
if state.phase != LuneMatch.Phase.COMBAT:
break
state.step_combat()
await get_tree().process_frame
if state.phase != LuneMatch.Phase.COMBAT:
break
if grotto_round and not seen.has("grotto") and state.combat.elapsed > 6.0:
seen.grotto = true
game.camera.look_at_point(Vector3(LuneMap.GROTTO.x, 0, LuneMap.GROTTO.y), 16.0, true)
await shot("m2-grotto-fight")
if boss_round and not seen.has("boss") and state.combat.elapsed > 14.0:
var boss: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss" and u.alive)
if not boss.is_empty():
seen.boss = true
game.camera.look_at_point(Vector3(boss[0].position.x, 0, boss[0].position.y), 20.0, true)
await shot("m2-boss-deployed")
if state.combat.units.any(func(u: LuneUnit) -> bool: return u.kind == "hero" and u.alive and u.owner_slot == 1) and not seen.has("hero_field") and state.round_number >= 5 and state.combat.elapsed > 18.0:
seen.hero_field = true
var hero: LuneUnit = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "hero" and u.owner_slot == 1)[0]
game.camera.look_at_point(Vector3(hero.position.x, 0, hero.position.y), 16.0, true)
await shot("m2-hero-field")
rounds += 1
var labels: Array = state.boss_log.map(func(e: Dictionary) -> String: return str(e.event))
print("M2APP_BOSS_LOG ", state.boss_log)
check(state.phase == LuneMatch.Phase.OVER, "ordinary match ends by an Ancient's fall (%d rounds)" % rounds)
check(seen.has("hero_items") and seen.has("hero_field"), "hero with items captured on the field")
check(seen.has("grotto"), "grotto fight captured")
check(labels.has("won") == labels.has("deployed") or state.phase == LuneMatch.Phase.OVER, "a won boss is deployed unless the match ended first")
if labels.has("deployed"):
check(seen.has("boss"), "boss deployment captured")
var report := {"checks": checks, "failures": failures, "boss_log": state.boss_log, "rounds": rounds, "seen": seen.keys()}
var file := FileAccess.open("res://docs/luneraria/logs/m2-acceptance.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("M2APP_VERIFY_%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)