saumwacht-godot/tools/luneraria/verify_g2.gd
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:12:49 +02:00

615 lines
33 KiB
GDScript

extends Node
## G2 acceptance: Ancients, towers, the Mondschlund grotto and the Schlundkrone in the real game.
## Part A assets and Blender pipeline (manifest, files, hashes, sources, clips, materials)
## Part B views, team identity, lights and particle budget in a running LuneGame
## Part C building states from the authoritative HP (towers, Ancients) and grotto states
## Part D tower attacks: lock marker and bolts follow the rules' targets, one bolt per hit
## Part E rules through the presentation: tower order, base towers gate the Ancient,
## ruins do not block the march, the fall of an Ancient ends the match at once
## Part F boss: grotto reachable, fight, roar, claim, dispatch on all three lanes
## Part G presentation never feeds back: the same match with and without views ends equal
## Part H complete bot matches (headless rules and one with the full game on screen)
## Part I occlusion fade and the scale hierarchy
## Run (window needed): godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g2.tscn
const OUT := "res://docs/luneraria/logs/g2/verify-g2.json"
const Game = preload("res://scripts/luneraria/lune_game.gd")
const Materials = preload("res://scripts/luneraria/view/lune_materials.gd")
const TowerView = preload("res://scripts/luneraria/view/lune_tower_view.gd")
const AncientView = preload("res://scripts/luneraria/view/lune_ancient_view.gd")
const GrottoView = preload("res://scripts/luneraria/view/lune_grotto_view.gd")
const BOSS_CLIPS: Array[String] = ["idle", "walk", "attack_a", "attack_b", "hit", "ability", "death", "roar", "victory", "start", "stop"]
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("G2_FAIL: [", _part, "] ", label)
func part(name: String) -> void:
_part = name
print("G2_PART ", name)
func _ready() -> void:
call_deferred("run")
func run() -> void:
Soundscape.enabled = false
var started: int = Time.get_ticks_msec()
part("A assets")
_assets()
part("B views")
await _views()
part("C states")
await _states()
part("D tower attacks")
await _tower_attacks()
part("E rules")
await _rules()
part("F boss")
await _boss()
part("G no feedback")
await _no_feedback()
part("H bot matches")
await _bot_matches()
part("I occlusion and scale")
await _occlusion_and_scale()
await _free_game()
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("G2_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 _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(2)
return game.match_state
## Steps the combat quickly (more fixed steps per frame, rules unchanged) until done() or limit.
func advance(state: LuneMatch, done: Callable, limit: float, per_frame: int = 20) -> void:
game.paused = true
while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < limit and not done.call():
for i: int in range(per_frame):
if state.phase != LuneMatch.Phase.COMBAT or done.call():
break
state.step_combat()
await frames()
func armies(state: LuneMatch, slots: Array, round_number: int) -> void:
state.round_number = round_number
for slot: int in range(6):
state.players[slot].draft.roster.clear()
state.players[slot].draft.gold = 100000 if slots.has(slot) else 0
if slots.has(slot):
LuneBot.plan(state, slot, "ausgewogen")
for entry: Dictionary in state.players[slot].draft.roster:
if entry.lane == LuneMatch.GROTTO:
entry.lane = state.players[slot].lane
var hero: Dictionary = state.players[slot].draft.hero
if not hero.is_empty() and hero.lane == LuneMatch.GROTTO:
hero.lane = state.players[slot].lane
func start_combat(state: LuneMatch) -> void:
for slot: int in range(6):
state.players[slot].ready = true
state.begin_combat()
await frames(2)
func view_of(unit: LuneUnit) -> Node3D:
return game._views.get(unit.id) if unit != null else null
func sha256_of(path: String) -> String:
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
context.update(FileAccess.get_file_as_bytes(path))
return context.finish().hex_encode()
# ---------------------------------------------------------------- A: assets
func _assets() -> void:
var manifest: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://assets/luneraria/g2/g2_manifest.json"))
check(manifest is Dictionary, "the G2 manifest parses")
var entries: Dictionary = {}
for entry: Dictionary in (manifest as Dictionary).get("assets", []):
entries[str(entry.id)] = entry
facts.assets = entries.keys()
for id: String in ["ancient", "grenzwacht", "bastion", "kernwacht", "bossgrotte", "schlundkrone"]:
check(entries.has(id), "manifest lists " + id)
if not entries.has(id):
continue
var entry: Dictionary = entries[id]
var glb: String = str(entry.resource_path)
check(ResourceLoader.exists(glb) and load(glb) is PackedScene, id + " GLB imports as a scene")
check(sha256_of(glb) == str(entry.sha256), id + " GLB matches the manifest hash (reproducible export)")
check(FileAccess.file_exists("res://" + str(entry.source)), id + " keeps its .blend source")
check(FileAccess.file_exists("res://" + str(entry.generator)), id + " keeps its generator script")
check(int(entry.triangles) > 1000 and int(entry.triangles) < 60000, "%s has a sane triangle budget (%d)" % [id, int(entry.triangles)])
var low: Array = entry.bounds_godot.min
if id != "bossgrotte":
check(absf(float(low[1])) < 1.05, "%s pivot sits at the ground (min y %.2f)" % [id, float(low[1])])
for key: String in entry.get("materials", []):
if key.begins_with("LN_"):
check(Materials.get_material(key) != null, "%s material %s has a runtime material" % [id, key])
# The boss: skeletal, all clips, contact timing, stride.
var boss: Dictionary = entries.get("schlundkrone", {})
check(bool(boss.get("skeletal", false)), "Schlundkrone is a skeletal model")
var clips: Array = boss.get("clips", [])
for clip: String in BOSS_CLIPS:
check(clips.has(clip), "Schlundkrone has the %s clip" % clip)
check(float(boss.get("contact_seconds", 0.0)) == .9, "boss attacks connect at 0.90 s like every SkeletalFigure")
var figure: Node3D = AssetLibrary.instantiate_fighter("schlundkrone")
check(figure != null and figure.has_method("update_combat"), "the boss instantiates as SkeletalFigure")
if figure != null:
add_child(figure)
for clip: String in BOSS_CLIPS:
check(figure.clips.has(clip), "imported boss has the %s animation" % clip)
check(figure.clip_length("attack_a") >= 1.25 and figure.clip_length("attack_b") >= 1.25, "attack clips cover contact and recovery")
check(is_equal_approx(figure.stride, 2.0), "boss walk cadence uses its own stride (%.2f m)" % figure.stride)
check(figure.skeleton != null and figure.skeleton.find_bone("Head") >= 0 and figure.skeleton.find_bone("Forearm.L") >= 0 \
and figure.skeleton.find_bone("Spine") >= 0, "boss skeleton has the overlay bones")
facts.boss_bones = figure.skeleton.get_bone_count() if figure.skeleton != null else 0
figure.queue_free()
# The grotto asset keeps every lane corridor clear (checked by its generator, recorded here).
var grotto: Dictionary = entries.get("bossgrotte", {})
check(float(grotto.get("lane_clearance_min", 0.0)) >= .5, "grotto generator enforces a lane clearance")
# ---------------------------------------------------------------- B: views
func _views() -> void:
var state: LuneMatch = await fresh()
var world: Node3D = game.world
var tiers: Dictionary = {}
for view: Node3D in world.tower_views.values():
tiers[view.tier] = int(tiers.get(view.tier, 0)) + 1
facts.tower_views = tiers
check(world.tower_views.size() == 16 and int(tiers.get("outer", 0)) == 6 and int(tiers.get("inner", 0)) == 6 and int(tiers.get("base", 0)) == 4,
"16 towers: 6 Grenzwacht, 6 Bastion, 4 Kernwacht")
check(world.ancient_views.size() == 2, "two Ancients (Mondherz)")
check(world.grotto_view != null and world.grotto_view.boss_figure != null, "the grotto view stands with its resting boss")
for definition: Dictionary in state.map.buildings:
var view: Node3D = world.building_nodes.get(definition.id)
var at: Vector2 = definition.position
check(view != null and Vector2(view.global_position.x, view.global_position.z).distance_to(at) < .001,
"%s view stands exactly at its rules position" % definition.id)
check(is_equal_approx(state.balance.building_radius, 1.3), "the rules' building radius is unchanged (1.3 m)")
# Team identity beyond colour: rune family, banner silhouette and metal differ per team.
var blue: Node3D = world.tower_views["blau_mid_outer"]
var red: Node3D = world.tower_views["rot_mid_outer"]
var blue_instance: GeometryInstance3D = blue._instances[0]
var red_instance: GeometryInstance3D = red._instances[0]
check(float(blue_instance.get_instance_shader_parameter("pattern")) != float(red_instance.get_instance_shader_parameter("pattern")),
"teams carry different rune families")
check(float(blue_instance.get_instance_shader_parameter("shape")) != float(red_instance.get_instance_shader_parameter("shape")),
"teams carry different banner silhouettes")
check(Materials.team_color(0, "metal") != Materials.team_color(1, "metal"), "teams carry different metals")
# Light and particle budget of the strategic objects.
var lights: int = 0
var shadow_lights: int = 0
var particles: int = 0
var particle_max: int = 0
for node: Node in world.find_children("*", "", true, false):
if node is OmniLight3D or node is SpotLight3D:
lights += 1
if (node as Light3D).shadow_enabled:
shadow_lights += 1
elif node is GPUParticles3D:
particles += 1
particle_max = maxi(particle_max, (node as GPUParticles3D).amount)
facts.local_lights = lights
facts.particle_systems = particles
check(lights <= 22, "local lights stay few (%d: one per building, two in the grotto)" % lights)
check(shadow_lights == 0, "no local light casts shadows")
check(particle_max <= 100, "every particle system is bounded (largest %d)" % particle_max)
# ---------------------------------------------------------------- C: states
func _states() -> void:
var state: LuneMatch = await fresh()
var world: Node3D = game.world
var tower: Node3D = world.tower_views["rot_mid_outer"]
var ancient: Node3D = world.ancient_views["blau_ancient"]
var cases: Array = [[1.0, TowerView.State.INTACT, AncientView.State.STABLE], [.61, TowerView.State.INTACT, AncientView.State.STABLE],
[.5, TowerView.State.LIGHT, AncientView.State.UNSTABLE], [.31, TowerView.State.LIGHT, AncientView.State.UNSTABLE],
[.2, TowerView.State.HEAVY, AncientView.State.CRITICAL], [.01, TowerView.State.HEAVY, AncientView.State.CRITICAL]]
for case: Array in cases:
state.building_hp["rot_mid_outer"] = float(state.map.building_def("rot_mid_outer").max_hp) * float(case[0])
state.building_hp["blau_ancient"] = float(state.map.building_def("blau_ancient").max_hp) * float(case[0])
await frames(2)
check(tower.state == case[1], "tower at %d %% HP shows state %d (got %d)" % [int(float(case[0]) * 100), case[1], tower.state])
check(ancient.state == case[2], "Ancient at %d %% HP shows state %d (got %d)" % [int(float(case[0]) * 100), case[2], ancient.state])
check(tower._parts["Upper_Damaged"].visible and not tower._parts["Upper"].visible, "heavy damage swaps in the broken upper part")
check(ancient._leak.emitting and ancient._smoke.emitting and ancient._ring_slipped, "a critical Ancient leaks energy, smokes and its ring slips")
# Destruction: a short sequence, then a lasting ruin; the view is never removed.
state.building_hp["rot_mid_outer"] = 0.0
state.building_hp["blau_ancient"] = 0.0
await frames(2)
check(tower.state == TowerView.State.DESTROYING, "a tower at 0 HP plays its destruction")
check(ancient.state == AncientView.State.COLLAPSING, "an Ancient at 0 HP plays its collapse")
await get_tree().create_timer(3.4).timeout
check(tower.state == TowerView.State.RUIN and tower._parts["Ruin"].visible and not tower._parts["Lower"].visible, "the tower remains as a ruin")
check(ancient.state == AncientView.State.FALLEN and ancient._parts["Ruin_Crystal"].visible, "the Ancient remains as a fallen ruin")
check(is_instance_valid(tower) and tower.is_inside_tree() and is_instance_valid(ancient), "destroyed buildings do not vanish")
check(not tower.light.visible and not ancient.light.visible, "ruins have no light")
# Grotto states from the public boss state.
var grotto: Node3D = world.grotto_view
var expected: Array = [[1, 1, {}, GrottoView.State.AVAILABLE], [7, 10, {"team": 1, "won_round": 6, "deploy_round": 7, "chooser_slot": 3, "lane": "top"}, GrottoView.State.DEPLOYED],
[8, 10, {}, GrottoView.State.COOLDOWN], [9, 10, {}, GrottoView.State.SOON], [10, 10, {}, GrottoView.State.AVAILABLE]]
for case: Array in expected:
state.round_number = int(case[0])
state.boss.next_available_round = int(case[1])
state.boss.claim = case[2]
await frames(3)
check(grotto.state == case[3], "grotto in round %d (next %d) is '%s' (got '%s')" % [case[0], case[1], GrottoView.STATE_NAMES[case[3]], grotto.state_name()])
check(grotto.boss_visible == (case[3] == GrottoView.State.AVAILABLE), "resting boss shows only while the grotto is available (%s)" % grotto.state_name())
check(grotto.claim_team == -1, "no claim colour without a claim")
state.boss.claim = {"team": 0, "won_round": 6, "deploy_round": 7, "chooser_slot": 0, "lane": "mid"}
state.round_number = 7
await frames(3)
check(grotto.claim_team == 0, "while deployed the grotto runes carry the claiming team")
# ---------------------------------------------------------------- D: tower attacks
func _tower_attacks() -> void:
var state: LuneMatch = await fresh()
armies(state, [0, 1, 2], 13)
await start_combat(state)
var fx: Node3D = game.world.tower_fx
var tower: LuneUnit = state.combat.buildings["rot_mid_outer"]
await advance(state, func() -> bool: return tower.target != null and tower.in_range, 90.0)
check(tower.target != null, "Rot's outer Mid tower acquires a target")
var hits_before: Dictionary = {}
for id: String in state.combat.buildings:
hits_before[id] = (state.combat.buildings[id] as LuneUnit).attack_serial
var launched_before: int = fx.launched
var marker_ok: bool = true
var marker_seen: int = 0
var bolt_ok: bool = true
var bolts_seen: int = 0
var rule_ok: bool = true
game.paused = false
var end_time: float = state.combat.elapsed + 12.0
while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < end_time:
await frames()
for id: String in state.combat.buildings:
var unit: LuneUnit = state.combat.buildings[id]
if unit.kind != "tower" or not unit.alive or unit.target == null or not unit.in_range:
continue
# Rules sanity: a tower only shoots hostile, living figures within its range.
rule_ok = rule_ok and unit.target.team != unit.team and unit.position.distance_to(unit.target.position) <= unit.attack_range() + .05
var marker: MeshInstance3D = fx._markers.get(id)
var target_view: Node3D = view_of(unit.target)
if marker != null and marker.visible and target_view != null:
marker_seen += 1
var d: float = Vector2(marker.global_position.x, marker.global_position.z).distance_to(Vector2(target_view.global_position.x, target_view.global_position.z))
marker_ok = marker_ok and d < .6
for bolt: Object in fx._bolts:
if bolt.active and bolt.target != null and is_instance_valid(bolt.target):
bolts_seen += 1
bolt_ok = bolt_ok and bolt.target.get("unit") != null
game.paused = true
var hits: int = 0
for id: String in state.combat.buildings:
var unit: LuneUnit = state.combat.buildings[id]
if unit.kind == "tower":
hits += unit.attack_serial - int(hits_before[id])
var launched: int = fx.launched - launched_before
facts.tower_hits_12s = hits
facts.tower_bolts_12s = launched
check(rule_ok, "towers only target hostile figures in range")
check(marker_seen > 0 and marker_ok, "the lock marker sits under the rules' target (%d samples)" % marker_seen)
check(bolts_seen > 0 and bolt_ok, "bolts fly at unit views (%d samples)" % bolts_seen)
check(hits > 0 and launched <= hits + 16 and launched >= int(hits * .7), "about one bolt per tower hit (%d hits, %d bolts)" % [hits, launched])
check(fx.landed <= fx.launched, "no bolt lands twice")
# ---------------------------------------------------------------- E: rules through the presentation
func _rules() -> void:
# Tower order and base gate: a Rot army against a Blau lane, fast forward, check the gates.
var state: LuneMatch = await fresh()
for id: String in ["blau_mid_outer"]:
state.building_hp[id] = 0.0
armies(state, [3, 4, 5], 16)
await start_combat(state)
# Lambdas capture locals by value, so results go into a dictionary.
var flags := {"order_ok": true, "core_safe": true, "gated_steps": 0}
var core_start: float = (state.combat.buildings["blau_ancient"] as LuneUnit).hp
await advance(state, func() -> bool:
var gates: Dictionary = state.combat.alive_map()
for id: String in state.combat.buildings:
var unit: LuneUnit = state.combat.buildings[id]
if unit.alive and not LuneMap.is_attackable(state.map.building_def(id), gates):
flags.gated_steps = int(flags.gated_steps) + 1
# A gated building must not lose health while its gate is closed.
if unit.hp < float(state.building_hp.get(id, unit.max_hp)) - .001:
flags.order_ok = false
var core: LuneUnit = state.combat.buildings["blau_ancient"]
if bool(gates.get("blau_base_1", false)) or bool(gates.get("blau_base_2", false)):
flags.core_safe = bool(flags.core_safe) and core.hp >= core_start - .001
return false, 60.0)
check(bool(flags.order_ok) and int(flags.gated_steps) > 0, "no gated tower takes damage before its gate opens (outer → inner → base)")
check(bool(flags.core_safe), "the Ancient takes no damage while a base tower stands")
# Ruins do not block: with Rot's outer Mid tower destroyed, Blau's Mid army marches past it.
state = await fresh()
state.building_hp["rot_mid_outer"] = 0.0
armies(state, [0, 1, 2], 10)
await start_combat(state)
var ruin_progress: float = state.map.lanes["mid"].length - float(state.map.building_def("rot_mid_outer").progress)
var march := {"passed": false}
await advance(state, func() -> bool:
for unit: LuneUnit in state.combat.units:
if unit.alive and unit.team == 0 and unit.lane == "mid" and unit.is_mobile() and unit.progress > ruin_progress + 2.0:
march.passed = true
return bool(march.passed), 120.0)
check(bool(march.passed), "figures march past the ruin of a destroyed tower")
await get_tree().create_timer(TowerView.DESTROY_SECONDS).timeout
check(game.world.tower_views["rot_mid_outer"].state == TowerView.State.RUIN, "the destroyed tower is shown as a ruin, not removed")
# The fall of an Ancient ends the match at once; the collapse only starts then.
state = await fresh()
for id: String in ["rot_mid_outer", "rot_mid_inner", "rot_base_1", "rot_base_2"]:
state.building_hp[id] = 0.0
state.building_hp["rot_ancient"] = 60.0
armies(state, [0, 1, 2], 16)
await start_combat(state)
var ancient: Node3D = game.world.ancient_views["rot_ancient"]
var collapse_before_end: bool = false
game.paused = true
while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 150.0:
state.step_combat()
if state.phase == LuneMatch.Phase.COMBAT and ancient.state == AncientView.State.COLLAPSING:
collapse_before_end = true
if state.combat.elapsed > 0.0 and int(state.combat.steps) % 20 == 0:
await frames()
check(state.phase == LuneMatch.Phase.OVER and state.winner == 0, "destroying Rot's Ancient ends the match for Blau")
check(not collapse_before_end, "the collapse never starts before the rules decided")
await frames(2)
check(ancient.state == AncientView.State.COLLAPSING, "the collapse plays after the victory is already decided")
check(state.phase == LuneMatch.Phase.OVER, "the match stays over while the collapse plays")
# ---------------------------------------------------------------- F: boss
func _boss() -> void:
var state: LuneMatch = await fresh()
var world: Node3D = game.world
var map: LuneMap = state.map
# Reachable: entries and boss point on the arena floor, the path between them free of columns.
for team: int in range(2):
var entry: Vector2 = map.grotto_entry(team)
check(entry.distance_to(map.grotto_center()) < LuneTerrain.ARENA_RADIUS - 1.0, "grotto entry %d lies inside the arena" % team)
check(absf(world.height_at(entry) - (LuneTerrain.ARENA_HEIGHT + GrottoView.FLOOR_LIFT)) < .02, "grotto entry %d stands on the floor" % team)
var clear: bool = true
for k: int in range(11):
var p: Vector2 = entry.lerp(map.grotto_boss_point(), k / 10.0) - map.grotto_center()
clear = clear and p.length() < 6.3
check(clear, "the way from entry %d to the boss stays inside the column ring" % team)
check(world.height_at(map.grotto_boss_point()) > LuneTerrain.ARENA_HEIGHT + .1, "the boss stands on its dais")
# Fight: only Blau sends an expedition, so the boss wakes at once and roars.
state.round_number = 6
for slot: int in range(6):
state.players[slot].draft.roster.clear()
state.players[slot].draft.gold = 0
state.players[1].draft.gold = 9000
LuneBot.plan(state, 1, "ausgewogen")
for entry: Dictionary in state.players[1].draft.roster:
entry.lane = LuneMatch.GROTTO
if not (state.players[1].draft.hero as Dictionary).is_empty():
state.players[1].draft.hero.lane = LuneMatch.GROTTO
await start_combat(state)
var combat: LuneCombat = state.combat
check(combat.grotto_boss != null, "an expedition wakes the grotto boss")
var boss_view: Node3D = view_of(combat.grotto_boss)
check(boss_view != null and boss_view.model != null and boss_view.model.has_method("update_combat"), "the grotto boss is shown as the Schlundkrone")
check(world.grotto_view.state == GrottoView.State.FIGHT and not world.grotto_view.boss_visible, "the grotto is in its fight state; the resting figure gives way")
var seen := {"awake": false, "roar": false}
await advance(state, func() -> bool:
if not combat.grotto_boss.dormant:
seen.awake = true
if boss_view.model.current_clip == "roar":
seen.roar = true
return combat.grotto_resolved, 150.0, 4)
check(bool(seen.awake) and bool(seen.roar), "the boss wakes and roars")
facts.boss_outcome = combat.grotto_outcome
check(combat.grotto_outcome == "success", "the expedition defeats the boss")
check(boss_view.model.dead, "the boss plays its death")
await frames(2)
check(world.grotto_view.state == GrottoView.State.COOLDOWN, "the grotto falls into its exhausted state")
await advance(state, func() -> bool: return false, 200.0)
check(state.phase == LuneMatch.Phase.PLANNING and not (state.boss.claim as Dictionary).is_empty() and int(state.boss.claim.team) == 0,
"Blau holds the claim after the round")
await frames(3)
check(world.grotto_view.state == GrottoView.State.DEPLOYED and world.grotto_view.claim_team == 0, "the grotto shows the deployed boss for Blau")
# Dispatch on every lane: the boss marches, meets a tower, is shot and stands on the ground.
var lanes_ok: Dictionary = {}
for lane: String in LuneMap.LANES:
state = await fresh()
armies(state, [0, 1, 2, 3, 4, 5], 7)
state.boss.claim = {"team": 0, "won_round": 6, "deploy_round": 7, "chooser_slot": 1, "lane": lane}
state.boss.next_available_round = 10
await start_combat(state)
var boss: LuneUnit = null
for unit: LuneUnit in state.combat.units:
if unit.kind == "boss" and unit.zone == "lane":
boss = unit
check(boss != null and boss.lane == lane and boss.team == 0, "the claimed boss spawns on %s for Blau" % lane)
if boss == null:
continue
var view: Node3D = view_of(boss)
check(view != null and view.model.has_method("update_combat"), "the %s boss is the Schlundkrone" % lane)
var tint: Color = view._boss_surfaces[0].get_instance_shader_parameter("tint") if not view._boss_surfaces.is_empty() else Color.BLACK
check(tint.is_equal_approx(Materials.team_color(0, "crystal")), "the %s boss glows in Blau's colour" % lane)
var start_progress: float = boss.progress
var probe := {"shot_at": false, "grounded": true, "frames": 0}
await advance(state, func() -> bool:
for id: String in state.combat.buildings:
var tower: LuneUnit = state.combat.buildings[id]
if tower.kind == "tower" and tower.target == boss:
probe.shot_at = true
if view != null and boss.alive and view.is_inside_tree():
probe.frames = int(probe.frames) + 1
var expected: float = game.world.height_at(Vector2(view.position.x, view.position.z)) + .03
probe.grounded = bool(probe.grounded) and absf(view.position.y - expected) < .06
return bool(probe.shot_at) and boss.progress > start_progress + 8.0 or not boss.alive, 120.0, 10)
check(boss.progress > start_progress + 3.0 or not boss.alive, "the %s boss advances along its lane (%.1f m)" % [lane, boss.progress - start_progress])
check(bool(probe.shot_at), "towers on %s target the boss" % lane)
check(bool(probe.grounded), "the %s boss stands on the ground, bridge or platform" % lane)
lanes_ok[lane] = bool(probe.shot_at) and bool(probe.grounded)
facts.boss_lanes = lanes_ok
# ---------------------------------------------------------------- G: presentation never feeds back
func _signature(state: LuneMatch) -> String:
var parts_list: Array = [state.round_number, state.phase, state.winner]
for id: String in state.building_hp:
parts_list.append("%s=%.4f" % [id, float(state.building_hp[id])])
for player: Dictionary in state.players:
parts_list.append("%d:%d:%d" % [int(player.slot), int(player.gold), int(player.earned)])
parts_list.append(JSON.stringify(state.boss))
return "|".join(parts_list.map(func(v: Variant) -> String: return str(v)))
func _no_feedback() -> void:
# Same seed, six bots: pure rules versus the full game with views, bolts and states.
var pure := LuneMatch.new()
pure.setup({"seed": 11, "human_slot": -1, "bunds": BUNDS})
for round_index: int in range(4):
for slot: int in range(6):
LuneBot.plan(pure, slot)
pure.begin_combat()
pure.finish_combat()
await _free_game()
game = Game.new()
add_child(game)
game.start({"seed": 11, "human_slot": -1, "bunds": BUNDS})
var shown: LuneMatch = game.match_state
await frames(2)
var launched: int = 0
while shown.round_number <= 4 and shown.phase != LuneMatch.Phase.OVER:
if shown.phase == LuneMatch.Phase.COMBAT:
for i: int in range(24):
if shown.phase != LuneMatch.Phase.COMBAT:
break
shown.step_combat()
await frames()
if shown.round_number > 4 and shown.phase == LuneMatch.Phase.PLANNING:
break
launched = game.world.tower_fx.launched
facts.no_feedback_bolts = launched
check(pure.history.size() >= 4 and shown.history.size() >= 4, "both runs play four rounds")
var equal: bool = true
for index: int in range(mini(4, mini(pure.history.size(), shown.history.size()))):
equal = equal and JSON.stringify(pure.history[index]) == JSON.stringify(shown.history[index])
check(equal, "rounds 1-4 are identical with and without the presentation (gold, buildings, boss)")
check(launched > 0, "the shown run really drew tower bolts (%d)" % launched)
# ---------------------------------------------------------------- H: bot matches
func _bot_matches() -> void:
var results: Array = []
for seed: int in [21, 22, 23, 24]:
var state := LuneMatch.new()
state.setup({"seed": seed, "human_slot": -1})
while state.phase != LuneMatch.Phase.OVER and state.round_number <= 40:
for slot: int in range(6):
LuneBot.plan(state, slot)
state.begin_combat()
state.finish_combat()
results.append({"seed": seed, "rounds": state.round_number, "winner": state.winner})
check(state.phase == LuneMatch.Phase.OVER and state.winner != LuneMatch.ONGOING, "bot match seed %d ends with a fallen Ancient (round %d)" % [seed, state.round_number])
facts.bot_matches = results
# One complete match with the full game on screen (bots only, fast clock).
await _free_game()
game = Game.new()
add_child(game)
game.start({"seed": 22, "human_slot": -1})
var shown: LuneMatch = game.match_state
await frames(2)
var errors_before: int = failures.size()
while shown.phase != LuneMatch.Phase.OVER and shown.round_number <= 40:
if shown.phase == LuneMatch.Phase.COMBAT:
for i: int in range(40):
if shown.phase != LuneMatch.Phase.COMBAT:
break
shown.step_combat()
await frames()
var fallen: int = 0
for view: Node3D in game.world.tower_views.values():
if view.state == TowerView.State.RUIN or view.state == TowerView.State.DESTROYING:
fallen += 1
facts.shown_match = {"rounds": shown.round_number, "winner": shown.winner, "fallen_towers_shown": fallen}
check(shown.phase == LuneMatch.Phase.OVER and shown.winner == int(results[1].winner) and shown.round_number == int(results[1].rounds),
"the shown match ends exactly like the headless one (round %d, winner %d)" % [shown.round_number, shown.winner])
var lost: int = 0
for id: String in shown.building_hp:
if float(shown.building_hp[id]) <= 0.0 and not id.ends_with("ancient"):
lost += 1
check(fallen == lost, "every destroyed tower is shown as destroyed (%d/%d)" % [fallen, lost])
check(failures.size() == errors_before, "no check failed during the shown match")
# ---------------------------------------------------------------- I: occlusion and scale
func _occlusion_and_scale() -> void:
var state: LuneMatch = await fresh()
var world: Node3D = game.world
var camera: Camera3D = game.camera
camera.reduced_motion = true
var ancient: Node3D = world.ancient_views["blau_ancient"]
# The northern base tower stands behind the Ancient from the default camera.
var behind: Vector2 = state.map.building_def("blau_base_1").position
camera.look_at_point(Vector3(behind.x, 0, behind.y), 20.0, true)
await frames(8)
check(ancient.fade_target > 0.0, "the Ancient thins out while the camera looks at what lies behind it")
await get_tree().create_timer(.5).timeout
check(ancient.current_fade() > .3 and ancient.current_fade() <= .61, "the fade is partial, never invisible (%.2f)" % ancient.current_fade())
camera.look_at_point(Vector3(-30.0, 0, 36.0), 20.0, true)
await frames(8)
check(ancient.fade_target == 0.0, "the Ancient is solid again when nothing lies behind it")
# The grotto gate gives way to the Top lane behind it.
# North of the gate (Top lane on the highland): from the default camera the gate stands in between.
var gate: Vector2 = LuneMap.GROTTO - LuneMap.AXIS * 8.95
var top_behind := Vector2(gate.x, gate.y - 5.5)
camera.look_at_point(Vector3(top_behind.x, 0, top_behind.y), 20.0, true)
await frames(8)
check(world.grotto_view.fade_target > 0.0, "the grotto gate thins out for the Top lane behind it")
camera.look_at_point(Vector3(LuneMap.GROTTO.x + 6.0, 0, LuneMap.GROTTO.y + 6.0), 20.0, true)
await frames(8)
check(world.grotto_view.fade_target == 0.0, "the grotto is solid when the camera looks at its arena")
# Scale hierarchy at view scale: troop < hero < champion < boss < Grenzwacht < Bastion < Kernwacht < Ancient.
var manifest: Dictionary = {}
for entry: Dictionary in JSON.parse_string(FileAccess.get_file_as_string("res://assets/luneraria/g2/g2_manifest.json")).assets:
manifest[str(entry.id)] = entry
var hero_def: Dictionary = LuneContent.hero_definition({"hero_id": "held_ringborke", "xp": 0, "items": [], "talents": []})
var champion: Dictionary = LuneContent.champion_definition("ch_belagerung", 7, state.balance)
var boss_def: Dictionary = LuneContent.boss_definition(7, state.balance, true)
var heights: Dictionary = {
"troop": AssetLibrary.model_height("w01"),
"hero": AssetLibrary.model_height(str(hero_def.id)) * float(hero_def.view_scale),
"champion": AssetLibrary.model_height(str(champion.id)) * float(champion.view_scale),
"boss": float(manifest.schlundkrone.bounds_godot.max[1]) * float(boss_def.view_scale),
"grenzwacht": float(manifest.grenzwacht.bounds_godot.max[1]),
"bastion": float(manifest.bastion.bounds_godot.max[1]),
"kernwacht": float(manifest.kernwacht.bounds_godot.max[1]),
"ancient": float(manifest.ancient.bounds_godot.max[1])}
facts.heights_m = heights
# Units among themselves may differ (a hero is bigger than a siege champion); above them
# the order is strict: boss < Tower 1 < Tower 2 < base tower < Ancient.
var pairs: Array = [["troop", "hero"], ["troop", "champion"], ["hero", "boss"], ["champion", "boss"], ["boss", "grenzwacht"],
["grenzwacht", "bastion"], ["bastion", "kernwacht"], ["kernwacht", "ancient"]]
for pair: Array in pairs:
check(float(heights[pair[0]]) < float(heights[pair[1]]), "%s (%.2f m) is smaller than %s (%.2f m)" % [pair[0],
float(heights[pair[0]]), pair[1], float(heights[pair[1]])])
check(float(heights.boss) > float(heights.champion) * 1.4, "the boss clearly towers over every unit")