Resolve pending gameplay tickets

This commit is contained in:
Loïc Wisniewski 2026-09-02 13:43:56 +02:00
commit c2ebbfaa2d
28 changed files with 618 additions and 188 deletions

View file

@ -11,6 +11,8 @@ func _ready() -> void:
GameState.extractors.clear()
GameState.vessel_levels["unlock_laser"] = 1
GameState.vessel_levels["unlock_gas_bomber"] = 1
GameState.vessel_levels["unlock_distorder"] = 1
GameState.unlocked.append("wing")
GameState.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 200)
GameState.deposit_fleet_resource(GameState.RESOURCE_GAS, 200)
var main: Node = load("res://world/Main.tscn").instantiate()
@ -18,10 +20,12 @@ func _ready() -> void:
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
var fleet: Node = main.get_node("Fleet")
fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, GameState.VESSEL_GAS_BOMBER]))
fleet.spawn_ships(4, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER,
GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_DISTORDER]))
main.get_node("LaserController")._process(0.0)
main.get_node("GasBombController")._process(0.0)
main.get_node("DashController")._process(0.0)
# Push the fire stick, so its knob shows deflected the way it looks in use.
var stick: Control = main.get_node("UI/FireStick")
var press := InputEventScreenTouch.new()

View file

@ -2,19 +2,18 @@ extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_building_collateral.gd
# Forgejo #165: a gas bomb and a vessel flying fast enough both damage a
# structure. The blast used to stop politely at anything the player owned,
# which made a bomb dropped on a besieged mothership a free way to sweep the
# attackers off it.
# Forgejo #176: the player's own ships and their weapons do not damage the
# player's buildings. Friendly fire on structures made defending them with a
# gas bomb or crossing them at speed punish ordinary play.
#
# Covers:
# A. a bomb landing on a structure damages it
# A. a bomb landing on a structure leaves it unharmed
# B. a bomb landing clear of it does not - the radius is the radius, and
# this is a weapon that has to be aimed
# C. a vessel resting against a structure does NOT damage it. The fleet
# sits on its own mothership constantly to bank ore; if that hurt, the
# game's own economy loop would grind its buildings down
# D. a vessel arriving fast does
# D. a vessel arriving fast still does not damage it or itself
var game_state: Node
var main: Node
@ -23,6 +22,7 @@ var building_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var hp_before: int = 0
var ships_before: int = 0
const BUILDING_AT := Vector2(1200.0, 0.0)
@ -78,11 +78,11 @@ func _process(delta: float) -> bool:
return _fail("a bomb 900 away still damaged the structure")
print("a bomb clear of the structure leaves it alone")
# --- A. a bomb on top of it does --------------------------------
# --- A. a friendly bomb on top of it is safe ----------------------
_detonate_at(BUILDING_AT)
if _hp() >= hp_before:
return _fail("a bomb landing on the structure did not damage it (hp %d)" % _hp())
print("a bomb on the structure took it from %d to %d" % [hp_before, _hp()])
if _hp() != hp_before:
return _fail("a friendly bomb damaged the structure (%d -> %d)" % [hp_before, _hp()])
print("a friendly bomb on the structure leaves it at %d hp" % _hp())
# --- C. resting against it is free ------------------------------
hp_before = _hp()
@ -104,8 +104,9 @@ func _process(delta: float) -> bool:
% [hp_before, _hp()])
print("a vessel parked on the structure for %.1fs did nothing to it" % elapsed)
# --- D. arriving fast does --------------------------------------
# --- D. arriving fast is still friendly -------------------------
hp_before = _hp()
ships_before = fleet.ships.size()
for ship in fleet.ships:
# Placed clear and sent IN, because the impact is detected on
# entry - a ship spawned already overlapping never enters.
@ -118,9 +119,11 @@ func _process(delta: float) -> bool:
3:
if elapsed < 1.2:
return false
if _hp() >= hp_before:
return _fail("a vessel arriving at 900 did not damage the structure")
print("a vessel arriving fast took it from %d to %d" % [hp_before, _hp()])
if _hp() != hp_before:
return _fail("a fast friendly vessel damaged the structure (%d -> %d)" % [hp_before, _hp()])
if fleet.ships.size() != ships_before:
return _fail("a friendly building destroyed a vessel crossing it")
print("a vessel crossing the structure at speed leaves both unharmed")
print("TEST_BUILDING_COLLATERAL: PASS")
quit(0)

