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