extends Node ## G3 acceptance: the in-match HUD in the real game (LuneGame with the real HUD, real input). ## Part A layout: every region inside the window and free of overlaps at 1280×720, 1920×1080, ## 2560×1440, ultrawide 2560×1080 and the minimum window, at several UI scales ## Part B match status: Ancients, towers, lanes, unlimited waves (two and three digits), gold ## Part C hero, items, normal units (no item slots), lane orders with the grotto warning ## Part D attack champions (temporary), the boss grotto in every state, target lane, banner ## Part E Ready lock, bots, the network guest (privacy, same public information), disconnect ## Part F input: minimap click, camera hotkeys, lane clicks, no click passthrough, tooltips ## Part G events, critical alerts and "Bewegung reduzieren" ## Part H update rates and the portrait cache ## Run (window needed): godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g3.tscn const OUT := "res://docs/luneraria/logs/g3/verify-g3.json" const Game = preload("res://scripts/luneraria/lune_game.gd") const Hud = preload("res://scripts/luneraria/ui/lune_hud.gd") const BUNDS: Array = ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"] var checks: int = 0 var failures: Array[String] = [] var facts: Dictionary = {} var parts: Dictionary = {} var _part: String = "" var game: Node3D func check(ok: bool, label: String) -> void: checks += 1 parts[_part] = int(parts.get(_part, 0)) + 1 if not ok: failures.append("[%s] %s" % [_part, label]) printerr("G3_FAIL: [", _part, "] ", label) func part(name: String) -> void: _part = name print("G3_PART ", name) func _ready() -> void: call_deferred("run") func run() -> void: Soundscape.enabled = false var started: int = Time.get_ticks_msec() var scale_before: float = Preferences.ui_scale var motion_before: bool = Preferences.reduced_motion Preferences.reduced_motion = false part("A layout") await _layout() part("B status") await _status() part("C hero and units") await _hero_and_units() part("D champions and boss") await _champions_and_boss() part("E ready, bots, network") await _ready_bots_network() part("F input") await _input_checks() part("G events and motion") await _events_and_motion() part("H rates and portraits") await _rates() await _free_game() Preferences.ui_scale = scale_before Preferences.reduced_motion = motion_before facts.seconds = (Time.get_ticks_msec() - started) / 1000.0 facts.checks_per_part = parts DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT.get_base_dir())) var file := FileAccess.open(OUT, FileAccess.WRITE) file.store_string(JSON.stringify({"checks": checks, "failures": failures, "facts": facts}, "\t")) file.close() print("G3_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) # ---------------------------------------------------------------- helpers func frames(count: int = 1) -> void: for i: int in count: await get_tree().process_frame func wait(seconds: float) -> void: await get_tree().create_timer(seconds).timeout func _free_game() -> void: if game != null and is_instance_valid(game): game.queue_free() await frames(2) game = null func fresh(seed: int = 4, slot: int = 1) -> LuneMatch: await _free_game() game = Game.new() add_child(game) game.start({"seed": seed, "human_slot": slot, "bunds": BUNDS}) await frames(3) return game.match_state func hud() -> Control: return game.hud func sync() -> void: game.hud.mark_dirty() await frames(2) func state() -> LuneMatch: return game.match_state func command(value: Dictionary) -> Dictionary: var result: Dictionary = state().submit(game.human_slot, value) game._rebuild_preview() return result func point_event(at: Vector2, button: int, pressed: bool) -> InputEventMouseButton: var event := InputEventMouseButton.new() event.button_index = button event.position = at event.global_position = at event.pressed = pressed return event func move_to(at: Vector2) -> void: var motion := InputEventMouseMotion.new() motion.position = at motion.global_position = at get_viewport().push_input(motion, true) await get_tree().process_frame func wheel_at(at: Vector2) -> void: await move_to(at) for pressed: bool in [true, false]: get_viewport().push_input(point_event(at, MOUSE_BUTTON_WHEEL_UP, pressed), true) await frames(2) func click_at(at: Vector2, button: int = MOUSE_BUTTON_LEFT) -> void: await move_to(at) for pressed: bool in [true, false]: get_viewport().push_input(point_event(at, button, pressed), true) await get_tree().process_frame func click(control: Control) -> void: if control == null or not control.is_visible_in_tree(): check(false, "control to click exists and is visible (%s)" % (control.name if control != null else "null")) return await click_at(control.get_global_rect().get_center()) func key(code: int) -> 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 func texts(root: Node) -> String: var result: PackedStringArray = [] for node: Node in root.find_children("*", "", true, false): if node is Label and (node as Label).is_visible_in_tree(): result.append((node as Label).text) elif node is Button and (node as Button).is_visible_in_tree(): result.append((node as Button).text) return "\n".join(result) func rect_inside(inner: Rect2, outer: Rect2) -> bool: return inner.position.x >= outer.position.x - 1.0 and inner.position.y >= outer.position.y - 1.0 \ and inner.end.x <= outer.end.x + 1.0 and inner.end.y <= outer.end.y + 1.0 func armies(round_number: int) -> void: var match_state: LuneMatch = state() match_state.round_number = round_number for slot: int in range(6): match_state.players[slot].draft.roster.clear() match_state.players[slot].draft.gold = 100000 LuneBot.plan(match_state, slot, "ausgewogen") for entry: Dictionary in match_state.players[slot].draft.roster: if entry.lane == LuneMatch.GROTTO: entry.lane = match_state.players[slot].lane var hero: Dictionary = match_state.players[slot].draft.hero if not hero.is_empty() and hero.lane == LuneMatch.GROTTO: hero.lane = match_state.players[slot].lane func advance(seconds: float) -> void: var match_state: LuneMatch = state() game.paused = false while match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat.elapsed < seconds: for i: int in range(20): if match_state.phase != LuneMatch.Phase.COMBAT or match_state.combat.elapsed >= seconds: break match_state.step_combat() await get_tree().process_frame game.paused = true # ---------------------------------------------------------------- A layout func _layout() -> void: var match_state: LuneMatch = await fresh() match_state.round_number = 3 match_state.players[game.human_slot].draft.gold = 2000 game._starter() command({"type": "hero_buy", "hero_id": "held_quellgeflecht"}) for index: int in range(6): command({"type": "buy", "type_id": match_state.base_ids(match_state.players[game.human_slot].bund)[index % 5]}) var roster: Array = match_state.players[game.human_slot].draft.roster hud().select_unit(str(roster[0].uid)) var sizes: Array[Vector2i] = [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440), Vector2i(2560, 1080), Vector2i(1152, 720)] var measured: Array = [] for window: Vector2i in sizes: get_window().size = window for wanted: float in [.9, 1.0, 1.3]: Preferences.ui_scale = wanted Preferences.changed.emit() await frames(6) var label: String = "%dx%d @%d%%" % [window.x, window.y, int(wanted * 100.0)] var area: Rect2 = get_viewport().get_visible_rect() measured.append([label, get_window().size, area.size, hud().ui_scale]) check(hud().ui_scale <= wanted + .001, "%s: UI scale never exceeds the chosen size (%.2f)" % [label, hud().ui_scale]) if wanted <= 1.0: check(is_equal_approx(hud().ui_scale, wanted) or area.size.x / wanted < Hud.MIN_AREA.x or area.size.y / wanted < Hud.MIN_AREA.y, "%s: the chosen UI scale applies when it fits" % label) _regions_ok(label + " planning, unit selected") _labels_fit(label) # Combat layout of the same window at 100 %. get_window().size = Vector2i(1920, 1080) Preferences.ui_scale = 1.0 Preferences.changed.emit() await frames(6) hud().open_hero() await frames(4) var modal: Rect2 = hud()._modal.panel.get_global_rect() check(rect_inside(modal, get_viewport().get_visible_rect()), "hero window inside the screen") var covered: float = modal.get_area() / get_viewport().get_visible_rect().get_area() check(covered < .75, "hero window leaves the field partly visible (%.0f %% covered)" % (covered * 100.0)) hud().close_modal() hud().open_champions() await frames(4) _regions_ok("1920x1080 champions tab") hud().open_troops() hud().clear_selection() game._on_ready() await frames(4) for window: Vector2i in [Vector2i(1280, 720), Vector2i(2560, 1080), Vector2i(1920, 1080)]: get_window().size = window await frames(6) _regions_ok("%dx%d combat" % [window.x, window.y]) facts.layouts = measured func _regions_ok(label: String) -> void: var area: Rect2 = get_viewport().get_visible_rect() var regions: Dictionary = hud().regions() for key: String in regions: check(rect_inside(regions[key], area), "%s: %s inside the window (%s in %s)" % [label, key, regions[key], area]) var keys: Array = regions.keys() for i: int in range(keys.size()): for j: int in range(i + 1, keys.size()): var a: Rect2 = regions[keys[i]] var b: Rect2 = regions[keys[j]] check(not a.grow(-1.0).intersects(b.grow(-1.0)), "%s: %s and %s do not overlap" % [label, keys[i], keys[j]]) # The right column never takes more than 30 % of the width. if regions.has("right"): check((regions.right as Rect2).size.x <= area.size.x * .3, "%s: context column at most 30 %% wide" % label) ## Unclipped labels must fit their box (clipped ones end with an ellipsis by design). func _labels_fit(label: String) -> void: var bad: PackedStringArray = [] for node: Node in hud().find_children("*", "Label", true, false): var text_label := node as Label if not text_label.is_visible_in_tree() or text_label.clip_text or text_label.autowrap_mode != TextServer.AUTOWRAP_OFF or text_label.text.is_empty(): continue if text_label.get_minimum_size().x > text_label.size.x + 1.5: bad.append(text_label.text) check(bad.is_empty(), "%s: no label wider than its box %s" % [label, str(bad)]) # ---------------------------------------------------------------- B status func _status() -> void: var match_state: LuneMatch = await fresh() get_window().size = Vector2i(1920, 1080) await frames(4) check(hud().lanes.size() == 3, "three lane indicators") for lane: String in LuneMap.LANES: var indicator: Control = hud().lanes[lane] check(indicator.is_visible_in_tree() and rect_inside(indicator.get_global_rect(), get_viewport().get_visible_rect()), "lane %s visible" % lane) match_state.building_hp["blau_ancient"] = 5000.0 match_state.building_hp["rot_ancient"] = 2600.0 match_state.building_hp["rot_top_outer"] = 0.0 match_state.building_hp["blau_mid_outer"] = 3000.0 await sync() var own: Dictionary = hud()._ancients[0] var enemy: Dictionary = hud()._ancients[1] check((own.value as Label).text == "5.000 / 12.000", "own Ancient HP shown (%s)" % (own.value as Label).text) check(absf((own.bar as LuneHpBar).fraction - 5000.0 / 12000.0) < .002, "own Ancient bar follows HP") check((enemy.value as Label).text == "2.600 / 12.000" and (enemy.badge as LuneStatusBadge).text == "KRITISCH", "enemy Ancient below 30 %% is KRITISCH (%s)" % (enemy.badge as LuneStatusBadge).text) check((own.caption as Label).text.contains("BLAU") and (enemy.caption as Label).text.contains("ROT"), "Ancients are named by team, not only coloured") var top: LuneLaneIndicator = hud().lanes["top"] check(not top.towers[2].alive and top.towers[3].alive, "TOP: enemy Grenzwacht shown destroyed, Bastion alive") var mid: LuneLaneIndicator = hud().lanes["mid"] check(mid.towers[1].alive and absf(mid.towers[1].fraction - .5) < .011, "MID: own Grenzwacht at 50 %% (%.2f)" % mid.towers[1].fraction) check(mid.level == LuneLaneThreat.Level.CONTESTED or mid.level == LuneLaneThreat.Level.PRESSURE, "MID threat rises with a damaged tower (%s)" % LuneLaneThreat.NAMES[mid.level]) check(top.towers[0].team == 0 and top.towers[2].team == 1, "tower chain runs from the own base to the enemy base") # Threat is deterministic and public: the same for every viewer of the same team. var a: Dictionary = LuneLaneThreat.evaluate(match_state, 0, "mid") var b: Dictionary = LuneLaneThreat.evaluate(match_state, 0, "mid") check(a.level == b.level and a.reasons == b.reasons, "lane threat is deterministic") match_state.building_hp["blau_bot_outer"] = 0.0 match_state.building_hp["blau_bot_inner"] = 0.0 await sync() check((hud().lanes["bot"] as LuneLaneIndicator).level == LuneLaneThreat.Level.CRITICAL, "both lane towers down: BOTTOM is KRITISCH") for round_number: int in [14, 21, 137]: match_state.round_number = round_number await sync() var wave: Label = hud()._wave check(wave.text == "WELLE %d" % round_number, "wave %d shown as \"%s\"" % [round_number, wave.text]) check(not wave.text.contains("/"), "no wave limit in the display (%s)" % wave.text) check(wave.get_minimum_size().x <= wave.size.x + 1.0 and rect_inside(hud()._top.get_global_rect(), get_viewport().get_visible_rect()), "wave %d fits the top bar" % round_number) match_state.round_number = 3 match_state.players[game.human_slot].draft.gold = 420 await sync() check(hud()._gold.amount.text == "420", "own gold shown (%s)" % hud()._gold.amount.text) check(hud()._gold.detail.text.contains(str(match_state.balance.round_income(3))), "base income of the next settlement shown") var ids: Array[String] = match_state.base_ids(match_state.players[game.human_slot].bund) await click(hud()._shop_buttons[ids[0]]) await sync() var cost: int = int(DataCatalog.get_unit(ids[0]).cost) check(match_state.players[game.human_slot].draft.gold == 420 - cost and hud()._gold.amount.text == str(420 - cost), "a card click buys and the gold display follows") for slot: int in range(6): if slot != game.human_slot: match_state.players[slot].draft.gold = 7777 + slot match_state.players[slot].gold = 6666 + slot await sync() var shown: String = texts(hud()) var leaked: bool = false for slot: int in range(6): if slot != game.human_slot: for value: int in [7777 + slot, 6666 + slot]: leaked = leaked or shown.contains(str(value)) or shown.contains(LuneUiTheme.number(value)) check(not leaked, "no other player's gold appears anywhere in the HUD") # ---------------------------------------------------------------- C hero and units func _hero_and_units() -> void: var match_state: LuneMatch = await fresh() match_state.round_number = 5 match_state.players[game.human_slot].draft.gold = 3000 await sync() await click(hud().hero_card) await frames(3) check(hud().modal_open() and hud().hero_detail.recruit_buttons.size() == 3, "hero card opens the recruiting window with three heroes") await click(hud().hero_detail.recruit_buttons["held_ringborke"]) await sync() var hero: Dictionary = match_state.players[game.human_slot].draft.hero check(not hero.is_empty() and hero.hero_id == "held_ringborke", "hero recruited through the window") hero.xp = 500 await sync() check(hud().hero_card._level.text == "STUFE 4", "hero level from experience (%s)" % hud().hero_card._level.text) check(absf(hud().hero_card._xp.fraction - (500.0 - 480.0) / (720.0 - 480.0)) < .01, "experience progress to the next level") check(hud().hero_card._slots.size() == 3, "hero card shows three item slots") var definition: Dictionary = LuneContent.hero_definition(hero) hud().open_hero() await frames(3) var window_text: String = texts(hud()._modal) check(window_text.contains(LuneUiTheme.number(definition.max_hp)) and window_text.contains(LuneUiTheme.decimal(definition.damage)), "hero window shows the real level-scaled HP and damage") check(window_text.contains(str(DataCatalog.get_unit("w04").ability_description)), "hero window shows the real ability text") hud().open_items() await frames(3) var cards: Dictionary = hud().hero_detail.item_cards check(cards.size() == LuneContent.ITEMS.size(), "all items listed") check((cards["kristallsplitter"] as LuneItemSlot).state == LuneItemSlot.State.AVAILABLE, "level-4 hero may buy Kristallsplitter") await click(cards["glutklinge"]) await sync() check((hero.items as Array).has("glutklinge"), "item bought through its card") cards = hud().hero_detail.item_cards check((cards["glutklinge"] as LuneItemSlot).state == LuneItemSlot.State.UNIQUE, "a carried item shows GETRAGEN (unique)") check(hud().hero_card._slots[0].glyph == "glutklinge", "the bottom hero card shows the equipped item") command({"type": "item_buy", "item_id": "basaltschild", "uid": hero.uid}) command({"type": "item_buy", "item_id": "rankenmantel", "uid": hero.uid}) await sync() check((hud().hero_detail.item_cards["funkenfokus"] as LuneItemSlot).state == LuneItemSlot.State.FULL, "fourth item: slots full") match_state.players[game.human_slot].draft.gold = 10 command({"type": "item_sell", "item_id": "rankenmantel"}) match_state.players[game.human_slot].draft.gold = 10 await sync() check((hud().hero_detail.item_cards["funkenfokus"] as LuneItemSlot).state == LuneItemSlot.State.UNAFFORDABLE, "missing gold: item marked unaffordable") var young: Dictionary = hero.duplicate(true) hero.xp = 0 (hero.items as Array).erase("basaltschild") match_state.players[game.human_slot].draft.gold = 3000 await sync() check((hud().hero_detail.item_cards["kristallsplitter"] as LuneItemSlot).state == LuneItemSlot.State.INCOMPATIBLE, "level-1 hero: Kristallsplitter needs level 3") hero.xp = young.xp hud().close_modal() # A normal unit: details without item slots, lane order with the grotto warning. var ids: Array[String] = match_state.base_ids(match_state.players[game.human_slot].bund) command({"type": "buy", "type_id": ids[1]}) await sync() var entry: Dictionary = match_state.players[game.human_slot].draft.roster[0] var chip: Button = hud()._chips.get(str(entry.uid)) await click(chip) await sync() var detail: LuneUnitDetail = hud().unit_detail check(detail.visible and detail.uid == str(entry.uid), "army chip selects the unit") var slots: int = 0 for node: Node in detail.find_children("*", "", true, false): slots += int(node is LuneItemSlot) check(slots == 0 and not texts(detail).contains("ITEMS"), "normal units have no item slots") var data: Dictionary = DataCatalog.get_unit(ids[1]) var detail_text: String = texts(detail) check(detail_text.contains(LuneUiTheme.number(data.max_hp)) and detail_text.contains(LuneUiTheme.decimal(data.damage)) and detail_text.contains(str(data.ability_description)), "unit details show real HP, damage and ability") check(detail_text.contains(str(DataCatalog.get_unit(str(data.upgrade_id)).display_name)) and detail_text.contains(str(int(DataCatalog.get_unit(str(data.upgrade_id)).cost))), "upgrade target and cost shown") await click(detail._lanes["top"]) await sync() check(str(match_state.players[game.human_slot].draft.roster[0].lane) == "top", "lane order TOP through the detail panel") match_state.boss.next_available_round = 1 await sync() await click(detail._lanes[LuneMatch.GROTTO]) await sync() check(str(match_state.players[game.human_slot].draft.roster[0].lane) == LuneMatch.GROTTO and detail._warning.visible and detail._warning.text == LuneUnitDetail.GROTTO_WARNING, "grotto order shows the missing-lane warning") match_state.boss.next_available_round = 9 command({"type": "assign", "uid": entry.uid, "lane": "mid"}) await sync() check((detail._lanes[LuneMatch.GROTTO] as Button).disabled and not (detail._lanes["top"] as Button).disabled, "a closed grotto is not offered; lanes stay active") var gold: int = match_state.players[game.human_slot].draft.gold await click(detail._upgrade) await sync() check(str(match_state.players[game.human_slot].draft.roster[0].type_id) == str(data.upgrade_id) and match_state.players[game.human_slot].draft.gold == gold - int(DataCatalog.get_unit(str(data.upgrade_id)).cost), "upgrade through the detail panel") # Esc clears the selection. await key(KEY_ESCAPE) await sync() check(not hud().unit_detail.visible, "Esc clears the unit selection") # ---------------------------------------------------------------- D champions and boss func _champions_and_boss() -> void: var match_state: LuneMatch = await fresh() match_state.round_number = 14 match_state.players[game.human_slot].draft.gold = 1000 await sync() hud().open_champions() await sync() var panel: LuneChampionPanel = hud().champion_panel check(panel.visible and panel.cards.size() == 3, "champion tab with three champions") for id: String in panel.cards: var card: LuneUnitCard = panel.cards[id] check(card.champion and card.theme_type_variation == "HudChampionCard" and card._tag.visible, "%s card uses the temporary champion look (1 WELLE)" % id) await click(panel._lane_buttons["bot"]) await click(panel.cards["ch_belagerung"]) await sync() var bookings: Array = match_state.players[game.human_slot].draft.new_bookings check(bookings.size() == 1 and str(bookings[0].lane) == "bot", "champion booked on the chosen target lane") check(texts(panel).contains("Einsatz in Welle 15 – BOTTOM"), "booking says \"Einsatz in Welle 15 – BOTTOM\"") check(match_state.players[game.human_slot].draft.roster.is_empty(), "a champion never joins the permanent army") hud().open_troops() # Boss states. var states: Array = [] match_state.boss = {"next_available_round": 1, "claim": {}} await sync() states.append(hud().boss_card._badge.text) check(hud().boss_card._badge.text == "BOSS VERFÜGBAR", "boss available (%s)" % hud().boss_card._badge.text) match_state.boss = {"next_available_round": 16, "claim": {}} await sync() states.append(hud().boss_card._badge.text) check(hud().boss_card._badge.text == "COOLDOWN – 2 RUNDEN", "boss cooldown with rounds left (%s)" % hud().boss_card._badge.text) match_state.boss = {"next_available_round": 17, "claim": {"team": 0, "won_round": 13, "deploy_round": 14, "chooser_slot": game.human_slot, "lane": "mid"}} await sync() states.append(hud().boss_card._badge.text) check(hud().boss_card._badge.text == "EINSATZ NÄCHSTE WELLE", "boss deployment announced (%s)" % hud().boss_card._badge.text) var banner: String = texts(hud()._banners) check(banner.contains("Welle 14") and banner.contains("MID") and banner.contains(LuneUiTheme.number(LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * 14)), "banner names wave, lane and the bounty (%s)" % banner) check(hud().boss_card._choice.visible, "the boss chooser gets the lane choice") await click(hud().boss_card._lane_buttons["bot"]) await sync() check(str(match_state.players[game.human_slot].draft.boss_lane) == "bot" and texts(hud()._banners).contains("BOTTOM"), "chooser moves the boss to BOTTOM; the banner follows") # Other seats see the public lane only. var other := LuneHudModel.new(match_state, 0) check(str(other.boss().lane) == "mid", "the chooser's uncommitted lane change stays private") match_state.boss.claim.team = 1 match_state.boss.claim.chooser_slot = 3 await sync() check(texts(hud()._banners).contains("Kopfgeld für euch"), "enemy boss: banner warns and names the defenders' bounty") # Combat: the deployed boss attacks; then the grotto fight is contested. game._on_ready() await frames(3) await sync() states.append(hud().boss_card._badge.text) check(hud().boss_card._badge.text.begins_with("BOSS GREIFT AN"), "deployed boss: BOSS GREIFT AN (%s)" % hud().boss_card._badge.text) var boss_unit: LuneUnit = null for unit: LuneUnit in match_state.combat.units: if unit.kind == "boss": boss_unit = unit check(boss_unit != null and boss_unit.team == 1, "the boss fights for its owner") var view: Node3D = game._views.get(boss_unit.id) if boss_unit != null else null check(view != null and view._owner_ring != null and view._tag.text.contains("für Rot"), "the boss carries an ownership ring and label in the world") match_state = await fresh() match_state.round_number = 6 match_state.boss = {"next_available_round": 1, "claim": {}} for slot: int in [1, 4]: match_state.players[slot].draft.gold = 2400 LuneBot.plan(match_state, slot, "ausgewogen") for entry: Dictionary in match_state.players[slot].draft.roster: entry.lane = LuneMatch.GROTTO game._on_ready() await frames(3) await sync() states.append(hud().boss_card._badge.text) check(hud().boss_card._badge.text == "BOSS UMKÄMPFT", "expeditions in the grotto: BOSS UMKÄMPFT (%s)" % hud().boss_card._badge.text) facts.boss_badges = states # ---------------------------------------------------------------- E ready, bots, network func _ready_bots_network() -> void: var match_state: LuneMatch = await fresh() match_state.round_number = 2 match_state.players[game.human_slot].draft.gold = 600 game._starter() await sync() var members: Array = hud().model.members(hud().model.team()) var bots: int = 0 for member: Dictionary in members: bots += int(member.status == "bot") check(bots == 2, "two bots in the own team") var badges: String = texts(hud()._team) check(badges.contains("BOT") and badges.contains("PLANT"), "team panel marks bots and the planning human") # Ready lock: planning continues (another human would still plan) but the own plan is binding. match_state.set_ready(game.human_slot) await sync() var roster_before: int = match_state.players[game.human_slot].draft.roster.size() check(hud()._ready_button.disabled and hud()._ready_check.visible, "Bereit shows as committed") var all_locked: bool = true for id: String in hud()._shop_buttons: all_locked = all_locked and (hud()._shop_buttons[id] as Button).disabled check(all_locked, "after Ready every unit card is locked") check(hud()._undo.disabled and hud()._starter.disabled, "undo and start army locked after Ready") await key(KEY_1) await sync() check(match_state.players[game.human_slot].draft.roster.size() == roster_before, "key 1 buys nothing after Ready") hud().select_unit(str(match_state.players[game.human_slot].draft.roster[0].uid)) await sync() check((hud().unit_detail._lanes["top"] as Button).disabled and hud().unit_detail._upgrade.disabled, "unit orders locked after Ready") check(hud()._phase_badge.text == "PLANUNG · BEREIT", "phase badge says BEREIT (%s)" % hud()._phase_badge.text) hud().clear_selection() # Network: a host with a seated guest, and the guest's own view built from rpc_state. var host: LuneSession = LuneSession.new() host.name = "HostSession" add_child(host) host.host_offline(0, {"seed": 7, "human_slot": 0, "bunds": BUNDS}) host.seat_peer(1, 80, "Dora") var hm: LuneMatch = host.match_state hm.players[0].draft.gold = 4321 hm.players[0].gold = 4321 hm.building_hp["rot_mid_outer"] = 2000.0 hm.boss = {"next_available_round": 9, "claim": {}} var ids: Array[String] = hm.base_ids("wurzelbund") host.host_command(80, 1, {"type": "buy", "type_id": ids[0]}) await _free_game() var guest: LuneSession = LuneSession.new() guest.name = "GuestSession" add_child(guest) guest.role = LuneSession.Role.CLIENT guest.local_slot = 1 guest.match_state = LuneMatch.new() guest.match_state.setup({"seed": 7, "human_slot": 1, "bunds": BUNDS}) guest.lobby = host.lobby.duplicate(true) guest.rpc_state(host.state_for(1)) game = Game.new() game.session = guest add_child(game) game.start({"seed": 7, "human_slot": 1}) await frames(3) await sync() var guest_text: String = texts(hud()) check(hud()._gold.amount.text == str(guest.match_state.gold_of(1)), "guest sees its own gold (%s)" % hud()._gold.amount.text) check(not guest_text.contains("4321") and not guest_text.contains("4.321"), "guest never sees the host's gold") check(hud().model.networked() and hud().model.is_guest(), "guest HUD knows it is a network guest") var host_model := LuneHudModel.new(hm, 0, host) var guest_digest: Dictionary = hud().public_digest() var host_digest: Dictionary = host_model.public_digest() check(JSON.stringify(guest_digest) == JSON.stringify(host_digest), "host and guest show the same public information") if JSON.stringify(guest_digest) != JSON.stringify(host_digest): for key: String in host_digest: if JSON.stringify(host_digest[key]) != JSON.stringify(guest_digest.get(key)): printerr("G3_DIGEST_DIFF ", key, " host=", host_digest[key], " guest=", guest_digest.get(key)) var host_row: Dictionary = {} for member: Dictionary in hud().model.members(0): if int(member.slot) == 0: host_row = member check(host_row.status == "human" and not host_row.has("gold"), "guest sees the host as a human seat, without gold") # The host's HUD: guest ready, then the guest disconnects and a bot takes the seat. var host_hud: Control = Hud.new() host_hud.match_state = hm host_hud.human_slot = 0 host_hud.session = host add_child(host_hud) await frames(3) host_hud.mark_dirty() await frames(2) var dora: Dictionary = host_hud.model.members(0)[1] check(dora.name == "Dora" and dora.status == "human" and not dora.ready, "host sees Dora planning") host.host_ready(1) host_hud.mark_dirty() await frames(2) check(texts(host_hud._team).contains("BEREIT"), "host sees Dora ready") host._on_peer_disconnected(80) host_hud.mark_dirty() await frames(2) check(host_hud.model.members(0)[1].status == "offline" and texts(host_hud._team).contains("BOT ÜBERNIMMT"), "after a disconnect the seat shows BOT ÜBERNIMMT") host_hud.queue_free() await _free_game() guest.queue_free() host.close() host.queue_free() await frames(2) # ---------------------------------------------------------------- F input func _input_checks() -> void: var match_state: LuneMatch = await fresh() get_window().size = Vector2i(1920, 1080) match_state.round_number = 3 match_state.players[game.human_slot].draft.gold = 400 game._starter() await sync() await wait(.3) var own_team: int = int(match_state.players[game.human_slot].team) # Camera hotkeys (existing keys, now listed in the options). var keys: Dictionary = {KEY_F2: "top", KEY_F3: "mid", KEY_F4: "bot"} for code: int in keys: await key(code) var lane: String = keys[code] var goal: Vector3 = game.camera._goal_focus var expected: Vector2 = match_state.map.lane_point(own_team, lane, match_state.map.lanes[lane].length * .2) check(Vector2(goal.x, goal.z).distance_to(expected) < .6, "hotkey for %s moves the camera there" % lane) await key(KEY_G) check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(match_state.map.grotto_center()) < .6, "G: camera to the boss grotto") await key(KEY_B) check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(match_state.map.core_position(own_team)) < .6, "B: camera to the own Ancient") var settings_text: String = "" for entry: Array in LuneSettings.KEYS: settings_text += str(entry) check(settings_text.contains("F2 · F3 · F4") and settings_text.contains("Bossgrotte") and settings_text.contains("eigener Ancient"), "camera hotkeys listed in the options") # Lane click. await click(hud().lanes["bot"]) var bot: Vector2 = match_state.map.lane_point(own_team, "bot", match_state.map.lanes["bot"].length * .2) check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(bot) < .6, "click on the BOTTOM lane row moves the camera") # Minimap click and drag: camera only, never an order. var gold: int = match_state.gold_of(game.human_slot) var roster: String = str(match_state.players[game.human_slot].draft.roster) var map: Control = hud().minimap var local := Vector2(map.size.x * .72, map.size.y * .3) var target: Vector3 = map.to_world(local) await click_at(map.get_global_transform() * local) check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(Vector2(target.x, target.z)) < 1.0, "minimap click moves the camera to that point") check(match_state.gold_of(game.human_slot) == gold and str(match_state.players[game.human_slot].draft.roster) == roster, "minimap click buys and orders nothing") # No click passthrough: the wheel over a panel does not zoom the world, over the field it does. await frames(10) var zoom: float = game.camera._goal_zoom var bar_point: Vector2 = hud()._bottom.get_global_rect().get_center() await wheel_at(bar_point) check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the bottom bar does not reach the world") var lane_point: Vector2 = (hud()._left.get_child(0) as Control).get_global_rect().get_center() await wheel_at(lane_point) check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the lane panel does not reach the world") await wheel_at(hud().unit_detail.get_global_rect().get_center() if hud().unit_detail.visible else hud().boss_card.get_global_rect().get_center()) check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the context column does not reach the world") var field: Vector2 = get_viewport().get_visible_rect().size * Vector2(.5, .52) await wheel_at(field) check(game.camera._goal_zoom < zoom, "mouse wheel over the free field zooms") var entry: Dictionary = match_state.players[game.human_slot].draft.roster[0] hud().select_unit(str(entry.uid)) await sync() await click_at((hud()._team.get_parent() as Control).get_global_rect().get_center()) await sync() check(hud().selected_uid == str(entry.uid), "a click on a HUD panel does not reach the world (selection kept)") # World click picks an own unit. hud().clear_selection() game.focus_lane(str(match_state.players[game.human_slot].lane), true) await frames(6) var picked: LuneUnit = null for view: Node3D in game._views.values(): if view.unit != null and view.unit.kind == "troop" and view.unit.owner_slot == game.human_slot: picked = view.unit await click_at(game.camera.unproject_position(view.aim_point())) break await sync() check(picked != null and hud().selected_uid == picked.persistent_uid, "clicking an own figure in the field selects it") # Tooltip: appears near the hovered card, stays on screen, disappears when leaving. # Synthetic pointer: hover signals come from the OS cursor, so the test raises mouse_entered # itself after moving the (synthetic) pointer onto the card. var card: Control = hud()._shop_buttons.values()[0] await move_to(card.get_global_rect().get_center()) card.mouse_entered.emit() await wait(.5) var tip: LuneTooltip = hud().tooltip check(tip.visible, "tooltip appears over a unit card") check(rect_inside(tip.get_global_rect(), get_viewport().get_visible_rect()), "tooltip stays inside the window") check(tip.mouse_filter == Control.MOUSE_FILTER_IGNORE, "tooltip never takes mouse input") await move_to(field) await frames(2) check(not tip.visible, "tooltip hides as soon as the pointer leaves") var tip_count: int = 0 for node: Node in get_tree().root.find_children("*", "", true, false): tip_count += int(node is LuneTooltip) check(tip_count == 1, "one shared tooltip (%d)" % tip_count) # ---------------------------------------------------------------- G events and motion func _events_and_motion() -> void: var match_state: LuneMatch = await fresh() armies(14) # Only Blau fields an army: its Mid wave reaches Rot's Grenzwacht quickly. for slot: int in range(3, 6): match_state.players[slot].draft.roster.clear() match_state.players[slot].draft.hero = {} match_state.building_hp["rot_mid_outer"] = 40.0 game._on_ready() await frames(3) var tower: LuneUnit = match_state.combat.buildings["rot_mid_outer"] game.paused = false while tower.alive and match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat.elapsed < 120.0: for i: int in range(20): if not tower.alive or match_state.phase != LuneMatch.Phase.COMBAT: break match_state.step_combat() await frames(1) game.paused = true await wait(.4) check(match_state.phase == LuneMatch.Phase.COMBAT and not tower.alive, "fixture: Rot's Mid Grenzwacht fell during the combat") var feed: String = texts(hud().feed) check(feed.contains("MID – Grenzwacht (Rot) zerstört"), "event: MID – Grenzwacht (Rot) zerstört (%s)" % feed.replace("\n", " | ")) check(hud().feed.entries.size() <= LuneEventFeed.LIMIT, "at most %d events" % LuneEventFeed.LIMIT) # Critical Ancient with and without reduced motion. Preferences.reduced_motion = true var core: LuneUnit = match_state.combat.buildings["blau_ancient"] core.hp = core.max_hp * .2 await wait(.4) check(float(hud()._ancients[0].pulse) == 0.0, "reduced motion: no pulse on the critical Ancient") check(texts(hud()._banners).contains("kritisch"), "critical own Ancient: banner shown") check(texts(hud().feed).contains("Dein Ancient kritisch"), "critical own Ancient: event") Preferences.reduced_motion = false var enemy_core: LuneUnit = match_state.combat.buildings["rot_ancient"] enemy_core.hp = enemy_core.max_hp * .2 await wait(.35) check(float(hud()._ancients[1].pulse) > 0.0, "without reduced motion the enemy Ancient pulses briefly") await wait(1.8) check(float(hud()._ancients[1].pulse) == 0.0, "the pulse ends by itself (no permanent pulsing)") core.hp = core.max_hp enemy_core.hp = enemy_core.max_hp # ---------------------------------------------------------------- H rates and portraits func _rates() -> void: var match_state: LuneMatch = await fresh() match_state.round_number = 4 game._starter() await wait(.5) var before: int = hud().syncs await wait(1.0) var planning_rate: int = hud().syncs - before check(planning_rate <= 14, "planning: HUD updates about 10 times a second (%d)" % planning_rate) armies(12) game._on_ready() await frames(3) game.paused = false game.speed_index = 0 await wait(.4) before = hud().syncs var redraws: int = hud().minimap.redraws await wait(1.0) var combat_rate: int = hud().syncs - before var map_rate: int = hud().minimap.redraws - redraws check(combat_rate <= 7, "combat: HUD updates about 5 times a second (%d)" % combat_rate) check(map_rate <= 12, "combat: minimap redraws about 10 times a second (%d)" % map_rate) var markers: Dictionary = hud().minimap.markers check(int(markers.get("clusters", 0)) < int(markers.get("units", 0)) and int(markers.get("units", 0)) > 20, "minimap merges armies (%s)" % str(markers)) check(int(markers.get("heroes", 0)) >= 1, "heroes have their own minimap markers") facts.rates = {"planning_syncs_per_s": planning_rate, "combat_syncs_per_s": combat_rate, "minimap_redraws_per_s": map_rate, "hud_sync_usec": hud().sync_usec, "minimap_markers": markers} var cache: LunePortraits = LunePortraits.shared() var guard: int = 0 while cache.pending() > 0 and guard < 600: await frames(1) guard += 1 var rendered: int = cache.rendered var keys: int = LunePortraits.textures.size() check(rendered > 0 and rendered <= keys, "portraits come from the real models and render once each (%d renders, %d cached)" % [rendered, keys]) var viewports: int = hud().find_children("*", "SubViewport", true, false).size() check(viewports == 0, "no live portrait viewports inside the HUD (%d)" % viewports) await fresh() game._starter() await wait(.6) check(cache.rendered == rendered or cache.rendered - rendered <= 2, "a new match reuses the cached portraits (%d → %d)" % [rendered, cache.rendered]) facts.portraits = {"rendered": cache.rendered, "cached": LunePortraits.textures.size()} game.paused = true