View file

@ -95,6 +95,8 @@ func _process(delta: float) -> bool:
point = load("res://surface/CapturePoint.tscn").instantiate()
point.point_index = 0
if point.capture_time > 4.0:
return _fail("default capture still takes %.1fs" % point.capture_time)
point.capture_time = 1.0
point.capture_radius = 200.0
point.enemy_clear_radius = 300.0

View file

@ -21,10 +21,10 @@ extends SceneTree
# E. it types while the game is stopped, being the one node still running
# F. the sci-fi part: the panel is drawn differently while decoding, and
# settles when the line is complete
# G. #168 - a KEY does exactly what a click does, both halves of it, since
# the game is played on a desktop with WASD and a mouse and pressing
# space at a dialog reached nothing at all. Modifiers alone and held-key
# echoes are not presses, or a line would be run out by a held key.
# G. #175 - keyboard presses do not finish or dismiss a dialog; only a
# deliberate tap/click advances it
# H. #178 - revealed text is seated at the left edge rather than growing
# outward from the middle
var game_state: Node
var scene: Node
@ -90,6 +90,8 @@ func _process(delta: float) -> bool:
return _fail("the label holds a partial string - the line will re-wrap as it types")
if narration.label.visible_characters < 0:
return _fail("the whole line is visible already")
if narration.label.horizontal_alignment != HORIZONTAL_ALIGNMENT_LEFT:
return _fail("dialog text is not left-aligned - partial text will grow from the middle")
seen.append(narration.label.visible_characters)
# Hand-ticked, so the reveal is measured rather than raced. The
@ -151,37 +153,34 @@ func _process(delta: float) -> bool:
return _fail("dismissing landed on \"%s\"" % narration.current)
print("the press after that moves on to \"%s\"" % narration.current)
# --- G. #168: a key is a press too -----------------------
# --- G. #175: keys never advance a dialog ----------------
narration.clear()
narration.say(first_line, "TEST")
narration.say("Second.", "TEST")
if not narration.is_typing():
return _fail("the fresh line was not arriving")
# Not everything that reaches the banner is a press.
_key(KEY_SHIFT)
_key(KEY_SPACE, true)
if not narration.is_typing():
return _fail("a modifier or a held-key echo finished the line")
# Still arriving: the key shows the whole of it, and no more.
_key(KEY_SPACE)
if narration.is_typing():
return _fail("a key did not finish a line that was still arriving")
if narration.current != first_line:
return _fail("a key on an arriving line dismissed it - the next line would be skipped unread")
# Whole: the key moves on, the same as a click.
_key(KEY_SPACE)
if narration.current != "Second.":
return _fail("a key on a finished line landed on \"%s\"" % narration.current)
# Any key, not one chosen key - the player has no reason to guess.
_key(KEY_W)
if narration.is_typing():
return _fail("only some keys finish a line")
_key(KEY_ENTER)
if not narration.is_typing() or narration.current != first_line:
return _fail("a keyboard press changed a dialog that was still arriving")
narration.finish_typing()
_key(KEY_W)
_key(KEY_ENTER)
if narration.current != first_line or narration.is_typing():
return _fail("a keyboard press dismissed a complete dialog")
_click()
if narration.current != "Second.":
return _fail("clicking after ignored keys did not reach the second line")
narration.finish_typing()
_click()
if narration.is_speaking():
return _fail("a key on the last line did not close the dialog")
return _fail("clicking the last complete line did not close the dialog")
if paused:
return _fail("keying through every line left the game stopped")
print("space, W and enter all finish then advance a line; shift and echoes do not")
return _fail("clicking through every line left the game stopped")
print("space, W and enter leave dialogs untouched; clicks still finish and advance")
print("TEST_DIALOG_TYPING: PASS")
quit(0)

View file

