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

246 lines
11 KiB
GDScript

extends Node
## M1 acceptance in the real application with real mouse and key events: menu, planning,
## commit, combat, pause/speed, settlement, return to menu, restart, a complete match to an
## Ancient's fall, result screen, rematch, window sizes and repeated menu/match cycles.
## Only the combat clock is fast-forwarded (more fixed steps per frame); rules are untouched.
## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m1.tscn
var main: Node
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("M1_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(8)
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(out + name + ".png")
print("M1_SHOT ", name)
func click(control: Control) -> void:
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 key(code: Key) -> void:
for pressed: bool in [true, false]:
var event := InputEventKey.new()
event.keycode = code
event.physical_keycode = code
event.pressed = pressed
get_viewport().push_input(event)
await get_tree().process_frame
var core_shot_taken: bool = false
func fast_combat(game: Node3D, steps_per_frame: int = 40, capture: String = "") -> void:
var captured: bool = capture.is_empty()
while game.match_state.phase == LuneMatch.Phase.COMBAT:
for i: int in range(steps_per_frame):
if game.match_state.phase != LuneMatch.Phase.COMBAT:
break
# Slow down near an Ancient's fall to capture the siege in the live combat view.
if not core_shot_taken and _weak_core(game) >= 0:
core_shot_taken = true
var team: int = _weak_core(game)
var at: Vector2 = game.match_state.map.core_position(team)
game.camera.look_at_point(Vector3(at.x, 0, at.y), 22.0, true)
await shot("core-siege")
game.match_state.step_combat()
await get_tree().process_frame
if not captured and game.match_state.phase == LuneMatch.Phase.COMBAT and game.match_state.combat.elapsed > 25.0:
captured = true
await shot(capture)
func _weak_core(game: Node3D) -> int:
var combat: LuneCombat = game.match_state.combat
for team: int in range(2):
var core: LuneUnit = combat.buildings["blau_ancient" if team == 0 else "rot_ancient"]
if core.alive and core.hp < core.max_hp * .3:
return team
return -1
func inside(control: Control, label: String) -> void:
var view := Rect2(Vector2.ZERO, get_viewport().get_visible_rect().size)
check(control.is_visible_in_tree() and view.encloses(control.get_global_rect()), label)
func run() -> void:
Soundscape.enabled = false
var started: int = Time.get_ticks_msec()
main = load("res://scenes/luneraria.tscn").instantiate()
add_child(main)
var menu: MainMenu = main.menu
check(ProjectSettings.get_setting("application/config/name") == "Champions of Luneraria", "project renamed to Champions of Luneraria")
check(menu.title_label.text == "CHAMPIONS" and menu.subtitle_label.text.contains("LUNERARIA"), "menu shows the new title")
while not (menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund")):
await get_tree().process_frame
print("M1_MENU_READY_MS ", Time.get_ticks_msec() - started)
await click(menu.new_game_button)
while menu.is_transitioning():
await get_tree().process_frame
await click(menu.lane_choice.get_button(0))
check(menu.lane == "top", "lane choice by click")
await click(menu.faction_choice.cards[0])
check(menu.bund == "glutpakt", "Bund choice by click")
await click(menu.start_button)
var game: Node3D = main.game
check(is_instance_valid(game) and game.human_slot == 0 and game.match_state.players[0].bund == "glutpakt", "start button launches a match for Blau Top with the chosen Bund")
var focus_before: Vector3 = game.camera._goal_focus
# Clicks and game keys during the fade must not reach the board.
var event := InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_WHEEL_UP
event.pressed = true
event.position = Vector2(900, 500)
get_viewport().push_input(event, true)
await key(KEY_1)
await key(KEY_SPACE)
check(game.match_state.players[0].draft.roster.is_empty() and game.match_state.phase == LuneMatch.Phase.PLANNING and is_equal_approx(game.camera._goal_zoom, 24.0), "no click or key reaches the game during the menu fade")
await get_tree().create_timer(.5).timeout
check(not menu.visible, "menu hides after the fade")
var state: LuneMatch = game.match_state
var hud: Control = game.hud
check(state.players.size() == 6 and state.players.filter(func(p: Dictionary) -> bool: return p.is_human).size() == 1, "one human and five bots")
check(state.round_number == 1 and state.phase == LuneMatch.Phase.PLANNING, "match opens in planning")
var gold0: int = state.gold_of(0)
await click(hud._starter)
check(state.players[0].draft.roster.size() == 3 and state.gold_of(0) == gold0 - 180, "Startaufstellung buys three Glutpakt troops for 180 gold")
await key(KEY_1)
check(state.players[0].draft.roster.size() == 3, "key 1 without enough gold is rejected")
await click(hud._undo)
check(state.players[0].draft.roster.size() == 2, "undo removes the last purchase")
await frames(4)
var lane_button: Button = null
for child: Node in hud._roster.get_children():
for grand: Node in child.get_children():
if grand is Button and grand.text == "M":
lane_button = grand
break
if lane_button != null:
break
await click(lane_button)
check(state.players[0].draft.roster.any(func(e: Dictionary) -> bool: return e.lane == "mid"), "lane assignment button moves a troop to Mid")
await click(hud._shop_buttons["g01"])
check(state.players[0].draft.roster.size() == 3, "shop click buys a troop")
for size: Vector2i in [Vector2i(1280, 720), Vector2i(1920, 1080)]:
get_window().size = size
await frames(6)
inside(hud._ready_button, "Bereit inside %dx%d" % [size.x, size.y])
inside(hud.minimap, "minimap inside %dx%d" % [size.x, size.y])
inside(hud._gold, "gold readout inside %dx%d" % [size.x, size.y])
await shot("planning-%dx%d" % [size.x, size.y])
var draft_uids: Array = state.players[0].draft.roster.map(func(e: Dictionary) -> String: return e.uid)
await key(KEY_SPACE)
check(state.phase == LuneMatch.Phase.COMBAT, "Space commits planning and starts combat")
check(state.players[0].roster.map(func(e: Dictionary) -> String: return e.uid) == draft_uids, "committed roster equals the draft")
var troops_on_top: int = 0
var troops_on_mid: int = 0
for unit: LuneUnit in state.combat.units:
if unit.owner_slot == 0:
troops_on_top += int(unit.lane == "top")
troops_on_mid += int(unit.lane == "mid")
check(troops_on_top == 2 and troops_on_mid == 1, "troops fight on their assigned lanes")
check(not state.submit(0, {"type": "buy", "type_id": "g01"}).ok, "no purchases during combat")
await get_tree().create_timer(1.0).timeout
await key(KEY_P)
var paused_time: float = state.combat.elapsed
await get_tree().create_timer(.5).timeout
check(game.paused and is_equal_approx(state.combat.elapsed, paused_time), "P pauses the combat clock")
await key(KEY_P)
await click(hud._speed)
check(game.SPEEDS[game.speed_index] == 2, "speed button switches to 2x")
game.focus_lane("mid", true)
await shot("combat-mid-live")
await fast_combat(game)
check(state.phase == LuneMatch.Phase.PLANNING and state.round_number == 2, "combat settles into planning round 2")
check(state.players[0].roster.size() == 3, "troops return after combat")
check(not state.last_settlement.is_empty() and int(state.last_settlement.income) > 0, "settlement pays income")
await frames(4)
check(hud._report.text.begins_with("Welle 1 abgeschlossen"), "HUD reports the settlement")
await shot("settlement")
# Menu and restart: a fresh match without previous progress.
await click(find_menu_button(hud))
await frames(4)
check(menu.visible and not is_instance_valid(game), "Menü returns to the title and frees the match")
await click(menu.new_game_button)
while menu.is_transitioning():
await get_tree().process_frame
await click(menu.start_button)
await get_tree().create_timer(.5).timeout
game = main.game
state = game.match_state
hud = game.hud
check(state.round_number == 1 and state.players[0].roster.is_empty() and state.gold_of(0) == state.balance.start_gold, "new match starts without previous progress")
# A complete match: the human's planning uses the same bot planner through the same commands.
var rounds: int = 0
var shots_taken: Dictionary = {}
while state.phase != LuneMatch.Phase.OVER and rounds < 60:
LuneBot.plan(state, game.human_slot, "druck")
game._on_ready()
if not shots_taken.has("lane") and state.round_number >= 3:
shots_taken.lane = true
game.focus_lane("top", true)
await fast_combat(game, 40, "combat-top")
elif not shots_taken.has("overview") and state.round_number >= 5:
shots_taken.overview = true
game.camera.overview(true)
await fast_combat(game, 40, "overview-combat")
else:
await fast_combat(game)
rounds += 1
check(state.phase == LuneMatch.Phase.OVER, "a complete match ends by an Ancient's fall (%d rounds)" % rounds)
var destroyed: Array = state.combat.destroyed_cores
check(destroyed.size() >= 1 and state.core_hp(destroyed[0]) <= 0.0, "the losing Ancient is actually destroyed")
check(core_shot_taken, "the siege of the falling Ancient was captured live")
await get_tree().create_timer(.8).timeout
await shot("result")
check(hud._over.visible and hud._over_title.text in ["SIEG", "NIEDERLAGE", "UNENTSCHIEDEN"], "result screen shows the outcome")
print("M1_MATCH_RESULT ", state.end_note, " after ", state.round_number, " waves")
var rematch: Button = null
for node: Node in hud._over.find_children("*", "Button", true, false):
if (node as Button).text == "NEUE PARTIE":
rematch = node
await click(rematch)
await frames(3)
check(is_instance_valid(main.game) and main.game != game and main.game.match_state.round_number == 1, "rematch starts a fresh match")
# Repeated menu/match cycles must not leak objects.
var objects: Array[float] = []
for cycle: int in range(4):
main.return_to_menu()
await frames(6)
main.start_match({"seed": 100 + cycle})
await get_tree().create_timer(.5).timeout
await frames(4)
objects.append(Performance.get_monitor(Performance.OBJECT_COUNT))
check(objects[3] <= objects[1] * 1.03 + 50, "no growing object count over menu/match cycles (%s)" % str(objects))
var report := {"checks": checks, "failures": failures, "object_counts": objects}
var file := FileAccess.open("res://docs/luneraria/logs/m1-acceptance.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("M1_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)
func find_menu_button(hud: Control) -> Button:
for node: Node in hud.find_children("*", "Button", true, false):
if (node as Button).text == "Menü" and (node as Button).is_visible_in_tree():
return node
return null