Compare commits

...
Author SHA1 Message Date
Loïc Wisniewski
f2309ea1c2 Track the uid files five test scripts were missing
Godot writes one per script and the project tracks the other 412; these
were left behind when their tests were added. Nothing but bookkeeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
2026-09-12 19:06:29 +02:00
Loïc Wisniewski
a43fae5e28 Let the speed fader have the click it is under
Clicking the fader did nothing on a desktop: it fired the guns instead.
Unhandled input reaches the last nodes in the tree first, and the fader's
mouse handling lived in GameController - the script on Main, the ROOT, so
it was served last of all. MouseCombatController is a child of it and marks
every left press handled to fire, so the press never arrived.

The fader asks for its own mouse events now, from the UI layer, which sits
after the combat controllers: the control the pointer is actually on gets
the press ahead of the guns behind it. The handlers themselves are the same
wrappers #118 wrote.

The test for #118 could not see any of this - it handed the event straight
to Main._unhandled_input, which is precisely the delivery the real game
never makes, so it passed against the bug it was written for. It pushes
through the viewport now, with mouse aiming on, and checks the guns stay
quiet. Confirmed it fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
2026-09-12 19:06:29 +02:00
Loïc Wisniewski
93bfe62e76 Let the fleet grow past twenty vessels
max_fleet_size() clamped itself to DEFAULT_FORMATION.size(), so the ceiling
on the fleet was a property of the LAYOUT - the wedge ran out of authored
positions at twenty - rather than a rule anyone decided on. Capacity is the
supplied Fleet Offices and nothing else now.

Dropping the clamp alone would index off the end of the formation array, so
the wedge continues by rule instead of stopping: formation_slot() grows it
on demand, each further pair stepping 30 further out and 40 further back,
which is what the authored rows already do - the first twenty slots come
back unchanged. A grown slot is rotated by formation_rotation so a fleet
that outgrows the array after the player has pivoted does not sprout hulls
facing the original heading, and that angle is saved with the layout.

Two "fleet is full" messages pointed at the vanished ceiling. Both now say
what actually buys a berth, and the hangar's stops claiming it is research,
which it has not been since #219.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
2026-09-12 19:06:29 +02:00
15 changed files with 196 additions and 41 deletions

View file