@ -0,0 +1,137 @@
extends SceneTree
# Forgejo #179: an Energy Propulser in the fleet grants a hold-aim-release
# dash. Covers visibility, aimed movement, energy cost, formation integrity,
# and one cooldown per Propulser.
const DASH_COST: int = 15
const DASH_DISTANCE: float = 520.0
var state: Node
var main: Node
var fleet: Node
var dash: Node
var stage: int = 0
var elapsed: float = 0.0
var first_origin: Vector2
var second_origin: Vector2
var offsets_before: Array = []
var first_component: Node
func _initialize() -> void:
state = root.get_node("GameState")
state.reset_new_game()
state.intro_active = false
state.reset_quests()
state.vessel_levels["unlock_distorder"] = 1
state.fleet_inventory.total_capacity = -1
state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, DASH_COST * 3)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
dash = main.get_node("DashController")
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)
if main.get_node("UI/DashButton").visible:
return _fail("dash button appeared without an Energy Propulser")
# The carrier keeps enough hold capacity for both dashes. Two
# Propulsers prove that cooldown belongs to a vessel, not the fleet.
fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER,
GameState.VESSEL_DISTORDER,
]))
dash._process(0.0)
if not main.get_node("UI/DashButton").visible:
return _fail("an Energy Propulser did not reveal the dash button")
first_component = dash._next_ready_ship().get_component("distorder")
first_origin = fleet.get_centroid()
for ship in fleet.ships:
offsets_before.append(ship.position - first_origin)
_aim_and_release(Vector2.RIGHT, 11)
if not fleet.is_dashing():
return _fail("releasing a rightward aim did not begin a dash")
if state.fleet_resource(GameState.RESOURCE_ENERGY) != DASH_COST * 2:
return _fail("first dash did not consume %d energy" % DASH_COST)
if first_component.is_ready():
return _fail("the Propulser that dashed did not enter cooldown")
dash._process(0.0)
if not main.get_node("UI/DashButton").enabled:
return _fail("one cooling Propulser disabled a second ready one")
stage = 1
elapsed = 0.0
return false
1:
if elapsed < 0.7:
return false
var moved: Vector2 = fleet.get_centroid() - first_origin
if absf(moved.x - DASH_DISTANCE) > 8.0 or absf(moved.y) > 8.0:
return _fail("rightward aim moved the fleet by %s" % moved)
for i in range(fleet.ships.size()):
var offset: Vector2 = fleet.ships[i].position - fleet.get_centroid()
if offset.distance_to(offsets_before[i]) > 1.0:
return _fail("dash distorted formation slot %d by %.2f" \
% [i, offset.distance_to(offsets_before[i])])
second_origin = fleet.get_centroid()
_aim_and_release(Vector2.UP, 12)
if state.fleet_resource(GameState.RESOURCE_ENERGY) != DASH_COST:
return _fail("second Propulser did not pay for its own dash")
dash._process(0.0)
if main.get_node("UI/DashButton").enabled:
return _fail("dash remained enabled after both Propulsers fired")
stage = 2
elapsed = 0.0
return false
2:
if elapsed < 0.7:
return false
var moved: Vector2 = fleet.get_centroid() - second_origin
if absf(moved.y + DASH_DISTANCE) > 8.0 or absf(moved.x) > 8.0:
return _fail("upward aim moved the fleet by %s" % moved)
first_component.tick(first_component.DASH_COOLDOWN)
dash._process(0.0)
if not main.get_node("UI/DashButton").enabled:
return _fail("button did not re-enable when one Propulser recharged")
var surface: Node = load("res://surface/PlanetSurface.tscn").instantiate()
if not surface.has_node("DashController") or not surface.has_node("UI/DashButton"):
return _fail("planet surface does not carry the dash ability")
surface.free()
print("two aimed dashes moved %.0f units, preserved formation, and cooled down per Propulser" \
% DASH_DISTANCE)
print("TEST_ENERGY_PROPULSER_DASH: PASS")
quit(0)
return true
return false
func _aim_and_release(direction: Vector2, touch_index: int) -> void:
var button: Control = main.get_node("UI/DashButton")
var press := InputEventScreenTouch.new()
press.index = touch_index
press.pressed = true
press.position = button.global_position + button.size * 0.5
main._handle_touch(press)
var drag := InputEventScreenDrag.new()
drag.index = touch_index
drag.position = press.position + direction * 240.0
main._handle_drag(drag)
var release := InputEventScreenTouch.new()
release.index = touch_index
release.pressed = false
release.position = drag.position
main._handle_touch(release)
func _fail(message: String) -> bool:
print("TEST_ENERGY_PROPULSER_DASH: FAIL (%s)" % message)
quit(1)
return true

