From 93bfe62e769ade16973fceaa38ff528362f67596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 12 Sep 2026 19:06:03 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn --- controllers/BuildController.gd | 2 +- core/GameState.gd | 56 +++++++++++++++++++++++++----- core/SaveGame.gd | 2 +- tests/test_fleet_growth.gd | 62 +++++++++++++++++++++++++++++++--- ui/DevMenuButton.gd | 4 +-- ui/FormationEditor.gd | 2 +- vessels/Fleet.gd | 8 ++--- 7 files changed, 113 insertions(+), 23 deletions(-) diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 42cd7e6..b125acd 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -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) diff --git a/core/GameState.gd b/core/GameState.gd index f804ecf..15be5b5 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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 ------------------------------------------------------------ diff --git a/core/SaveGame.gd b/core/SaveGame.gd index c1abc5d..7003d37 100644 --- a/core/SaveGame.gd +++ b/core/SaveGame.gd @@ -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. diff --git a/tests/test_fleet_growth.gd b/tests/test_fleet_growth.gd index 0aa3fcc..d194576 100644 --- a/tests/test_fleet_growth.gd +++ b/tests/test_fleet_growth.gd @@ -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 diff --git a/ui/DevMenuButton.gd b/ui/DevMenuButton.gd index 22dc7f8..0d9e28b 100644 --- a/ui/DevMenuButton.gd +++ b/ui/DevMenuButton.gd @@ -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: diff --git a/ui/FormationEditor.gd b/ui/FormationEditor.gd index 79a05b8..ea01f72 100644 --- a/ui/FormationEditor.gd +++ b/ui/FormationEditor.gd @@ -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: diff --git a/vessels/Fleet.gd b/vessels/Fleet.gd index 25422b2..19c5d28 100644 --- a/vessels/Fleet.gd +++ b/vessels/Fleet.gd @@ -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()