From ec7a8f1f172e85d876ddb11f55650a7038747fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Mon, 24 Aug 2026 12:10:31 +0200 Subject: [PATCH] Cover the last batch of tickets with tests The previous ten tickets shipped with almost no coverage - only #109, #113, #115 and #117 touched a test. That was a gap, not a decision: everything before it had its behaviour pinned, and #116 in particular added a whole subsystem nothing was watching. Four new files and one extended case: test_spacial_workshop #116 - the repair economy end to end: the cost of a repair scales with the damage, the workshop asks logistics for that ore and stops at its buffer, a drone spends when it LEAVES so two cannot double-charge one wall, and a target levelled in flight sends its load home rather than swallowing it test_desktop_controls #118/#121 - both driven through the real routing, because the routing is what was missing; a test calling handle_mouse_button() itself would have passed against both reports test_enemy_bomber #114 - it flies, it faces where it flies, its bombs arrive, and it bombs structures with no fleet in sight test_menu_polish #111/#112/#119/#120 - what each panel shows test_wave_system (case G) #110 - the dev button, and that it does not exist outside dev mode One assertion worth recording: the craft caption is checked as an EXACT match against the vessel's display name rather than by hunting for resource words in it. The first version failed on "Gas Bomber" - a vessel named after a resource, and always will be. What #111 removed was the price spelled out underneath, not the name. Co-Authored-By: Claude Opus 5 --- tests/test_desktop_controls.gd | 184 ++++++++++++++++++++++++ tests/test_enemy_bomber.gd | 192 +++++++++++++++++++++++++ tests/test_garrison_abilities.gd.uid | 1 + tests/test_intro_scene.gd.uid | 1 + tests/test_menu_polish.gd | 178 +++++++++++++++++++++++ tests/test_spacial_workshop.gd | 202 +++++++++++++++++++++++++++ tests/test_spacial_workshop.gd.uid | 1 + tests/test_wave_system.gd | 31 ++++ 8 files changed, 790 insertions(+) create mode 100644 tests/test_desktop_controls.gd create mode 100644 tests/test_enemy_bomber.gd create mode 100644 tests/test_garrison_abilities.gd.uid create mode 100644 tests/test_intro_scene.gd.uid create mode 100644 tests/test_menu_polish.gd create mode 100644 tests/test_spacial_workshop.gd create mode 100644 tests/test_spacial_workshop.gd.uid diff --git a/tests/test_desktop_controls.gd b/tests/test_desktop_controls.gd new file mode 100644 index 0000000..8467ad0 --- /dev/null +++ b/tests/test_desktop_controls.gd @@ -0,0 +1,184 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_desktop_controls.gd +# Forgejo #118 and #121: two raw-touch controls that a mouse could not +# reach. +# +# Both read InputEventScreenTouch/ScreenDrag, and the project no longer turns +# clicks into them - the same routing gap #93 found in the research tree. +# The boost was unreachable on a desktop and the formation could not be +# rearranged at all. +# +# Driven through the REAL routing in each case (GameController for the +# fader, BuildController for the editor) rather than by calling the controls +# directly, because the routing is what was missing - a test that called +# handle_mouse_button() itself would have passed against both reports. +# +# Covers: +# A. #118 - a click on the fader takes hold of it, dragging moves the +# boost, and releasing lets go +# B. the fader claims the press, so dragging the boost does not also zoom +# the world behind it +# C. #121 - a click grabs a formation slot, moving drags it, and the +# formation really changes +# D. releasing drops the slot, and moving afterwards does not keep dragging +# E. the wheel zooms the formation panel, which is what replaces the pinch + +var game_state: Node +var scene: Node +var controller: Node +var fleet: Node +var fader: Control +var editor: Control +var formation_menu: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.reset_formation() + game_state.intro_active = false + game_state.vessel_levels["unlock_distorder"] = 1 + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + controller = scene.get_node("BuildController") + fader = scene.get_node("UI/SpeedFader") + editor = scene.get_node("UI/FormationMenu/Editor") + formation_menu = scene.get_node("UI/FormationMenu") + + +func _fail(message: String) -> bool: + print("TEST_DESKTOP_CONTROLS: FAIL (%s)" % message) + quit(1) + return true + + +func _click(at: Vector2, pressed: bool, target: Node) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.position = at + target._unhandled_input(event) + + +func _move(to: Vector2, target: Node) -> void: + var event: InputEventMouseMotion = InputEventMouseMotion.new() + event.position = to + target._unhandled_input(event) + + +func _wheel(button: int, at: Vector2) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = button + event.pressed = true + event.position = at + controller._unhandled_input(event) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + # The fader only exists where the fleet can actually boost. + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER])) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + if not fader.visible: + return _fail("the fleet has an Energy Propulser and the fader is still hidden") + if game_state.max_fleet_speed_multiplier <= 1.0: + return _fail("the boost has no range to move through") + + # --- A. click, drag, release ----------------------------- + var top: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5, + fader.track_margin + 2.0) + var bottom: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5, + fader.size.y - fader.track_margin - 2.0) + _click(bottom, true, scene) + if not fader.is_active(): + return _fail("a click on the fader did not take hold of it") + var at_bottom: float = game_state.fleet_speed_multiplier + _move(top, scene) + var at_top: float = game_state.fleet_speed_multiplier + if at_top <= at_bottom: + return _fail("dragging the fader from bottom to top left the boost at %.2f" % at_top) + _click(top, false, scene) + if fader.is_active(): + return _fail("the fader is still held after the button came up") + _move(bottom, scene) + if not is_equal_approx(game_state.fleet_speed_multiplier, at_top): + return _fail("the fader kept following the pointer after release") + print("fader: %.2fx at the bottom, %.2fx at the top, released cleanly" \ + % [at_bottom, at_top]) + + # --- B. it claims the press ------------------------------ + var zoom_before: float = scene.get_node("Camera2D").zoom.x + _click(bottom, true, scene) + _click(bottom, false, scene) + if not is_equal_approx(scene.get_node("Camera2D").zoom.x, zoom_before): + return _fail("pressing the fader also moved the camera behind it") + print("the press stops at the fader; the world behind it is untouched") + + # --- C/D/E. the formation editor ------------------------- + formation_menu.visible = true + editor.queue_redraw() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + var slot_before: Vector2 = game_state.formation[0] + var grab: Vector2 = editor.global_position + editor._slot_screen_pos(0) + _click(grab, true, controller) + if editor.dragging_index != 0: + return _fail("a click on the first formation slot grabbed %d" % editor.dragging_index) + _move(grab + Vector2(70.0, 40.0), controller) + if game_state.formation[0] == slot_before: + return _fail("dragging the slot did not move it") + var dragged: Vector2 = game_state.formation[0] + print("slot 0 moved from %s to %s under a mouse" % [str(slot_before), str(dragged)]) + + # --- D. release lets go ---------------------------------- + _click(grab + Vector2(70.0, 40.0), false, controller) + if editor.dragging_index != -1: + return _fail("the slot is still held after the button came up") + _move(grab + Vector2(200.0, 200.0), controller) + if game_state.formation[0] != dragged: + return _fail("the slot kept following the pointer after release") + print("released, and the slot stopped following") + + # --- E. the wheel zooms ---------------------------------- + var scale_before: float = editor.scale_factor + _wheel(MOUSE_BUTTON_WHEEL_UP, editor.global_position + editor.size * 0.5) + if editor.scale_factor <= scale_before: + return _fail("the wheel did not zoom the formation panel in") + _wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5) + _wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5) + if editor.scale_factor >= scale_before: + return _fail("the wheel did not zoom back out") + print("the wheel zooms the panel both ways (%.2f -> %.2f)" \ + % [scale_before, editor.scale_factor]) + + print("TEST_DESKTOP_CONTROLS: PASS") + quit(0) + return true + return false diff --git a/tests/test_enemy_bomber.gd b/tests/test_enemy_bomber.gd new file mode 100644 index 0000000..69aaa96 --- /dev/null +++ b/tests/test_enemy_bomber.gd @@ -0,0 +1,192 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_bomber.gd +# Forgejo #114: the enemy bomber - the artillery enemy, the one that lobs an +# area shell. +# +# It was a siege piece that crept at 20px/s and threw a shell taking five +# seconds to cross the gap, which read as a thing that was stuck rather than +# a thing that was dangerous: by the time a shell landed the fleet had +# wandered off, and the tower was still approaching. +# +# Covers: +# A. it closes on a target at a speed worth calling flying, and faces +# where it is going +# B. its bombs travel fast enough to arrive while the target is roughly +# where they were aimed +# C. the bomb damages a player vessel it lands on +# D. and a BUILDING it lands on - it used to hit only player_ships, which +# was already out of step with #101 +# E. it bombs a structure with no fleet in sight, through the shared +# targeting rather than asking Fleet directly +# F. its hull is its own, not a copy of another enemy's + +const MIN_FLYING_SPEED: float = 90.0 +const MIN_SHELL_SPEED: float = 350.0 + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var bomber: Node2D +var target_ship: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var start_distance: float = 0.0 +var building_id: int = 0 +var hp_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.intro_active = false + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("BuildingField") + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_BOMBER: FAIL (%s)" % message) + quit(1) + return true + + +func _spawn_bomber(at: Vector2) -> Node2D: + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + enemy.enemy_id = "artillery" + enemy.position = at + main.add_child(enemy) + return enemy + + +func _shells() -> Array: + var out: Array = [] + for shell in get_nodes_in_group("artillery_shells"): + if is_instance_valid(shell) and not shell.is_queued_for_deletion(): + out.append(shell) + return out + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + + # --- F. its own silhouette ------------------------------- + var definitions: Script = load("res://enemies/EnemyDefinitions.gd") + var bomber_def: Resource = definitions.get_definition("artillery") + for other in definitions.all(): + if other.enemy_id == "artillery": + continue + if other.hull_points == bomber_def.hull_points: + return _fail("the bomber shares its hull with the %s" % other.enemy_id) + if bomber_def.speed < MIN_FLYING_SPEED: + return _fail("the bomber's top speed is %.0f - that is still creeping" \ + % bomber_def.speed) + print("bomber: %d hull points of its own, %.0fpx/s top speed" \ + % [bomber_def.hull_points.size() / 2, bomber_def.speed]) + + fleet.spawn_ships(1, Vector2.ZERO, + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + target_ship = fleet.ships[0] + target_ship.move_target = target_ship.position + bomber = _spawn_bomber(Vector2(700.0, 0.0)) + start_distance = bomber.global_position.distance_to(target_ship.global_position) + elapsed = 0.0 + stage = 1 + return false + 1: + # --- A. it flies, and faces where it is going ------------ + if elapsed < 1.0: + return false + if not is_instance_valid(bomber): + return _fail("the bomber is gone before it could be measured") + var closed: float = start_distance \ + - bomber.global_position.distance_to(target_ship.global_position) + var speed: float = closed / elapsed + if speed < MIN_FLYING_SPEED: + return _fail("the bomber closed at %.0fpx/s - #114 asks for an aircraft" % speed) + var heading: Vector2 = target_ship.global_position - bomber.global_position + var drift: float = absf(angle_difference(bomber.rotation, heading.angle() + PI / 2.0)) + if drift > PI / 3.0: + return _fail("it flew %.0f degrees away from where it was pointing" \ + % rad_to_deg(drift)) + print("closed %.0fpx in %.1fs (%.0fpx/s), nose within %.0f degrees" \ + % [closed, elapsed, speed, rad_to_deg(drift)]) + + # --- B/C. the bomb arrives, and it hurts ----------------- + var component: Node = bomber.get_component("artillery") + if component.shell_speed < MIN_SHELL_SPEED: + return _fail("its bombs travel at %.0f - too slow to arrive" % component.shell_speed) + component.cooldown_left = 0.0 + elapsed = 0.0 + stage = 2 + return false + 2: + if _shells().is_empty(): + if elapsed > 6.0: + return _fail("the bomber never dropped a bomb") + return false + print("bomb away, travelling at %.0fpx/s" % _shells()[0].shell_speed) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 4.0: + return false + if is_instance_valid(target_ship) and not target_ship.is_queued_for_deletion(): + return _fail("a bomb landed on a stationary vessel and it survived") + print("the vessel it was aimed at is gone") + + # --- D/E. and it bombs what the player has built --------- + for hazard in get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The fleet is parked far away, so nothing here is about ships. + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2.ZERO) + building_id = int(game_state.buildings[-1].id) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.3: + return false + hp_before = game_state.building_hp(building_id) + bomber = _spawn_bomber(Vector2(600.0, 0.0)) + bomber.speed = 0.0 + bomber.get_component("artillery").cooldown_left = 0.0 + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 5.0: + return false + var now: int = game_state.building_hp(building_id) + if now >= hp_before: + return _fail("the bomber shelled a crafter with no fleet in sight and did nothing") + print("with the fleet 9000px away it bombed the crafter: %d -> %d hp" % [hp_before, now]) + + print("TEST_ENEMY_BOMBER: PASS") + quit(0) + return true + return false diff --git a/tests/test_garrison_abilities.gd.uid b/tests/test_garrison_abilities.gd.uid new file mode 100644 index 0000000..b69c8a1 --- /dev/null +++ b/tests/test_garrison_abilities.gd.uid @@ -0,0 +1 @@ +uid://dswjmvt7wj6ev diff --git a/tests/test_intro_scene.gd.uid b/tests/test_intro_scene.gd.uid new file mode 100644 index 0000000..cb06c12 --- /dev/null +++ b/tests/test_intro_scene.gd.uid @@ -0,0 +1 @@ +uid://b5elovr5n525a diff --git a/tests/test_menu_polish.gd b/tests/test_menu_polish.gd new file mode 100644 index 0000000..9db3f2a --- /dev/null +++ b/tests/test_menu_polish.gd @@ -0,0 +1,178 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_menu_polish.gd +# Forgejo #111, #112, #119 and #120 - four panels that were hard to read, +# each for its own reason. Grouped because they are all "what does this +# panel actually show", and because each alone is a handful of assertions. +# +# Covers: +# A. #111 - the craft menu names no resources: prices are glyphs, and +# every vessel gets a caption that does not overlap its neighbour +# B. #111 - and a price strip really carries the vessel's cost +# C. #120 - the hangar's captions have room for two lines, and there is a +# rule between what is parked and what is flying +# D. #112 - every skill node has a mark, and a node that unlocks a +# building uses that building's own icon +# E. #119 - the command centre draws the vessels actually in the fleet, +# not one placeholder repeated + +var game_state: Node +var scene: Node +var controller: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.reset_formation() + game_state.intro_active = false + game_state.rocks = 5000 + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + controller = scene.get_node("BuildController") + + +func _fail(message: String) -> bool: + print("TEST_MENU_POLISH: FAIL (%s)" % message) + quit(1) + return true + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.4: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- A. the craft menu spells no resource out ------------- + controller.craft_building_id = 1 + controller._refresh_craft_status() + # The caption is the vessel's NAME and nothing else. Checked as an + # exact match rather than by hunting for resource words in it: a + # Gas Bomber is named after a resource and always will be, and + # what #111 removed was the PRICE spelled out underneath. + var fleet_defs: Array = VesselDefinitions.fleet() + for i in range(mini(controller.craft_vessel_labels.size(), fleet_defs.size())): + var caption: String = String(controller.craft_vessel_labels[i].text) + var expected: String = String(fleet_defs[i].display_name) + if caption != expected: + return _fail("a craft caption reads \"%s\", expected just \"%s\"" \ + % [caption, expected]) + # Captions must not sit on top of one another, which is what + # made the old single row unreadable. + for i in range(controller.craft_vessel_labels.size()): + for j in range(i + 1, controller.craft_vessel_labels.size()): + var a: Control = controller.craft_vessel_labels[i] + var b: Control = controller.craft_vessel_labels[j] + if Rect2(a.position, a.size).intersects(Rect2(b.position, b.size)): + return _fail("craft captions %d and %d overlap" % [i, j]) + print("craft captions: %d, one line each, none overlapping" \ + % controller.craft_vessel_labels.size()) + + # --- B. and the price is really there --------------------- + if controller.craft_price_strips.size() != controller.craft_vessel_buttons.size(): + return _fail("%d price strips for %d vessels" \ + % [controller.craft_price_strips.size(), + controller.craft_vessel_buttons.size()]) + var priced: int = 0 + for i in range(controller.craft_price_strips.size()): + var definition: Resource = VesselDefinitions.fleet()[i] + var costs: Dictionary = game_state.product_costs(definition.definition_id) + var strip: Control = controller.craft_price_strips[i] + if costs.is_empty(): + continue + if strip.is_free(): + return _fail("%s costs %s and its strip is empty" \ + % [definition.definition_id, str(costs)]) + priced += 1 + if priced == 0: + return _fail("no vessel showed a price at all") + print("%d vessels show their price as glyphs" % priced) + + # --- C. the hangar --------------------------------------- + controller._layout_hangar() + if controller.hangar_divider_y <= 0.0: + return _fail("the hangar laid out with no divider between its halves") + var available_bottom: float = 0.0 + var fleet_top: float = 1.0e9 + for label in controller.hangar_available_labels: + available_bottom = maxf(available_bottom, label.position.y + label.size.y) + for slot in controller.hangar_fleet_slots: + fleet_top = minf(fleet_top, slot.position.y) + if not (controller.hangar_divider_y > available_bottom - 40.0 + and controller.hangar_divider_y < fleet_top): + return _fail("the divider at %.0f is not between the two grids (%.0f / %.0f)" \ + % [controller.hangar_divider_y, available_bottom, fleet_top]) + # Two lines of caption. The font is 24-ish, so anything under + # about 44 cannot show a second line at all. + var caption_height: float = controller.hangar_available_labels[0].size.y + if caption_height < 44.0: + return _fail("hangar captions are %.0fpx tall - a two-word name is clipped" \ + % caption_height) + print("hangar: %.0fpx captions, divider at %.0f between %.0f and %.0f" \ + % [caption_height, controller.hangar_divider_y, available_bottom, fleet_top]) + + # --- D. every skill node has a mark ----------------------- + var tree_view: Control = scene.get_node("UI/SkillMenu/Tree") + var marked: int = 0 + for node in GameState.SKILL_TREE: + var icon: int = game_state.skill_node_icon(node) + if icon < 0: + return _fail("skill node \"%s\" has no icon" % node.id) + marked += 1 + var unlocks: String = String(node.get("unlocks", "")) + if unlocks == "": + continue + var definition: Resource = game_state.building_definition(unlocks) + if definition != null and icon != int(definition.menu_icon): + return _fail("\"%s\" unlocks the %s but draws a different icon" \ + % [node.id, unlocks]) + if tree_view._glyphs.size() != marked: + return _fail("%d nodes have icons but the tree built %d glyphs" \ + % [marked, tree_view._glyphs.size()]) + print("%d skill nodes, every one marked, unlocks matching their building" % marked) + + # --- E. the formation shows the real fleet ---------------- + scene.get_node("Fleet").spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD])) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + var editor: Control = scene.get_node("UI/FormationMenu/Editor") + var crewed: PackedStringArray = game_state.fleet_vessel_types + if crewed.size() != 2: + return _fail("the fleet mirror holds %d types, expected 2" % crewed.size()) + var interceptor: Resource = game_state.vessel_definition(GameState.VESSEL_INTERCEPTOR) + var shield: Resource = game_state.vessel_definition(GameState.VESSEL_SHIELD) + if interceptor.hull_points == shield.hull_points: + return _fail("the two vessels share a hull, so this case proves nothing") + if not editor.has_method("_draw_vessel_slot"): + return _fail("the editor has no per-vessel drawing at all") + # The panel draws from the mirror, so the mirror having the right + # types in the right order IS what makes the markers differ. + if String(crewed[0]) != GameState.VESSEL_INTERCEPTOR \ + or String(crewed[1]) != GameState.VESSEL_SHIELD: + return _fail("the fleet mirror is %s, not the order they were crewed in" \ + % str(crewed)) + print("formation draws a %s and a %s, in the slots they fly in" \ + % [interceptor.display_name, shield.display_name]) + + print("TEST_MENU_POLISH: PASS") + quit(0) + return true + return false diff --git a/tests/test_spacial_workshop.gd b/tests/test_spacial_workshop.gd new file mode 100644 index 0000000..00eccca --- /dev/null +++ b/tests/test_spacial_workshop.gd @@ -0,0 +1,202 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_spacial_workshop.gd +# Forgejo #116: the Spacial Workshop and its repair drones. +# +# Modelled on the transport hub, so most of what it does is already +# understood: drones belong to a workshop, are provisioned when it is built, +# are never a production order, and are upgraded at a ship lab. What is new +# is what a repair COSTS - a share of the building's own price, in +# proportion to the health it is missing. +# +# Covers: +# A. the building is in the catalog behind its own research, with its own +# look, and it provisions drones the moment it is placed +# B. the drone is a catalogued vessel that CANNOT be crafted, and carries +# its own upgrades +# C. a full repair costs a share of the building's price scaled by the +# damage: undamaged is free, half-wrecked is about half +# D. the workshop asks hub logistics for the ore its repairs will need, +# and stops asking past its buffer +# E. a drone flies out, spends ore, and puts structure back +# F. it will not go with an empty till, and two drones never chase the +# same wall +# G. a building demolished mid-flight gets its ore back rather than +# swallowing it +# H. the berth upgrade reaches workshops already standing + +var game_state: Node +var workshop_id: int = 0 +var crafter_id: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.repair_drones.clear() + game_state.reset_progression() + game_state.reset_waves() + + +func _fail(message: String) -> bool: + print("TEST_SPACIAL_WORKSHOP: FAIL (%s)" % message) + quit(1) + return true + + +func _drones_of(id: int) -> Array: + var out: Array = [] + for drone in game_state.repair_drones: + if int(drone.get("workshop_id", 0)) == id: + out.append(drone) + return out + + +# Runs the autoload's own repair clock without waiting out the real seconds. +func _simulate(seconds: float) -> void: + var step: float = 1.0 / 60.0 + for i in range(int(seconds / step)): + game_state._advance_repairs(step) + + +func _process(_delta: float) -> bool: + var definitions: Script = load("res://buildings/BuildingDefinitions.gd") + + # --- A. catalogued, researched, and it brings its own drones --------- + var workshop_def: Resource = definitions.get_definition(GameState.BUILDING_WORKSHOP) + if workshop_def == null: + return _fail("no \"spacial_workshop\" definition in the catalog") + if workshop_def.visual_scene == null or workshop_def.menu_button_name == "": + return _fail("the workshop has no visual or no build-menu entry") + for other in definitions.all(): + if other.definition_id != GameState.BUILDING_WORKSHOP \ + and other.menu_icon == workshop_def.menu_icon: + return _fail("the workshop shares its icon with the %s" % other.definition_id) + if game_state.is_building_unlocked(GameState.BUILDING_WORKSHOP): + return _fail("the workshop is buildable before its node is researched") + var research: String = "" + for node in GameState.SKILL_TREE: + if node.get("unlocks", "") == GameState.BUILDING_WORKSHOP: + research = String(node.id) + if research == "": + return _fail("nothing in the skill tree unlocks the workshop") + game_state.unlocked.append(research) + + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + workshop_id = game_state.add_building(GameState.BUILDING_WORKSHOP, Vector2(600.0, 0.0)) + if _drones_of(workshop_id).size() != GameState.BASE_WORKSHOP_DRONES: + return _fail("a new workshop stationed %d drones, expected %d" \ + % [_drones_of(workshop_id).size(), GameState.BASE_WORKSHOP_DRONES]) + print("workshop researched at \"%s\", arrives with %d drones" \ + % [research, GameState.BASE_WORKSHOP_DRONES]) + + # --- B. the drone is a vessel, but not a product --------------------- + var drone_def: Resource = game_state.vessel_definition(GameState.REPAIR_DRONE) + if drone_def == null: + return _fail("no repair_drone in the vessel catalog") + if bool(drone_def.fleet_eligible): + return _fail("the repair drone is fleet-eligible - it would be craftable") + if drone_def.upgrades.is_empty(): + return _fail("the repair drone carries no ship-lab upgrades") + if game_state.queue_order(workshop_id, GameState.REPAIR_DRONE): + return _fail("a repair drone was accepted as a production order") + print("repair drone: %d lab upgrades, not craftable" % drone_def.upgrades.size()) + + # --- C. what a repair costs ----------------------------------------- + crafter_id = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(900.0, 0.0)) + var price: int = int(definitions.get_definition(GameState.BUILDING_SHIP_CRAFTER).cost) + if game_state.repair_cost(crafter_id) != 0: + return _fail("an undamaged crafter costs %d to repair" % game_state.repair_cost(crafter_id)) + var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER) + game_state.damage_building(crafter_id, full / 2) + var half_cost: int = game_state.repair_cost(crafter_id) + if absf(float(half_cost) - float(price) * 0.5) > float(price) * 0.12: + return _fail("a half-wrecked crafter costs %d of its %d price, expected about half" \ + % [half_cost, price]) + print("crafter at %d%% health: %d ore of its %d price" \ + % [int(game_state.building_health_fraction(crafter_id) * 100.0), half_cost, price]) + + # --- D. and it asks for that ore ------------------------------------ + var asked: int = game_state.building_requested_resource(workshop_id, GameState.RESOURCE_ORE) + if asked <= 0: + return _fail("a workshop with a wrecked crafter beside it asks for no ore") + if asked > GameState.WORKSHOP_ORE_BUFFER: + return _fail("it asked for %d ore, past its %d buffer" \ + % [asked, GameState.WORKSHOP_ORE_BUFFER]) + print("workshop asks hub logistics for %d ore" % asked) + + # --- F (first half). an empty till sends nobody ---------------------- + _simulate(4.0) + for drone in _drones_of(workshop_id): + if String(drone.state) != GameState.DRONE_IDLE: + return _fail("a drone left with no ore in the workshop") + if game_state.building_health_fraction(crafter_id) > 0.51: + return _fail("the crafter was repaired out of an empty workshop") + print("empty till: every drone stayed home") + + # --- E. paid for, it flies and mends -------------------------------- + game_state.deposit_resource_to_building(workshop_id, GameState.RESOURCE_ORE, 60) + var ore_before: int = game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) + var hp_before: int = game_state.building_hp(crafter_id) + game_state._advance_repairs(0.016) + var flying: int = 0 + for drone in _drones_of(workshop_id): + if String(drone.state) == GameState.DRONE_OUTBOUND: + flying += 1 + if flying != 1: + return _fail("%d drones set off for one damaged crafter" % flying) + # --- F (second half). the ore leaves with the drone ------------------ + if game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) >= ore_before: + return _fail("a drone left without the ore being taken") + _simulate(30.0) + var hp_after: int = game_state.building_hp(crafter_id) + if hp_after <= hp_before: + return _fail("the drone arrived and the crafter is still at %d hp" % hp_after) + print("drone spent %d ore and put %d structure back" \ + % [ore_before - game_state.building_resource(workshop_id, GameState.RESOURCE_ORE), + hp_after - hp_before]) + + # --- G. a destination that stops existing --------------------------- + game_state.building_inventory(workshop_id).set_amount(GameState.RESOURCE_ORE, 60) + var doomed: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-900.0, 0.0)) + game_state.damage_building(doomed, game_state.building_max_hp(GameState.BUILDING_SHIP_LAB) / 2) + game_state._advance_repairs(0.016) + var carrying: int = 0 + for drone in game_state.repair_drones: + if String(drone.state) == GameState.DRONE_OUTBOUND and int(drone.target_id) == doomed: + carrying = int(drone.carrying) + if carrying <= 0: + return _fail("nothing was dispatched to the damaged lab") + var till_before: int = game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) + game_state.destroy_building(doomed) + _simulate(30.0) + if game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) != till_before + carrying: + return _fail("a drone whose target was levelled under it lost its %d ore" % carrying) + print("target levelled in flight: %d ore came home" % carrying) + + # --- H. the upgrade reaches standing workshops ---------------------- + var bonus_id: String = "" + for node in GameState.SKILL_TREE: + if int(node.get("workshop_bonus", 0)) > 0: + bonus_id = String(node.id) + if bonus_id == "": + return _fail("no skill node raises the drone count") + var before_count: int = _drones_of(workshop_id).size() + game_state.rocks = 5000 + game_state.research_points = 20 + if not game_state.unlock(bonus_id): + return _fail("could not research \"%s\"" % bonus_id) + if _drones_of(workshop_id).size() != before_count + 1: + return _fail("a workshop built before the upgrade still has %d drones" \ + % _drones_of(workshop_id).size()) + print("\"%s\" raises an already-standing workshop from %d to %d drones" \ + % [bonus_id, before_count, _drones_of(workshop_id).size()]) + + print("TEST_SPACIAL_WORKSHOP: PASS") + quit(0) + return true diff --git a/tests/test_spacial_workshop.gd.uid b/tests/test_spacial_workshop.gd.uid new file mode 100644 index 0000000..e25a453 --- /dev/null +++ b/tests/test_spacial_workshop.gd.uid @@ -0,0 +1 @@ +uid://bab6h5ejrcxe8 diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd index 9e89c98..ab1e5e8 100644 --- a/tests/test_wave_system.gd +++ b/tests/test_wave_system.gd @@ -25,6 +25,8 @@ extends SceneTree # E. a launched wave really arrives, around the fleet, and clearing it # returns the world to peace # F. the alert is up during the warning and at no other time +# G. #110 - a dev-mode button puts a wave on the board now, skipping both +# the peace and the warning, and it does not exist outside dev mode var game_state: Node var scene: Node @@ -222,6 +224,35 @@ func _process(delta: float) -> bool: return _fail("killing the whole wave did not bring the quiet back") print("the wave died and the quiet came back") + # --- G. the dev button --------------------------------------- + var dev_button: Control = scene.get_node("UI/DevWaveButton") + game_state.dev_mode = false + dev_button._process(0.0) + if dev_button.visible: + return _fail("the dev wave button is offered in a normal game") + game_state.dev_mode = true + dev_button._process(0.0) + if not dev_button.visible: + return _fail("the dev wave button is hidden in dev mode") + var number_before: int = game_state.wave_number + dev_button.button.pressed.emit() + if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE: + return _fail("the button left the wave in \"%s\"" % game_state.wave_phase) + if not game_state.wave_pending: + return _fail("the button turned the wave active without owing it to anyone") + if game_state.wave_number != number_before + 1: + return _fail("the forced wave was not counted") + # The point of the button is to SEE the wave, so it skips the + # nine-second alert as well as the fifty-second peace. + if game_state.wave_is_incoming(): + return _fail("the button stopped at the warning - that is not \"now\"") + spawner._process(0.0) + if _enemy_count() <= 0: + return _fail("the forced wave produced no enemies") + print("dev button: wave %d on the board at once, %d enemies" \ + % [game_state.wave_number, _enemy_count()]) + game_state.dev_mode = false + print("TEST_WAVE_SYSTEM: PASS") quit(0) return true