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 <noreply@anthropic.com>
This commit is contained in:
parent
0810c99f08
commit
ec7a8f1f17
8 changed files with 790 additions and 0 deletions
184
tests/test_desktop_controls.gd
Normal file
184
tests/test_desktop_controls.gd
Normal file
|
|
@ -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.<instance member> 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
|
||||
192
tests/test_enemy_bomber.gd
Normal file
192
tests/test_enemy_bomber.gd
Normal file
|
|
@ -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.<instance member> 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
|
||||
1
tests/test_garrison_abilities.gd.uid
Normal file
1
tests/test_garrison_abilities.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dswjmvt7wj6ev
|
||||
1
tests/test_intro_scene.gd.uid
Normal file
1
tests/test_intro_scene.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b5elovr5n525a
|
||||
178
tests/test_menu_polish.gd
Normal file
178
tests/test_menu_polish.gd
Normal file
|
|
@ -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.<instance member> 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
|
||||
202
tests/test_spacial_workshop.gd
Normal file
202
tests/test_spacial_workshop.gd
Normal file
|
|
@ -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.<instance member> 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
|
||||
1
tests/test_spacial_workshop.gd.uid
Normal file
1
tests/test_spacial_workshop.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bab6h5ejrcxe8
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue