saumwacht-godot/tools/luneraria/verify_m1.gd
Martin Stickelbroeck 1b540f2a55 G3 (Zwischenstand): neues Ingame-HUD; Projekt wechselt nach Unity
Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of
Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das
HUD im Unity-Projekt (../luneraria-unity).

- HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen
  (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer
  Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse,
  Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails,
  Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben
- Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie
- Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715;
  verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern
- Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md
- HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:59:55 +02:00

245 lines
12 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)
# G3 HUD: select the troop in the army strip, then order it to Mid in the detail panel.
await click(hud._chips.values()[0] if not hud._chips.is_empty() else null)
await frames(3)
var lane_button: Button = hud.unit_detail._lanes.get("mid") if hud.unit_detail.visible else null
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):
var caption: String = (node as Button).text
if node.has_meta("label"):
caption = (node.get_meta("label") as Label).text
if caption == "Menü" and (node as Button).is_visible_in_tree():
return node
return null