@ -839,7 +839,7 @@ func _on_hangar_crew_type(selection: Variant) -> void:
if vessel_type == "":
return
if fleet.ships.size() >= GameState.max_fleet_size():
hangar_menu.set_status("Fleet is full (%d/%d) - research capacity at the mothership" \
hangar_menu.set_status("Fleet is full (%d/%d) - each further berth is a supplied Fleet Office" \
% [fleet.ships.size(), GameState.max_fleet_size()])
return
var stored_index: int = GameState.stored_vessels.find(vessel_type)

View file

@ -383,14 +383,14 @@ func _unhandled_input(event: InputEvent) -> void:
_handle_key(event)
elif event is InputEventMouseMotion:
_note_keyboard()
if speed_fader != null:
speed_fader.handle_mouse_motion(event)
elif event is InputEventMouseButton:
_note_keyboard()
# #118: the fader gets first refusal, so dragging the boost does not
# also pan or zoom the world behind it.
if speed_fader != null and speed_fader.handle_mouse_button(event):
return
# The speed fader used to be driven from here, and could not work:
# MouseCombatController is dispatched before this node (it is a
# child, and this is the root) and marks every left press handled to
# fire the guns, so the press never arrived. It listens for its own
# mouse events now - see SpeedFader._unhandled_input - and claims
# them from the UI layer, ahead of the combat controllers.
if not event.pressed:
return
# Wheel zoom, the desktop equivalent of the pinch.

View file

@ -951,11 +951,16 @@ func _advance_fleet_office_upkeep(delta: float) -> void:
fleet_upkeep_failed.emit(i + BASE_FLEET_SIZE, vessel_type)
# However many berths the fleet offices are keeping supplied, with no
# ceiling above them: a player who builds and feeds fifty offices flies
# fifty vessels. There used to be one at DEFAULT_FORMATION.size(), which was
# a limit of the LAYOUT rather than a rule of the game - the wedge simply ran
# out of authored positions at twenty. It now continues by rule instead (see
# formation_slot), so there is nothing left for the cap to protect.
func max_fleet_size() -> int:
if intro_fleet_size > 0:
return mini(intro_fleet_size, DEFAULT_FORMATION.size())
return mini(BASE_FLEET_SIZE + supplied_fleet_office_count(),
DEFAULT_FORMATION.size())
return intro_fleet_size
return BASE_FLEET_SIZE + supplied_fleet_office_count()
func reset_progression() -> void:
@ -2798,11 +2803,11 @@ func get_mothership_position() -> Variant:
# capacity upgrade is researched, but they are defined up front so the wedge
# keeps a deliberate shape as it grows rather than having positions invented
# at unlock time.
# Twenty slots, because max_fleet_size() caps itself on this array's length -
# so the wedge has to be long enough for every doctrine to be worth buying.
# Each further pair continues the same V rather than starting a new shape:
# the rows step 30 out and 40 back, which is the rule the first ten already
# followed, so the wedge that arrives at twenty is the one that left at three.
# Twenty slots are AUTHORED. They are not a cap: the fleet grows as far as
# the offices supply it, and formation_slot() continues the wedge past the
# end of this array by the same rule the rows here already follow - each
# further pair steps 30 further out and 40 further back, so the wedge that
# arrives at fifty is the one that left at three.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
@ -2814,9 +2819,43 @@ const DEFAULT_FORMATION: Array = [
var formation: Array = DEFAULT_FORMATION.duplicate()
# Which way the wedge currently faces. Kept because slots are grown on
# demand: one that appears after the player has pivoted has to arrive
# already turned, or a fleet that outgrows the authored twenty would sprout
# hulls pointing back along the formation's original heading.
var formation_rotation: float = 0.0
# The rule the wedge continues by past the authored slots. Pairs start at
# index 8 - the point where the hand-placed rows settle into a regular
# V - with the even index on the left, and each pair after that steps out
# and back by FORMATION_PAIR_STEP.
const FORMATION_PAIR_FIRST: int = 8
const FORMATION_PAIR_ORIGIN: Vector2 = Vector2(120.0, 112.0)
const FORMATION_PAIR_STEP: Vector2 = Vector2(30.0, 40.0)
func reset_formation() -> void:
formation = DEFAULT_FORMATION.duplicate()
formation_rotation = 0.0
# Where one slot sits, growing the wedge if the fleet has outgrown the
# positions stored so far. Every reader asks through this rather than
# indexing `formation` directly, because with no cap on fleet size there is
# no index the array is guaranteed to already hold.
func formation_slot(index: int) -> Vector2:
if index < 0:
return Vector2.ZERO
while formation.size() <= index:
formation.append(_grown_formation_slot(formation.size()).rotated(formation_rotation))
return formation[index]
func _grown_formation_slot(index: int) -> Vector2:
var pair: int = (index - FORMATION_PAIR_FIRST) / 2
var out: float = FORMATION_PAIR_ORIGIN.x + FORMATION_PAIR_STEP.x * float(pair)
return Vector2(out if index % 2 == 1 else -out,
FORMATION_PAIR_ORIGIN.y + FORMATION_PAIR_STEP.y * float(pair))
func rotate_formation(radians: float) -> void:
@ -2824,6 +2863,7 @@ func rotate_formation(radians: float) -> void:
# and only changing which way it faces.
for i in range(formation.size()):
formation[i] = formation[i].rotated(radians)
formation_rotation = wrapf(formation_rotation + radians, -PI, PI)
# --- Extractors ------------------------------------------------------------

View file

@ -47,7 +47,7 @@ const FIELDS: Array = [
"unlocked", "research_points", "vessel_levels",
# The fleet itself.
"fleet_position", "fleet_vessel_types", "surviving_ship_count",
"surviving_vessel_types", "formation", "stored_vessels",
"surviving_vessel_types", "formation", "formation_rotation", "stored_vessels",
"vessel_targets", "vessel_deliveries", "repair_drones",
"max_fleet_speed_multiplier", "fleet_speed_multiplier", "build_time_scale",
# The world as the player has seen it.

View file

@ -0,0 +1 @@
uid://bk3grd8nn8pj1

View file

@ -0,0 +1 @@
uid://ckdulc4563wt5

View file

@ -10,16 +10,19 @@ extends SceneTree
# 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.
# Driven through the REAL routing in each case - the viewport itself 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. It did, in fact, and hid a second one: the fader was dispatched
# from Main, which is the LAST node to see an unhandled press, so
# MouseCombatController fired the guns with it and the knob never moved.
#
# 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
# B. the press stops at the fader: not the camera behind it, and not the
# guns, which claim every other left press on a desktop
# 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
@ -67,6 +70,31 @@ func _fail(message: String) -> bool:
return true
# Pushed through the viewport, not handed to a node's own _unhandled_input:
# WHICH node sees a press first is the thing under test. #118 shipped a fader
# that only worked when a test called it directly - MouseCombatController
# claims every left press to fire the guns, and it is dispatched well before
# Main's own handler, so on a real desktop the click never arrived.
# `true` keeps the coordinates in viewport space, which is the space the
# controls are laid out in.
func _push(event: InputEvent) -> void:
root.push_input(event, true)
func _push_click(at: Vector2, pressed: bool) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.pressed = pressed
event.position = at
_push(event)
func _push_move(to: Vector2) -> void:
var event: InputEventMouseMotion = InputEventMouseMotion.new()
event.position = to
_push(event)
func _click(at: Vector2, pressed: bool, target: Node) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
@ -112,22 +140,29 @@ func _process(delta: float) -> bool:
return _fail("the boost has no range to move through")
# --- A. click, drag, release -----------------------------
# With mouse aiming live, which is what a desktop always has:
# the guns are the other thing a left press means, and they used
# to take every one of them.
var mouse_combat: Node = scene.get_node("MouseCombatController")
mouse_combat.pointer_mode = mouse_combat.PointerMode.ON
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)
_push_click(bottom, true)
if not fader.is_active():
return _fail("a click on the fader did not take hold of it")
if mouse_combat.firing:
return _fail("the click that grabbed the fader also fired the fleet's guns")
var at_bottom: float = game_state.fleet_speed_multiplier
_move(top, scene)
_push_move(top)
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)
_push_click(top, false)
if fader.is_active():
return _fail("the fader is still held after the button came up")
_move(bottom, scene)
_push_move(bottom)
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" \
@ -135,10 +170,13 @@ func _process(delta: float) -> bool:
# --- B. it claims the press ------------------------------
var zoom_before: float = scene.get_node("Camera2D").zoom.x
_click(bottom, true, scene)
_click(bottom, false, scene)
_push_click(bottom, true)
_push_click(bottom, false)
if not is_equal_approx(scene.get_node("Camera2D").zoom.x, zoom_before):
return _fail("pressing the fader also moved the camera behind it")
if mouse_combat.firing:
return _fail("the fader's press reached the guns behind it")
mouse_combat.pointer_mode = mouse_combat.PointerMode.AUTO
print("the press stops at the fader; the world behind it is untouched")
# --- C/D/E. the formation editor -------------------------

View file

@ -21,7 +21,8 @@ extends SceneTree
# B. each supplied office adds exactly one berth, and the cap bites
# C. an office that cannot pay loses its vessel, and says so
# D. a restocked office goes back to work without being rebuilt
# E. capacity can never outrun the formation's defined positions
# E. the wedge has a place for every slot, however far out it is
# F. and nothing caps the fleet at the twenty positions the wedge authors
var game_state: Node
var scene: Node
@ -107,10 +108,35 @@ func _process(delta: float) -> bool:
GameState.BASE_FLEET_SIZE + i + 1])
print("3 supplied offices -> %d berths" % game_state.max_fleet_size())
# --- E. and never more than the formation can seat ------------
if game_state.max_fleet_size() > GameState.DEFAULT_FORMATION.size():
return _fail("capacity %d exceeds the %d positions the formation defines" \
% [game_state.max_fleet_size(), GameState.DEFAULT_FORMATION.size()])
# --- E. the wedge has a place for every slot ------------------
# The formation used to be the fleet's real ceiling: it defined
# twenty positions and capacity clamped itself to them. It now
# continues by rule instead, so what is pinned here is that the
# rule keeps the authored shape - the far slots are distinct,
# and each pair still steps 30 out and 40 back from the last.
var authored: int = GameState.DEFAULT_FORMATION.size()
var probe: int = authored * 3
var seen: Dictionary = {}
for i in range(probe):
var slot: Vector2 = game_state.formation_slot(i)
var key: String = "%.1f,%.1f" % [slot.x, slot.y]
if seen.has(key):
return _fail("slot %d sits on top of slot %d at %s" % [i, int(seen[key]), key])
seen[key] = i
for i in range(authored):
if not game_state.formation_slot(i).is_equal_approx(GameState.DEFAULT_FORMATION[i]):
return _fail("growing the wedge moved authored slot %d to %s" \
% [i, game_state.formation_slot(i)])
for i in range(authored, probe - 2):
var here: Vector2 = game_state.formation_slot(i)
var behind: Vector2 = game_state.formation_slot(i + 2)
var step: Vector2 = Vector2(GameState.FORMATION_PAIR_STEP.x * signf(here.x),
GameState.FORMATION_PAIR_STEP.y)
if not (behind - here).is_equal_approx(step):
return _fail("slot %d steps %s from slot %d, expected %s" \
% [i + 2, behind - here, i, step])
print("the wedge continues past its %d authored slots, %d checked and all distinct" \
% [authored, probe])
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
@ -187,6 +213,32 @@ func _process(delta: float) -> bool:
return _fail("a supplied office lost its vessel anyway")
print("restocked and re-crewed: the office pays and the vessel stays")
# --- F. and nothing caps the fleet at twenty ------------------
# Offices past the wedge's authored positions buy real berths,
# and the fleet really flies them: every hull gets a place of
# its own rather than piling onto the last defined slot.
var target: int = GameState.DEFAULT_FORMATION.size() + 4
while game_state.max_fleet_size() < target:
var extra: int = game_state.add_building(GameState.BUILDING_FLEET_OFFICE,
Vector2(600.0 * float(game_state.max_fleet_size()), 900.0))
_stock(extra, 500)
if game_state.max_fleet_size() != target:
return _fail("%d supplied offices gave %d berths, expected %d" \
% [game_state.fleet_offices().size(), game_state.max_fleet_size(), target])
while fleet.ships.size() < target:
if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
return _fail("berth %d could not be crewed" % (fleet.ships.size() + 1))
var places: Dictionary = {}
for i in range(fleet.ships.size()):
var offset: Vector2 = fleet.formation_offsets[i]
var place: String = "%.1f,%.1f" % [offset.x, offset.y]
if places.has(place):
return _fail("vessel %d flies the same place as vessel %d" \
% [i, int(places[place])])
places[place] = i
print("%d berths, %d vessels, %d distinct places - no ceiling at %d" \
% [target, fleet.ships.size(), places.size(), GameState.DEFAULT_FORMATION.size()])
print("TEST_FLEET_GROWTH: PASS")
quit(0)
return true