View file

@ -15,6 +15,8 @@ extends SceneTree
# 5. #169 - a second press while a load is running is ignored, or an
# impatient double tap builds the world twice (on the web a button fires
# on PRESS, so that tap costs nothing to make)
# 6. #174 - the opaque loading cover fills the viewport and puts its
# activity bar at the middle of the screen
var menu: Node
var game_state: Node
@ -61,7 +63,19 @@ func _process(delta: float) -> bool:
return _fail("the press started a load with no loading screen up")
if current_scene != menu:
return _fail("the scene swapped inside the press, so nothing was ever shown")
var viewport_size: Vector2 = menu.get_viewport().get_visible_rect().size
if menu.loading_screen.size.distance_to(viewport_size) > 1.0:
return _fail("loading cover is %s, viewport is %s" \
% [menu.loading_screen.size, viewport_size])
if menu.loading_screen.BACKDROP.a < 0.999:
return _fail("loading cover is translucent (alpha %.2f)" \
% menu.loading_screen.BACKDROP.a)
var bar: Rect2 = menu.loading_screen._bar_rect()
if absf(bar.get_center().y - viewport_size.y * 0.5) > 1.0:
return _fail("loading bar centre %.1f is not screen centre %.1f" \
% [bar.get_center().y, viewport_size.y * 0.5])
print("pressing an entry raises the loading screen and leaves the menu up")
print("loading cover fills %s and centres its bar" % viewport_size)
# --- 5. and a second press changes nothing --------------------
var screen: Node = menu.loading_screen

View file

