Limit how much the fleet can carry #56

Closed
opened 2026-08-15 20:30:00 +00:00 by metaheavens · 1 comment
Owner

The fleet hold is currently unbounded - GameState.fleet_inventory is constructed as INVENTORY.new() with no capacity, so a lone interceptor can carry an unlimited amount of everything. Cap it, and make the cap depend on fleet composition: only basic vessels haul cargo, combat specialists do not.

Inventory already supports this natively (total_capacity, space_for(), and clamping inside deposit/set_amount/transfer_to), so most of this ticket is wiring rather than new mechanics.

Scope

  1. VesselDefinition.base_stats gains a cargo_capacity entry per vessel:
    • interceptor: a modest amount (suggest 20)
    • shield vessel, gas bomber: 0 (combat specialists carry nothing)
    • transporter: unchanged - it is logistics-only and never in the fleet (#44 era), so it must not contribute to the fleet hold.
  2. GameState.fleet_cargo_capacity() -> int: sum vessel_stat(type, "cargo_capacity") over the current fleet composition. A vessel contributes only if its definition declares the cargo capability, so the rule is data-driven rather than a list of vessel ids.
  3. Apply it by setting fleet_inventory.total_capacity whenever composition changes: Fleet.spawn_ships(), Fleet.add_ship(), Fleet.store_ship(), Fleet._on_ship_destroyed(), and the surface/space scene-swap restore in PlanetSurfaceController / PlanetAtmosphereController. Put the recompute in one GameState method that all of those call.
  4. GameState.collect_cargo() (rock pickup) must respect the cap: if the hold is full, the rock stays on the surface rather than being consumed for nothing. Check Rock.gd's collection path - the pickup should only free itself when something was actually accepted.
  5. The HUD must show the limit (e.g. 12/40 on the fleet column) so a full hold is legible rather than mysterious. If #49 has landed, add it there; otherwise add it to the existing fleet counters.

Out of scope

The carrier vessel (#57), which raises the ceiling.

Acceptance criteria

  • A fleet of one interceptor cannot carry more than its capacity; further pickups are refused and the rock remains.
  • Transfers into a full fleet hold clamp to the remaining space and report the amount actually moved.
  • Losing a cargo-capable ship lowers the ceiling immediately; an over-capacity hold is not silently deleted (decide and document: clamp on next deposit rather than dropping existing cargo).
  • A fleet of shield vessels and gas bombers has near-zero capacity.
  • Capacity survives the space <-> surface scene swap.

Tests

tests/test_fleet_cargo_limit.gd (bash tests/run_tests.sh cargo), plus run_tests.sh transfer and run_tests.sh fleet for regressions.

Repo notes (read before starting)

  • Godot 4.7.1, portrait 720x1280, touch UI. Project dir: /storage/emulated/0/GodotProjects/SpaceshipTest.
  • After editing any project file, run touch on it. Android FUSE does not bump mtime under ~/storage/shared, and Godot's import cache will silently keep serving the old version, making a correct fix look like it did nothing.
  • Headless tests: bash tests/run_tests.sh <substring>; full suite with no argument. Tests are SceneTree scripts printing TEST_NAME: PASS / TEST_NAME: FAIL (reason) and calling quit(0|1) - copy the shape of tests/test_capture_points.gd.
  • Real screenshots: bash tests/screenshot.sh <res://Scene.tscn> <out.png> <frames> 720x1280. Use few frames (~15); the software rasterizer is slow. --headless renders nothing at all, so it cannot validate visuals.
  • Drawing goes through NeonDraw / NeonPiece; do not introduce a different visual idiom.
  • The repo has no type switches (tickets #31-#35): behaviour comes from *Definition resources plus capability component scenes. Do not add if type == "..." branches.
  • There is no persistence layer (no FileAccess, no user://). GameState is an in-memory autoload. Do not invent a save system.
  • Commit this ticket separately and run the affected test groups plus the full suite before pushing.

Traps that have already cost hours on this codebase

  1. Never put a top-level const X = preload(...) in a test file that is itself the -s entry point. It evaluates during that file's own early compilation, which can run before autoloads are registered; any script it pulls in that references GameState inside a function body then fails to compile with Identifier not found: GameState. Use var X: Script plus load() inside _initialize() instead. See the header comment in tests/test_capture_points.gd.
  2. Assigning a plain [] to a typed Array[Resource] export from another script fails at runtime - the compiler only sees the target as Resource and cannot confirm the element type. Leave the declared default alone, or build the typed array inside the owning script.
  3. Fleet.spawn_ships(0, ...) does not empty the fleet - it falls back to one default interceptor by design. Clear fleet.ships explicitly if a test or preview needs genuinely zero ships.
  4. Setting ship.position without also setting ship.move_target lets Ship._physics_process steer it straight back toward the stale target within a few frames.
  5. Physics ticks can run ahead of a SceneTree test's own _process() clock under device load, even with --fixed-fps 60. Gate waits on the system's own state (a component's timer or flag), not on elapsed >= duration.
  6. A node that queue_free()s itself (projectiles, shells) must not be re-read inside a per-frame wait condition - cache the scalar you need before the wait.
  7. Preview scenes for screenshots are added to the tree from inside _screenshot_tool.gd's _initialize(), so their _ready() races the game scene's own _ready(). Do setup on the first _process() tick, and get_tree().paused = true once everything is placed if the scene must hold still.
The fleet hold is currently unbounded - `GameState.fleet_inventory` is constructed as `INVENTORY.new()` with no capacity, so a lone interceptor can carry an unlimited amount of everything. Cap it, and make the cap depend on fleet composition: only basic vessels haul cargo, combat specialists do not. `Inventory` already supports this natively (`total_capacity`, `space_for()`, and clamping inside `deposit`/`set_amount`/`transfer_to`), so most of this ticket is wiring rather than new mechanics. ### Scope 1. `VesselDefinition.base_stats` gains a `cargo_capacity` entry per vessel: - interceptor: a modest amount (suggest 20) - shield vessel, gas bomber: 0 (combat specialists carry nothing) - transporter: unchanged - it is logistics-only and never in the fleet (#44 era), so it must not contribute to the fleet hold. 2. `GameState.fleet_cargo_capacity() -> int`: sum `vessel_stat(type, "cargo_capacity")` over the **current** fleet composition. A vessel contributes only if its definition declares the `cargo` capability, so the rule is data-driven rather than a list of vessel ids. 3. Apply it by setting `fleet_inventory.total_capacity` whenever composition changes: `Fleet.spawn_ships()`, `Fleet.add_ship()`, `Fleet.store_ship()`, `Fleet._on_ship_destroyed()`, and the surface/space scene-swap restore in `PlanetSurfaceController` / `PlanetAtmosphereController`. Put the recompute in one `GameState` method that all of those call. 4. `GameState.collect_cargo()` (rock pickup) must respect the cap: if the hold is full, **the rock stays on the surface** rather than being consumed for nothing. Check `Rock.gd`'s collection path - the pickup should only free itself when something was actually accepted. 5. The HUD must show the limit (e.g. `12/40` on the fleet column) so a full hold is legible rather than mysterious. If #49 has landed, add it there; otherwise add it to the existing fleet counters. ### Out of scope The carrier vessel (#57), which raises the ceiling. ### Acceptance criteria - A fleet of one interceptor cannot carry more than its capacity; further pickups are refused and the rock remains. - Transfers into a full fleet hold clamp to the remaining space and report the amount actually moved. - Losing a cargo-capable ship lowers the ceiling immediately; an over-capacity hold is not silently deleted (decide and document: clamp on next deposit rather than dropping existing cargo). - A fleet of shield vessels and gas bombers has near-zero capacity. - Capacity survives the space <-> surface scene swap. ### Tests `tests/test_fleet_cargo_limit.gd` (`bash tests/run_tests.sh cargo`), plus `run_tests.sh transfer` and `run_tests.sh fleet` for regressions. ### Repo notes (read before starting) - Godot 4.7.1, portrait 720x1280, touch UI. Project dir: `/storage/emulated/0/GodotProjects/SpaceshipTest`. - **After editing any project file, run `touch` on it.** Android FUSE does not bump mtime under `~/storage/shared`, and Godot's import cache will silently keep serving the old version, making a correct fix look like it did nothing. - Headless tests: `bash tests/run_tests.sh <substring>`; full suite with no argument. Tests are `SceneTree` scripts printing `TEST_NAME: PASS` / `TEST_NAME: FAIL (reason)` and calling `quit(0|1)` - copy the shape of `tests/test_capture_points.gd`. - Real screenshots: `bash tests/screenshot.sh <res://Scene.tscn> <out.png> <frames> 720x1280`. Use few frames (~15); the software rasterizer is slow. `--headless` renders nothing at all, so it cannot validate visuals. - Drawing goes through `NeonDraw` / `NeonPiece`; do not introduce a different visual idiom. - The repo has **no type switches** (tickets #31-#35): behaviour comes from `*Definition` resources plus capability component scenes. Do not add `if type == "..."` branches. - There is **no persistence layer** (no `FileAccess`, no `user://`). `GameState` is an in-memory autoload. Do not invent a save system. - Commit this ticket separately and run the affected test groups plus the full suite before pushing. ### Traps that have already cost hours on this codebase 1. **Never put a top-level `const X = preload(...)` in a test file that is itself the `-s` entry point.** It evaluates during that file's own early compilation, which can run before autoloads are registered; any script it pulls in that references `GameState` inside a function body then fails to compile with `Identifier not found: GameState`. Use `var X: Script` plus `load()` inside `_initialize()` instead. See the header comment in `tests/test_capture_points.gd`. 2. **Assigning a plain `[]` to a typed `Array[Resource]` export from another script fails at runtime** - the compiler only sees the target as `Resource` and cannot confirm the element type. Leave the declared default alone, or build the typed array inside the owning script. 3. **`Fleet.spawn_ships(0, ...)` does not empty the fleet** - it falls back to one default interceptor by design. Clear `fleet.ships` explicitly if a test or preview needs genuinely zero ships. 4. **Setting `ship.position` without also setting `ship.move_target`** lets `Ship._physics_process` steer it straight back toward the stale target within a few frames. 5. **Physics ticks can run ahead of a `SceneTree` test's own `_process()` clock** under device load, even with `--fixed-fps 60`. Gate waits on the system's own state (a component's timer or flag), not on `elapsed >= duration`. 6. **A node that `queue_free()`s itself** (projectiles, shells) must not be re-read inside a per-frame wait condition - cache the scalar you need before the wait. 7. **Preview scenes for screenshots** are added to the tree from inside `_screenshot_tool.gd`'s `_initialize()`, so their `_ready()` races the game scene's own `_ready()`. Do setup on the first `_process()` tick, and `get_tree().paused = true` once everything is placed if the scene must hold still.
Author
Owner

Implemented in f040327.

The fleet hold is now finite and driven by fleet composition, instead of unbounded.

  • cargo_capacity + a "cargo" capability added to InterceptorVesselDefinition.tres (20, plus VesselCargoComponent.tscn in its component_scenes so the capability is backed by a real composed component, per the no-type-switch rule - test_vessel_composition.gd enforces every declared capability actually resolves to a child component, and caught me skipping that on the first pass). Shield vessel and gas bomber declare no "cargo" capability, so they contribute nothing - near-zero capacity for a combat-only fleet, verified directly. The transporter already had cargo_capacity/"cargo" for its own per-trip load limit, but it's never fleet-eligible, so it was already structurally excluded from ever affecting the fleet hold.
  • GameState.fleet_cargo_capacity(vessel_types) sums cargo_capacity over ships with the "cargo" capability (data-driven, no vessel-id list); GameState.sync_fleet_cargo_capacity(vessel_types) applies it to fleet_inventory.total_capacity. Fleet.gd calls the sync in the one place composition changes: spawn_ships(), add_ship(), store_ship(), _on_ship_destroyed() - which transitively covers the space<->surface scene-swap restore too, since both PlanetSurfaceController and PlanetAtmosphereController rebuild the fleet through spawn_ships().
  • Rock.gd and GasVentField.gd (the two "fly over it" pickup paths) now check what collect_cargo()/collect_gas_cargo() actually accepted (both now return the deposited amount) before consuming their source. A rock over a full hold stays on the surface; a gas vent keeps its undelivered fraction rather than discarding it into a full hold.
  • Losing a cargo-capable ship drops the ceiling immediately but does not delete cargo already held over the new cap - the excess just blocks further deposits until it's unloaded. Documented and tested explicitly.
  • HUD: the existing "FLEET HOLD" caption (from #49's ResourceReadout) now shows the live total, e.g. FLEET HOLD 27/40, reusing the sibling caption Label rather than adding a new node.

Tests: tests/test_fleet_cargo_limit.gd (data-driven capacity by composition, a full hold refusing a pickup without consuming the rock, losing a ship dropping the ceiling without deleting cargo, a clamped transfer reporting the actual amount moved, capacity surviving the space->surface swap). run_tests.sh cargo (3/3), run_tests.sh fleet (3/3), run_tests.sh transfer (2/2, after fixing test_resource_transfer.gd - it was pushing more ore through a 2-ship fleet than 40 capacity allows; bumped it to spawn the largest fleet every unlocked doctrine permits, since that test is about the transfer UI, not the cap). Full suite: 83/83.

Screenshot taken and visually confirmed: 2 interceptors + 1 shield vessel (40 capacity total), hold at 27, HUD caption reads FLEET HOLD 27/40 clearly, no overlap with the resource rows below it.

Implemented in f040327. The fleet hold is now finite and driven by fleet composition, instead of unbounded. - `cargo_capacity` + a `"cargo"` capability added to `InterceptorVesselDefinition.tres` (20, plus `VesselCargoComponent.tscn` in its `component_scenes` so the capability is backed by a real composed component, per the no-type-switch rule - `test_vessel_composition.gd` enforces every declared capability actually resolves to a child component, and caught me skipping that on the first pass). Shield vessel and gas bomber declare no `"cargo"` capability, so they contribute nothing - near-zero capacity for a combat-only fleet, verified directly. The transporter already had `cargo_capacity`/`"cargo"` for its own per-trip load limit, but it's never fleet-eligible, so it was already structurally excluded from ever affecting the fleet hold. - `GameState.fleet_cargo_capacity(vessel_types)` sums `cargo_capacity` over ships with the `"cargo"` capability (data-driven, no vessel-id list); `GameState.sync_fleet_cargo_capacity(vessel_types)` applies it to `fleet_inventory.total_capacity`. `Fleet.gd` calls the sync in the one place composition changes: `spawn_ships()`, `add_ship()`, `store_ship()`, `_on_ship_destroyed()` - which transitively covers the space<->surface scene-swap restore too, since both `PlanetSurfaceController` and `PlanetAtmosphereController` rebuild the fleet through `spawn_ships()`. - `Rock.gd` and `GasVentField.gd` (the two "fly over it" pickup paths) now check what `collect_cargo()`/`collect_gas_cargo()` actually accepted (both now return the deposited amount) before consuming their source. A rock over a full hold stays on the surface; a gas vent keeps its undelivered fraction rather than discarding it into a full hold. - Losing a cargo-capable ship drops the ceiling immediately but does **not** delete cargo already held over the new cap - the excess just blocks further deposits until it's unloaded. Documented and tested explicitly. - HUD: the existing "FLEET HOLD" caption (from #49's `ResourceReadout`) now shows the live total, e.g. `FLEET HOLD 27/40`, reusing the sibling caption Label rather than adding a new node. Tests: `tests/test_fleet_cargo_limit.gd` (data-driven capacity by composition, a full hold refusing a pickup without consuming the rock, losing a ship dropping the ceiling without deleting cargo, a clamped transfer reporting the actual amount moved, capacity surviving the space->surface swap). `run_tests.sh cargo` (3/3), `run_tests.sh fleet` (3/3), `run_tests.sh transfer` (2/2, after fixing `test_resource_transfer.gd` - it was pushing more ore through a 2-ship fleet than 40 capacity allows; bumped it to spawn the largest fleet every unlocked doctrine permits, since that test is about the transfer UI, not the cap). Full suite: 83/83. Screenshot taken and visually confirmed: 2 interceptors + 1 shield vessel (40 capacity total), hold at 27, HUD caption reads `FLEET HOLD 27/40` clearly, no overlap with the resource rows below it.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
metaheavens/spaceship#56
No description provided.