View file

@ -0,0 +1 @@
uid://bvb4aa4k1702

View file

@ -0,0 +1 @@
uid://ctxpryt5rqddw

View file

@ -0,0 +1 @@
uid://ntytohsnghw3

View file

@ -37,8 +37,8 @@ func _on_fleet_add_requested(vessel_type: String) -> void:
if fleet.add_ship(vessel_type):
menu.set_status("Added %s" % GameState.vessel_name(vessel_type))
else:
menu.set_status("Fleet is full — unlock all skills for up to %d slots" \
% GameState.DEFAULT_FORMATION.size())
menu.set_status("Fleet is full at %d — every berth past the first is a supplied Fleet Office" \
% GameState.max_fleet_size())
func _on_fleet_remove_requested(vessel_type: String) -> void:

View file

@ -38,7 +38,7 @@ func _centre() -> Vector2:
func _slot_screen_pos(i: int) -> Vector2:
return _centre() + GameState.formation[i] * scale_factor
return _centre() + GameState.formation_slot(i) * scale_factor
func _screen_to_formation(p: Vector2) -> Vector2:

View file

@ -101,6 +101,28 @@ func handle_mouse_motion(event: InputEventMouseMotion) -> bool:
return true
# ...and listening for those events HERE rather than being handed them by
# GameController, which is what #118 did.
#
# Unhandled input reaches the last nodes in the tree first, and Main - where
# that dispatch lives - is the root, so it is served last of all.
# MouseCombatController is a child of it and claims every left press to fire
# the fleet's guns, which meant a click on the fader shot at the fader
# instead of taking hold of it: the knob never moved on a desktop. The UI
# layer sits after the combat controllers, so asking for the event here puts
# the control the player is actually pointing at ahead of the guns behind it.
#
# Touch still comes through GameController: nothing competes for a finger,
# and the joystick/fire stick ordering it arbitrates is real.
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if handle_mouse_button(event):
get_viewport().set_input_as_handled()
elif event is InputEventMouseMotion:
if handle_mouse_motion(event):
get_viewport().set_input_as_handled()
func handle_touch(event: InputEventScreenTouch) -> bool:
return _press(event.pressed, event.position, event.index)