@ -2,9 +2,9 @@ extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_payments_use_fleet_hold.gd
# Every payment draws on the bank AND on what the ships are carrying. The
# player can already shuttle resources between the two by hand, so refusing a
# purchase that a couple of taps would have made affordable was ceremony.
# A payment draws on the bank and, only at the place being paid, what the
# ships are carrying. Fleet cargo is physical: a building across the map may
# not reach into it (#180).
#
# It was also a deadlock. A new game starts with an empty bank, ore is mined
# into the fleet hold, and the only way to bank it is to deliver it to a
@ -22,8 +22,10 @@ extends SceneTree
# are physically carrying
# F. end to end: an empty bank and ore in the hold actually gets a
# mothership built through BuildController
# G. no payment position, or a distant one, cannot drain the fleet hold
const HOLD_ORE: int = 40
const PAYMENT_AT := Vector2(120.0, 60.0)
var game_state: Node
var scene: Node
@ -37,6 +39,7 @@ func _initialize() -> void:
game_state.reset_progression()
game_state.buildings.clear()
game_state.extractors.clear()
game_state.fleet_position = PAYMENT_AT
var cost: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])
@ -50,13 +53,16 @@ func _initialize() -> void:
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) >= cost:
_fail("the bank was not actually empty, so nothing below tests the deadlock")
return
if not game_state.can_afford(cost):
if game_state.can_afford(cost):
_fail("a location-free affordability check reached into the fleet hold")
return
if not game_state.can_afford(cost, PAYMENT_AT):
_fail("a fleet holding %d ore could not afford a %d purchase" % [HOLD_ORE, cost])
return
print("bank 0 + hold %d can afford a %d purchase" % [HOLD_ORE, cost])
# --- B. bank first, hold for the shortfall -------------------------------
if not game_state.spend(cost):
if not game_state.spend(cost, PAYMENT_AT):
_fail("spend refused a purchase it had just called affordable")
return
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \
@ -68,7 +74,7 @@ func _initialize() -> void:
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30)
if not game_state.spend(20):
if not game_state.spend(20, PAYMENT_AT):
_fail("a 20 purchase was refused with 8 banked and 30 held")
return
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \
@ -82,7 +88,7 @@ func _initialize() -> void:
# --- C. no partial charge -----------------------------------------------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5)
if game_state.spend(50):
if game_state.spend(50, PAYMENT_AT):
_fail("a purchase costing more than everything owned was allowed")
return
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \
@ -93,6 +99,22 @@ func _initialize() -> void:
return
print("a purchase nobody can afford takes nothing")
# --- G. cargo does not teleport to a remote purchase ------------------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost)
game_state.fleet_position = PAYMENT_AT + Vector2(9000.0, 0.0)
if game_state.spend(cost, PAYMENT_AT):
_fail("a remote purchase took resources from the fleet hold")
return
if game_state.fleet_resource(GameState.RESOURCE_ORE) != cost:
_fail("a refused remote payment still drained the hold")
return
if game_state.spend(cost):
_fail("a purchase with no location reached into the fleet hold")
return
game_state.fleet_position = PAYMENT_AT
print("distant and location-free payments leave fleet cargo untouched")
# The hold has a real capacity (one starting interceptor carries 20), and
# set_amount() is clamped by it - which silently gave the checks below
# less than they asked for and made an affordable price look unaffordable.
@ -125,12 +147,12 @@ func _check_multi_resource_atomicity() -> bool:
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 10)
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 1)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 1)
if game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 40}):
if game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 40}, PAYMENT_AT):
return _fail("a two-resource price was charged with only one of them affordable")
if game_state.available_ore() != 20 or game_state.available_resource(GameState.RESOURCE_GAS) != 2:
return _fail("a refused two-resource purchase still took some ore or gas (ore %d, gas %d)" \
% [game_state.available_ore(), game_state.available_resource(GameState.RESOURCE_GAS)])
if not game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 2}):
if not game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 2}, PAYMENT_AT):
return _fail("an affordable two-resource price was refused")
if game_state.available_ore() != 5 or game_state.available_resource(GameState.RESOURCE_GAS) != 0:
return _fail("a two-resource purchase charged wrongly (ore %d left, gas %d left)" \
@ -147,9 +169,9 @@ func _check_skill_tree() -> bool:
game_state.research_points = 999
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost)
if not game_state.can_unlock(id):
if not game_state.can_unlock(id, PAYMENT_AT):
return _fail("the skill tree refused '%s' (cost %d) with the whole price in the fleet hold" % [id, cost])
if not game_state.unlock(id):
if not game_state.unlock(id, PAYMENT_AT):
return _fail("unlocking '%s' failed despite can_unlock saying yes" % id)
if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0:
return _fail("the skill unlock did not charge the hold: %d left of %d" \
@ -177,9 +199,9 @@ func _check_ship_lab() -> bool:
game_state.research_points = 999
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost)
if not game_state.can_buy_upgrade(id):
if not game_state.can_buy_upgrade(id, PAYMENT_AT):
return _fail("the ship lab refused upgrade '%s' (cost %d) with the whole price in the hold" % [id, cost])
if not game_state.buy_upgrade(id):
if not game_state.buy_upgrade(id, PAYMENT_AT):
return _fail("buying '%s' failed despite can_buy_upgrade saying yes" % id)
if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0:
return _fail("the upgrade did not charge the hold: %d left of %d" \
@ -206,7 +228,7 @@ func _check_vessel_research() -> bool:
for resource_type in costs:
game_state.bank_inventory.set_amount(String(resource_type), 0)
game_state.fleet_inventory.set_amount(String(resource_type), int(costs[resource_type]))
if not game_state.unlock_vessel(vessel):
if not game_state.unlock_vessel(vessel, PAYMENT_AT):
return _fail("researching '%s' was refused with its whole price (%s) in the fleet hold" % [vessel, str(costs)])
for resource_type in costs:
if game_state.available_resource(String(resource_type)) != 0:

View file

@ -33,6 +33,7 @@ func _initialize() -> void:
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
@ -92,9 +93,17 @@ func _process(delta: float) -> bool:
return _fail("the research panel still has an UnlockButton node")
print("the separate buy button is gone from the panel")
main.get_node("UI/SkillMenu").visible = true
tree_view.visible = true
tree_view.reset_scroll()
# Open through the menu's public API so research has the mothership as its
# payment site. Since fleet-held resources became location-aware, merely
# forcing the Control visible no longer represents a reachable game state.
var mothership_id: int = 0
for building in game_state.buildings:
if String(building.type) == GameState.BUILDING_MOTHERSHIP:
mothership_id = int(building.id)
break
if mothership_id == 0:
return _fail("the test has no mothership research site")
main.get_node("UI/SkillMenu").open_for(mothership_id)
game_state.unlocked.clear()
game_state.research_points = 99
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500)