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

778 lines
39 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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