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")