diff --git a/Fleet.gd b/Fleet.gd index ba32666..78e4627 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -80,6 +80,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, ship.destroyed.connect(_on_ship_destroyed.bind(ship)) fleet_target = center + GameState.sync_fleet_cargo_capacity(vessel_types()) func vessel_types() -> PackedStringArray: @@ -138,6 +139,7 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: if ship.has_signal("destroyed"): ship.destroyed.connect(_on_ship_destroyed.bind(ship)) ship.set_move_target(fleet_target + offset) + GameState.sync_fleet_cargo_capacity(vessel_types()) return true @@ -155,6 +157,7 @@ func store_ship(index: int) -> String: formation_offsets.remove_at(index) ship.queue_free() apply_formation() + GameState.sync_fleet_cargo_capacity(vessel_types()) return vessel_type @@ -273,6 +276,7 @@ func _on_ship_destroyed(ship: Node) -> void: if idx != -1: ships.remove_at(idx) formation_offsets.remove_at(idx) + GameState.sync_fleet_cargo_capacity(vessel_types()) func get_centroid() -> Vector2: diff --git a/GameState.gd b/GameState.gd index 910aa6b..c3bae6c 100644 --- a/GameState.gd +++ b/GameState.gd @@ -57,11 +57,14 @@ var fleet_gas_cargo: int: get: return fleet_inventory.amount(RESOURCE_GAS) set(value): fleet_inventory.set_amount(RESOURCE_GAS, value) -func collect_cargo(amount: int = 1) -> void: - fleet_inventory.deposit(RESOURCE_ORE, amount) +# Both return the amount actually accepted (#56: the fleet hold now has a +# finite total_capacity), so a collector can tell a full hold apart from a +# successful pickup instead of assuming everything offered was taken. +func collect_cargo(amount: int = 1) -> int: + return fleet_inventory.deposit(RESOURCE_ORE, amount) -func collect_gas_cargo(amount: int = 1) -> void: - fleet_inventory.deposit(RESOURCE_GAS, amount) +func collect_gas_cargo(amount: int = 1) -> int: + return fleet_inventory.deposit(RESOURCE_GAS, amount) func resource_types() -> PackedStringArray: @@ -90,6 +93,16 @@ func fleet_resource(resource_type: String) -> int: return fleet_inventory.amount(resource_type) +# #56: total across every resource type in the hold, and the combined cap +# that limits it - a HUD readout wants both, not any one resource's amount. +func fleet_cargo_used() -> int: + return fleet_inventory.total() + + +func fleet_cargo_total_capacity() -> int: + return fleet_inventory.total_capacity + + func _inventory_for_record(record: Dictionary) -> RefCounted: var inventory: RefCounted = record.get("inventory") as RefCounted if inventory == null: @@ -633,6 +646,25 @@ func shield_recharge_time() -> float: var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield") return vessel_stat(definition.definition_id, "shield_recharge") +# #56: sums cargo_capacity over ships that actually declare the "cargo" +# capability, so combat specialists (no capability, whatever their stat +# dict says) contribute nothing - data-driven rather than a vessel-id list. +# Takes the composition as an argument rather than reading it off a live +# Fleet node: GameState has no fixed reference to "the" Fleet (space and a +# planet surface each own a separate one), so the caller passes its own +# Fleet.vessel_types(). +func fleet_cargo_capacity(vessel_types: PackedStringArray) -> int: + var total: int = 0 + for vessel_type in vessel_types: + if vessel_has_capability(vessel_type, "cargo"): + total += int(vessel_stat(vessel_type, "cargo_capacity")) + return total + + +func sync_fleet_cargo_capacity(vessel_types: PackedStringArray) -> void: + fleet_inventory.total_capacity = fleet_cargo_capacity(vessel_types) + + func transporter_load_limit() -> int: var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport") return int(vessel_stat(definition.definition_id, "cargo_capacity")) diff --git a/GasVentField.gd b/GasVentField.gd index 8ad7e51..8669495 100644 --- a/GasVentField.gd +++ b/GasVentField.gd @@ -16,8 +16,11 @@ func _process(delta: float) -> void: if fleet.get_centroid().distance_to(vent) < 90.0: progress += delta if progress >= 1.0: - GameState.collect_gas_cargo(int(progress)) - progress = fmod(progress, 1.0) + # #56: a full fleet hold rejects the deposit - keep the + # undelivered whole units in progress rather than discarding + # them, so gas is only ever lost by not gathering it. + var whole: int = int(progress) + progress -= float(GameState.collect_gas_cargo(whole)) return progress = 0.0 func _draw() -> void: diff --git a/InterceptorVesselDefinition.tres b/InterceptorVesselDefinition.tres index 1f0552a..f0330e5 100644 --- a/InterceptorVesselDefinition.tres +++ b/InterceptorVesselDefinition.tres @@ -1,10 +1,11 @@ -[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3] +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=8 format=3] [ext_resource type="Script" path="res://VesselDefinition.gd" id="1"] [ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"] [ext_resource type="Resource" path="res://InterceptorDamageUpgrade.tres" id="3"] [ext_resource type="Resource" path="res://InterceptorRateUpgrade.tres" id="4"] [ext_resource type="Resource" path="res://InterceptorRangeUpgrade.tres" id="5"] [ext_resource type="Resource" path="res://InterceptorSpeedUpgrade.tres" id="6"] +[ext_resource type="PackedScene" path="res://VesselCargoComponent.tscn" id="7"] [resource] script = ExtResource("1") definition_id = "interceptor" @@ -15,7 +16,7 @@ build_time = 10.0 icon = 2 glow_color = Color(0.25, 0.85, 1, 1) fleet_eligible = true -capabilities = PackedStringArray("weapon") -component_scenes = Array[PackedScene]([ExtResource("2")]) -base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "delivery_speed": 180.0} +capabilities = PackedStringArray("weapon", "cargo") +component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")]) +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "delivery_speed": 180.0, "cargo_capacity": 20.0} upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5"), ExtResource("6")]) diff --git a/ResourceReadout.gd b/ResourceReadout.gd index cec47fb..c88433f 100644 --- a/ResourceReadout.gd +++ b/ResourceReadout.gd @@ -30,9 +30,19 @@ const GLYPH_GAP: float = 8.0 var _banked_numbers: Dictionary = {} var _fleet_numbers: Dictionary = {} +# #56: the fleet hold now has a finite total capacity. Rather than a new +# node, this reuses the existing "FLEET HOLD" caption Label that both Main +# and PlanetSurface already place as a sibling - get_node_or_null so a +# scene that happens to embed ResourceReadout without that sibling (none +# does today) degrades to just not showing a limit, instead of erroring. +@onready var _fleet_caption: Label = get_node_or_null("../FleetCaption") +var _fleet_caption_base_text: String = "" + func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE + if _fleet_caption != null: + _fleet_caption_base_text = _fleet_caption.text var y: float = 0.0 for resource_type in GameState.resource_types(): var banked_color: Color = GameState.resource_color(resource_type) @@ -81,6 +91,11 @@ func fleet_number(resource_type: String) -> NeonNumber: func _process(_delta: float) -> void: + if _fleet_caption != null: + var capacity: int = GameState.fleet_cargo_total_capacity() + _fleet_caption.text = "%s %d/%d" % [_fleet_caption_base_text, GameState.fleet_cargo_used(), capacity] \ + if capacity >= 0 else _fleet_caption_base_text + for resource_type in GameState.resource_types(): var banked: NeonNumber = _banked_numbers.get(resource_type) if banked != null: diff --git a/ResourceReadout.gd.uid b/ResourceReadout.gd.uid new file mode 100644 index 0000000..71807b4 --- /dev/null +++ b/ResourceReadout.gd.uid @@ -0,0 +1 @@ +uid://ctbtddn7ewmvv diff --git a/Rock.gd b/Rock.gd index a5a85bd..e84a1fe 100644 --- a/Rock.gd +++ b/Rock.gd @@ -82,7 +82,10 @@ func _on_area_entered(area: Area2D) -> void: if area.is_in_group("player_ships"): # Into the fleet's hold, not the bank (#14). It only counts as # spendable once the fleet has flown it back to the mothership. - GameState.collect_cargo(1) + # #56: the hold has a finite total_capacity now - a full fleet must + # leave the rock behind rather than despawning it for nothing. + if GameState.collect_cargo(1) <= 0: + return collected.emit() queue_free() diff --git a/tests/_fleet_cargo_limit_preview.gd b/tests/_fleet_cargo_limit_preview.gd new file mode 100644 index 0000000..03aad5e --- /dev/null +++ b/tests/_fleet_cargo_limit_preview.gd @@ -0,0 +1,19 @@ +extends Node2D + +# Preview, not a test - the fleet-hold cap shown on the FLEET HOLD caption. +# +# bash tests/screenshot.sh res://tests/_fleet_cargo_limit_preview.tscn /tmp/cargo_limit.png 15 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.rocks = 200 + GameState.gas = 30 + GameState.fleet_cargo = 0 + var scene: Node = load("res://Main.tscn").instantiate() + add_child(scene) + var fleet: Node = scene.get_node("Fleet") + fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray(["interceptor", "interceptor", "shield"])) + # Two cargo-capable interceptors (40) + one shield vessel (0) = 40 total. + GameState.fleet_cargo = 27 diff --git a/tests/_fleet_cargo_limit_preview.tscn b/tests/_fleet_cargo_limit_preview.tscn new file mode 100644 index 0000000..8c5eeb7 --- /dev/null +++ b/tests/_fleet_cargo_limit_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_fleet_cargo_limit_preview.gd" id="1"] + +[node name="FleetCargoLimitPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_fleet_cargo_limit.gd b/tests/test_fleet_cargo_limit.gd new file mode 100644 index 0000000..90699c4 --- /dev/null +++ b/tests/test_fleet_cargo_limit.gd @@ -0,0 +1,149 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_fleet_cargo_limit.gd +# Forgejo #56: the fleet hold has a finite, composition-driven capacity +# instead of being unbounded. +# +# Covers: +# A. capacity is data-driven: only vessels with the "cargo" capability +# contribute their cargo_capacity stat - combat specialists (shield, +# gas bomber) contribute zero +# B. a lone interceptor cannot carry more than its capacity - a further +# Rock pickup is refused and the rock is not consumed +# C. losing a cargo-capable ship lowers the ceiling immediately without +# deleting cargo already held (clamps on the next deposit instead) +# D. a transfer into a full hold clamps to the remaining space and +# reports the amount actually moved +# E. capacity survives the space -> surface scene swap + +var game_state: Node +var scene: Node +var fleet: Node +var stage: int = 0 +var elapsed: float = 0.0 +var mothership_id: int = 0 +var rock: Node + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.rocks = 500 + game_state.fleet_cargo = 0 + mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + + +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) + fleet = scene.get_node("Fleet") + + # --- A. capacity is data-driven --------------------------------- + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray(["interceptor"])) + if game_state.fleet_cargo_total_capacity() != 20: + return _fail("one interceptor: capacity %d, expected 20" % game_state.fleet_cargo_total_capacity()) + + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray(["shield", "gas_bomber"])) + if game_state.fleet_cargo_total_capacity() != 0: + return _fail("shield+gas_bomber: capacity %d, expected 0" % game_state.fleet_cargo_total_capacity()) + print("capacity is data-driven: interceptor=20, shield+gas_bomber=0") + + # --- B. a full hold refuses a pickup, the rock stays ------------ + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray(["interceptor"])) + game_state.fleet_cargo = 20 + rock = load("res://Rock.tscn").instantiate() + rock.build_shape(1) + rock.global_position = fleet.ships[0].global_position + scene.add_child(rock) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + if game_state.fleet_cargo != 20: + return _fail("a full hold still absorbed a pickup: hold=%d" % game_state.fleet_cargo) + if not is_instance_valid(rock): + return _fail("the rock was consumed even though the hold was full") + print("full hold (20/20) refused a pickup, the rock remains") + + # --- C. losing a cargo ship drops the ceiling, keeps the cargo -- + # Mirrors Ship.gd's own death sequence (destroyed.emit() then + # queue_free()) rather than just calling the handler, so no + # untracked ghost ship is left sitting in the scene. + var dying_ship: Node = fleet.ships[0] + fleet._on_ship_destroyed(dying_ship) + dying_ship.queue_free() + if game_state.fleet_cargo_total_capacity() != 0: + return _fail("empty fleet: capacity %d, expected 0" % game_state.fleet_cargo_total_capacity()) + if game_state.fleet_cargo != 20: + return _fail("existing cargo was deleted when capacity dropped below it: %d" % game_state.fleet_cargo) + print("losing the ship: capacity=0, existing cargo untouched at %d" % game_state.fleet_cargo) + + if game_state.collect_cargo(5) != 0: + return _fail("an over-capacity hold accepted a further deposit") + print("over-capacity hold clamps further deposits to zero, as documented") + + # --- D. a transfer into a hold with partial room clamps --------- + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray(["interceptor", "interceptor"])) + if game_state.fleet_cargo_total_capacity() != 40: + return _fail("two interceptors: capacity %d, expected 40" % game_state.fleet_cargo_total_capacity()) + var bank_before: int = game_state.rocks + var moved: int = game_state.transfer_resource_to_fleet(mothership_id, GameState.RESOURCE_ORE, 100) + if moved != 20: + return _fail("transfer into 20/40 room reported moved=%d, expected 20" % moved) + if game_state.fleet_cargo != 40: + return _fail("hold after the clamped transfer is %d, expected 40 (full)" % game_state.fleet_cargo) + if game_state.rocks != bank_before - 20: + return _fail("bank dropped by %d, expected exactly the 20 that moved" % (bank_before - game_state.rocks)) + print("transfer into 20/40 room clamped to 20, hold now full, bank debited exactly 20") + + # --- E. capacity survives the space -> surface scene swap ------- + game_state.surviving_ship_count = 2 + game_state.surviving_vessel_types = PackedStringArray(["interceptor", "interceptor"]) + game_state.planet_id = "test_fleet_cargo_limit" + game_state.planet_name = "Cargo Limit Test" + scene.queue_free() + elapsed = 0.0 + stage = 2 + return false + 2: + # A tick for queue_free() to actually leave the tree before adding + # the surface scene, matching the shape of every other scene-swap + # test in this suite. + if elapsed < 0.3: + return false + scene = load("res://PlanetSurface.tscn").instantiate() + root.add_child(scene) + current_scene = scene + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.3: + return false + if game_state.fleet_cargo_total_capacity() != 40: + return _fail("capacity after the surface restore is %d, expected 40" % game_state.fleet_cargo_total_capacity()) + if game_state.fleet_cargo != 40: + return _fail("cargo did not survive the scene swap: %d, expected 40" % game_state.fleet_cargo) + print("capacity (40) and cargo (40) both survived the space -> surface swap") + print("TEST_FLEET_CARGO_LIMIT: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_FLEET_CARGO_LIMIT: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_resource_transfer.gd b/tests/test_resource_transfer.gd index e33bdd9..8854082 100644 --- a/tests/test_resource_transfer.gd +++ b/tests/test_resource_transfer.gd @@ -88,7 +88,12 @@ func _process(delta: float) -> bool: scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) _clear_hazards() - fleet.spawn_ships(2, MOTHERSHIP_AT) + # #56 gave the hold a finite, composition-driven capacity. This + # test is about the transfer UI, not that cap - test_fleet_cargo_ + # limit.gd owns the cap itself - so it spawns the largest fleet + # every unlocked doctrine allows (8 interceptors x 20 = 160), + # comfortably above every hand-picked amount below. + fleet.spawn_ships(game_state.max_fleet_size(), MOTHERSHIP_AT) var field: Node = scene.get_node("BuildingField") field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) mothership_id = int(game_state.buildings[-1].id)