diff --git a/BuildController.gd b/BuildController.gd index f1cf834..4747cf1 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -39,6 +39,8 @@ extends Node2D @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") +@onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton") +@onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel") @onready var skill_menu: Control = get_node("../UI/SkillMenu") @onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") @@ -104,6 +106,7 @@ func _ready() -> void: action_button.pressed.connect(_on_action_pressed) craft_interceptor_button.pressed.connect(_on_craft_interceptor) + load_ore_button.pressed.connect(_on_load_ore) craft_close_button.pressed.connect(func(): craft_menu.visible = false) formation_close_button.pressed.connect(func(): formation_menu.visible = false) @@ -211,6 +214,7 @@ func _on_action_pressed() -> void: return if current_building.building_type == GameState.BUILDING_MOTHERSHIP: skill_menu.visible = true + _refresh_load_ore() _refresh_skill_menu() elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: craft_menu.visible = true @@ -454,6 +458,17 @@ func _on_craft_interceptor() -> void: _refresh_craft_status() +func _refresh_load_ore() -> void: + load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo] + load_ore_button.enabled = GameState.rocks > 0 + load_ore_button.queue_redraw() + + +func _on_load_ore() -> void: + GameState.withdraw_to_fleet() + _refresh_load_ore() + + func _on_formation_changed() -> void: fleet.apply_formation() diff --git a/BuildingField.gd b/BuildingField.gd index 076e5be..4db4fac 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -11,6 +11,9 @@ extends Node2D var building_scene: PackedScene = preload("res://Building.tscn") var spawned: Array = [] +# Whether the fleet was already standing on the mothership last frame - see +# the edge-triggered delivery in _process(). +var _was_on_mothership: bool = false func _ready() -> void: @@ -23,11 +26,16 @@ func _process(_delta: float) -> void: # fleet standing on one?" - and it uses the fleet CENTROID, the same # stable single-number test Planet and Building already use, rather than # per-ship collision that flickers as ships jostle in formation. - if GameState.fleet_cargo <= 0: - return + # EDGE-triggered on arrival, not continuous. Delivering every frame the + # fleet is parked on the mothership would make withdrawing impossible: + # ore loaded into the hold at the mothership (#17) would be banked again + # the very next frame, and the button would look broken. var under: Node2D = get_building_fleet_is_on() - if under != null and under.building_type == GameState.BUILDING_MOTHERSHIP: + var on_mothership: bool = under != null \ + and under.building_type == GameState.BUILDING_MOTHERSHIP + if on_mothership and not _was_on_mothership and GameState.fleet_cargo > 0: GameState.deliver_cargo() + _was_on_mothership = on_mothership func rebuild_all() -> void: diff --git a/GameState.gd b/GameState.gd index f214914..a7a793f 100644 --- a/GameState.gd +++ b/GameState.gd @@ -45,6 +45,35 @@ func collect_cargo(amount: int = 1) -> void: fleet_cargo += amount +# One press of "load ore" at the mothership. A batch rather than the whole +# bank: the hold is the part that can be lost, and emptying the bank in one +# tap would also strand research, which still spends from it. +const CARGO_LOAD_BATCH: int = 25 + + +func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int: + # Banked ore back into the hold, so the fleet can carry it to a building + # that is waiting on it (#17). + var taken: int = mini(amount, rocks) + if taken <= 0: + return 0 + rocks -= taken + fleet_cargo += taken + return taken + + +func unload_into_building(id: int) -> int: + # Everything the fleet is carrying, into this building's stock. Returns + # how much moved so a caller can tell the player something happened. + if fleet_cargo <= 0: + return 0 + var moved: int = fleet_cargo + if deposit_to_building(id, moved) <= 0: + return 0 + fleet_cargo = 0 + return moved + + func deliver_cargo() -> int: # Everything the fleet is carrying, banked at once. Returns how much moved # so a caller can react to a delivery actually having happened rather than diff --git a/Main.tscn b/Main.tscn index c830b41..dca95e7 100644 --- a/Main.tscn +++ b/Main.tscn @@ -761,6 +761,28 @@ script = ExtResource("10") icon = 5 glow_color = Color(0.45, 0.9, 0.55, 1) +[node name="LoadOreButton" type="Control" parent="UI/SkillMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -256.0 +offset_top = 1090.0 +offset_right = -108.0 +offset_bottom = 1238.0 +script = ExtResource("10") +icon = 4 +glow_color = Color(1, 0.78, 0.25, 1) + +[node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -300.0 +offset_top = 1246.0 +offset_right = -64.0 +offset_bottom = 1296.0 +horizontal_alignment = 1 +autowrap_mode = 2 +text = "Load ore" + [node name="CloseButton" type="Control" parent="UI/SkillMenu"] anchor_left = 1.0 anchor_right = 1.0 diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd index 6ab29c3..d116fcb 100644 --- a/tests/test_building_stock_queue.gd +++ b/tests/test_building_stock_queue.gd @@ -63,6 +63,7 @@ func _process(delta: float) -> bool: 0: scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) + _clear_hazards() fleet.spawn_ships(2, Vector2.ZERO) field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) crafter_id = int(game_state.buildings[-1].id) @@ -231,3 +232,16 @@ func _process(delta: float) -> bool: quit(0) return true return false + + +func _clear_hazards() -> void: + # set_process(false) on AsteroidField only stops NEW chunks spawning - + # rocks already in the world keep drifting and can destroy a parked ship, + # which silently changes the fleet size this test asserts on. This is a + # logistics test, not a survival one. + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + for e in get_nodes_in_group("enemies"): + if is_instance_valid(e): + e.queue_free() diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd new file mode 100644 index 0000000..338fd3e --- /dev/null +++ b/tests/test_manual_supply_route.gd @@ -0,0 +1,205 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_manual_supply_route.gd +# Forgejo #17 (part 4 of #12): the player can supply a building by hand - +# withdraw banked ore into the fleet's hold at the mothership, fly it over, +# and have the building take it. +# +# This is what keeps the game unblockable. #16 lets an order queue waiting +# for ore, and until transporters exist (#19) there is otherwise no way to +# fulfil one - a player with a queued order and no Transport Hub would be +# stuck forever. +# +# Verifies: +# 1. withdrawing moves ore bank -> hold, and conserves the total +# 2. withdrawing is capped by what is actually banked (no overdraft) +# 3. arriving at the mothership banks the hold, but doing so is EDGE +# triggered - ore withdrawn while parked there is not instantly taken +# back, which would make the button look broken +# 4. a building takes the fleet's whole hold when opened +# 5. end to end: a queued order the bank cannot reach is fulfilled using +# only the fleet, with no Transport Hub in existence +# 6. nothing is created or destroyed anywhere along the route + +var game_state: Node +var scene: Node +var fleet: Node +var field: Node +var controller: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var ships_before: int = 0 +var total_before: int = 0 + +const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) +const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0) + + +func _total() -> int: + var sum: int = game_state.rocks + game_state.fleet_cargo + for b in game_state.buildings: + sum += int(b.get("stock", 0)) + return sum + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.rocks = 40 + game_state.fleet_cargo = 0 + game_state.ships_owed = 0 + game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + controller = scene.get_node("BuildController") + + +func _park_fleet_on(at: Vector2) -> void: + for ship in fleet.ships: + ship.position = at + fleet.move_to(at) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + _clear_hazards() + fleet.spawn_ships(2, MOTHERSHIP_AT) + field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + total_before = _total() + _park_fleet_on(CRAFTER_AT) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + # --- 5. an order the crafter cannot pay for ----------------- + ships_before = fleet.ships.size() + game_state.queue_order(crafter_id) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if game_state.pending_orders(crafter_id) != 1: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order did not queue)") + quit(1) + return true + + # --- 1 + 2. withdraw at the mothership ---------------------- + _park_fleet_on(MOTHERSHIP_AT) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.3: + return false + var bank_before: int = game_state.rocks + var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST) + if took != GameState.SHIP_COST or game_state.fleet_cargo != GameState.SHIP_COST: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo]) + quit(1) + return true + if game_state.rocks != bank_before - GameState.SHIP_COST: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (bank did not pay for the withdrawal)") + quit(1) + return true + print("withdrew %d at the mothership -> bank=%d hold=%d" % [ + took, game_state.rocks, game_state.fleet_cargo + ]) + elapsed = 0.0 + stage = 4 + return false + 4: + # --- 3. still parked on the mothership ---------------------- + if elapsed < 0.5: + return false + if game_state.fleet_cargo != GameState.SHIP_COST: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was instantly banked again - delivery is not edge triggered)") + quit(1) + return true + print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo) + + # --- 4. carry it to the crafter and open it ----------------- + _park_fleet_on(CRAFTER_AT) + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.6: + return false + var moved: int = game_state.unload_into_building(crafter_id) + if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo]) + quit(1) + return true + print("unloaded %d into the crafter" % moved) + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.5: + return false + # --- 5. the queued order completed off the fleet's own ore --- + if game_state.pending_orders(crafter_id) != 0: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order still queued after a hand delivery)") + quit(1) + return true + if fleet.ships.size() != ships_before + 1: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel: %d -> %d)" % [ships_before, fleet.ships.size()]) + quit(1) + return true + print("queued order fulfilled by hand, no transport hub: ships %d->%d" % [ + ships_before, fleet.ships.size() + ]) + + # --- 6. conservation ---------------------------------------- + var expected: int = total_before - GameState.SHIP_COST + if _total() != expected: + print("bank=%d hold=%d stocks total=%d, expected %d" % [ + game_state.rocks, game_state.fleet_cargo, _total(), expected + ]) + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore created or destroyed along the route)") + quit(1) + return true + print("ore conserved: %d -> %d, exactly the %d the vessel cost" % [ + total_before, _total(), GameState.SHIP_COST + ]) + + # --- 2. no overdraft ---------------------------------------- + game_state.rocks = 3 + var over: int = game_state.withdraw_to_fleet(999) + if over != 3 or game_state.rocks != 0: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks]) + quit(1) + return true + print("withdrawing 999 from a bank of 3 took %d" % over) + print("TEST_MANUAL_SUPPLY_ROUTE: PASS") + quit(0) + return true + return false + + +func _clear_hazards() -> void: + # set_process(false) on AsteroidField only stops NEW chunks spawning - + # rocks already in the world keep drifting and can destroy a parked ship, + # which silently changes the fleet size this test asserts on. This is a + # logistics test, not a survival one. + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + for e in get_nodes_in_group("enemies"): + if is_instance_valid(e): + e.queue_free()