View file

@ -96,7 +96,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO,
spawn_types.append(GameState.VESSEL_INTERCEPTOR)
var n: int = clamp(spawn_types.size(), 1, GameState.max_fleet_size())
for i in range(n):
var offset: Vector2 = GameState.formation[i]
var offset: Vector2 = GameState.formation_slot(i)
var ship: Area2D = ship_scene.instantiate()
ship.vessel_type = spawn_types[i]
ship.position = center + offset
@ -178,7 +178,7 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool:
if not GameState.vessel_is_fleet_eligible(vessel_type) \
or ships.size() >= GameState.max_fleet_size():
return false
var offset: Vector2 = GameState.formation[ships.size()]
var offset: Vector2 = GameState.formation_slot(ships.size())
var ship: Area2D = ship_scene.instantiate()
# Before add_child, so Ship._ready() builds the right vessel - the shield
# ring is created there and only for a shield vessel.
@ -222,9 +222,7 @@ func apply_formation() -> void:
# Without this, editing the layout at a command centre (or pivoting it)
# only affected ships built later, which read as the edit doing nothing.
for i in range(ships.size()):
if i >= GameState.max_fleet_size():
break
formation_offsets[i] = GameState.formation[i]
formation_offsets[i] = GameState.formation_slot(i)
_assign_slot_targets()