diff --git a/BuildController.gd b/BuildController.gd index 1872226..f1cf834 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -83,6 +83,8 @@ const BUILDING_INFO: Dictionary = { # Which structure the popup is currently offering to build, or "" when it is # only explaining something that cannot be built right now. var pending_build_type: String = "" +# Which crafter the craft menu is showing (see _on_action_pressed). +var craft_building_id: int = 0 var current_building: Node2D = null var pending_building: Node2D = null @@ -153,6 +155,11 @@ func _process(delta: float) -> void: build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) + if craft_menu.visible: + # Live, so a delivery arriving while the menu is open visibly works + # the queue down instead of looking stuck. + _refresh_craft_status() + if world_map.visible or build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible: current_building = null @@ -420,19 +427,30 @@ func _on_unlock_pressed() -> void: func _refresh_craft_status() -> void: - craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [ - GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.max_fleet_size() + # "Here" is the ore delivered to THIS crafter, not the bank: since #16 a + # building builds out of its own stock, and showing the bank would say + # you can afford something the crafter cannot actually make. + var queued: int = GameState.pending_orders(craft_building_id) + var stock: int = GameState.building_stock(craft_building_id) + var queue_text: String = "" if queued == 0 else " Queued %d" % queued + craft_status.text = "Interceptor %d ore Here %d%s Fleet %d/%d" % [ + GameState.SHIP_COST, stock, queue_text, + fleet.ships.size(), GameState.max_fleet_size() ] func _on_craft_interceptor() -> void: - if fleet.ships.size() >= GameState.max_fleet_size(): + if craft_building_id == 0: + return + # Still refused when there is nowhere to put the vessel - queueing an + # order that can never be delivered would just bank ships_owed forever. + if fleet.ships.size() + GameState.ships_owed + GameState.pending_orders(craft_building_id) \ + >= GameState.max_fleet_size(): craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size() return - if not GameState.spend(GameState.SHIP_COST): - craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks] - return - fleet.add_ship() + # No affordability check: an order the crafter cannot pay for yet is + # QUEUED, and completes by itself once ore is delivered here (#16). + GameState.queue_order(craft_building_id) _refresh_craft_status() diff --git a/Building.gd b/Building.gd index 5164b70..6dfc0b4 100644 --- a/Building.gd +++ b/Building.gd @@ -19,6 +19,10 @@ extends Node2D # never changes were a real framerate cost earlier on. @export var building_type: String = "mothership" +# Which GameState.buildings record this node is showing. Set by +# BuildingField when it spawns, so a node can be traced back to the stock +# and production queue that survive the scene swap (#16). +@export var building_id: int = 0 @export var interact_radius: float = 150.0 const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) diff --git a/BuildingField.gd b/BuildingField.gd index ae0ea7d..076e5be 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -56,6 +56,7 @@ func _spawn(record: Dictionary) -> void: var b: Node2D = building_scene.instantiate() b.position = record.position b.building_type = record.type + b.building_id = int(record.get("id", 0)) # Deferred for the same reason as the other field spawners: this can run # from inside a parent's _ready() chain, where a direct add_child() is # rejected while the tree is still setting up children. diff --git a/Fleet.gd b/Fleet.gd index 8b4475b..6abccd5 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -113,6 +113,9 @@ func move_in_direction(direction: Vector2) -> void: func _process(delta: float) -> void: + # Before the steering early-out: a ship the crafter has already built and + # paid for is owed whether or not the fleet happens to be flying (#16). + _claim_owed_ships() if steer_direction == Vector2.ZERO: return # The anchor - the fleet's formation centre - travels at cruise_speed. @@ -122,6 +125,17 @@ func _process(delta: float) -> void: _assign_slot_targets() +func _claim_owed_ships() -> void: + # Production completes in the GameState autoload, which may well happen + # while the player is on a planet's surface with no Fleet node in + # existence to hand a vessel to. Those ships wait as `ships_owed` until a + # fleet with room can take them. + while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size(): + if not add_ship(): + return + GameState.ships_owed -= 1 + + func _assign_slot_targets() -> void: for i in range(ships.size()): ships[i].set_move_target(fleet_target + formation_offsets[i]) diff --git a/GameState.gd b/GameState.gd index b4f6d23..f214914 100644 --- a/GameState.gd +++ b/GameState.gd @@ -211,8 +211,96 @@ func reset_progression() -> void: unlocked.clear() -func add_building(type: String, at_position: Vector2) -> void: - buildings.append({"type": type, "position": at_position}) +# --- building stock and production queue (#16) ----------------------------- + +# Since #12, a building cannot just deduct from the bank at the moment of +# purchase: what it produces has to be paid for out of resources physically +# delivered to it. So each building carries its own `stock`, and an order it +# cannot afford yet is QUEUED rather than refused, and completes by itself +# once the stock turns up. +# +# `id` exists because a building has to be referable across the space <-> +# planet-surface scene swap, when every Building NODE has been destroyed and +# rebuilt. Position would nearly work and then fail on two buildings placed +# on the same spot. +var next_building_id: int = 1 + +# Ships that have been paid for and built but not yet handed to the fleet. +# Production is simulated here, in the autoload, the same way extractor +# haulers are - so an order can complete while the player is down on a +# planet's surface, where there is no Fleet node to hand a ship to. The fleet +# claims them when it next can. +var ships_owed: int = 0 + +signal order_completed(building_id: int, type: String) + + +func add_building(type: String, at_position: Vector2) -> int: + var id: int = next_building_id + next_building_id += 1 + buildings.append({ + "id": id, "type": type, "position": at_position, "stock": 0, "queued": 0, + }) + return id + + +func find_building(id: int) -> Dictionary: + for b in buildings: + if b.get("id", 0) == id: + return b + return {} + + +func production_cost(type: String) -> int: + # What one order out of this building costs it. 0 means it produces + # nothing and cannot be ordered from. + if type == BUILDING_SHIP_CRAFTER: + return SHIP_COST + return 0 + + +func building_stock(id: int) -> int: + var b: Dictionary = find_building(id) + return 0 if b.is_empty() else int(b.get("stock", 0)) + + +func pending_orders(id: int) -> int: + var b: Dictionary = find_building(id) + return 0 if b.is_empty() else int(b.get("queued", 0)) + + +func deposit_to_building(id: int, amount: int) -> int: + if amount <= 0: + return 0 + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + b.stock = int(b.get("stock", 0)) + amount + return amount + + +func queue_order(id: int) -> bool: + var b: Dictionary = find_building(id) + if b.is_empty() or production_cost(b.type) <= 0: + return false + b.queued = int(b.get("queued", 0)) + 1 + return true + + +func _advance_production() -> void: + for b in buildings: + var cost: int = production_cost(b.type) + if cost <= 0: + continue + # `while`, not `if`: a delivery big enough to cover several queued + # orders should clear them all rather than dribbling one out per + # frame. + while int(b.get("queued", 0)) > 0 and int(b.get("stock", 0)) >= cost: + b.stock = int(b.stock) - cost + b.queued = int(b.queued) - 1 + if b.type == BUILDING_SHIP_CRAFTER: + ships_owed += 1 + order_completed.emit(int(b.id), String(b.type)) func has_mothership() -> bool: @@ -340,8 +428,10 @@ func get_hauler_position(extractor: Dictionary) -> Variant: func _process(delta: float) -> void: - # Runs in the autoload, so extraction keeps earning while the player is - # on a planet's surface or nowhere near the route. + # Runs in the autoload, so extraction keeps earning - and queued orders + # keep completing - while the player is on a planet's surface or nowhere + # near either. + _advance_production() if extractors.is_empty(): return for e in extractors: diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd new file mode 100644 index 0000000..6ab29c3 --- /dev/null +++ b/tests/test_building_stock_queue.gd @@ -0,0 +1,233 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_building_stock_queue.gd +# Forgejo #16 (part 3 of #12): a building produces out of its OWN stock, and +# an order it cannot pay for yet is queued rather than refused. +# +# Before this, BuildController deducted from the bank at the moment of +# purchase and refused outright with "Not enough rocks", so nothing ever had +# to be delivered anywhere. +# +# Verifies: +# 1. ordering with an empty stock queues instead of failing, and produces +# nothing yet +# 2. it does NOT quietly take the cost out of the bank - the whole point is +# that the building has to be supplied +# 3. delivering enough stock completes the order on its own +# 4. one delivery covering several queued orders clears them all, rather +# than dribbling out one per frame +# 5. a part-delivery is not enough: stock below the cost leaves the order +# queued and the ore untouched +# 6. stock and queue survive the space scene being torn down (the trip to +# a planet's surface) +# 7. production completes with NO fleet in existence, and the fleet picks +# the vessel up when it comes back - orders must not depend on which +# scene the player is standing in + +var game_state: Node +var scene: Node +var fleet: Node +var field: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var ships_at_start: int = 0 +var bank_at_start: int = 0 +var ticks: int = 0 + +const CRAFTER_AT: Vector2 = Vector2(900.0, 0.0) + + +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 + game_state.ships_owed = 0 + # Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet + # starts at exactly 5, so without this every completed order would have + # nowhere to put its vessel and the test would be measuring the cap + # rather than the queue. + 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") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + fleet.spawn_ships(2, Vector2.ZERO) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + ships_at_start = fleet.ships.size() + bank_at_start = game_state.rocks + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + # --- 1 + 2. an unaffordable order queues, and costs nothing -- + if not game_state.queue_order(crafter_id): + print("TEST_BUILDING_STOCK_QUEUE: FAIL (the crafter refused an order outright)") + quit(1) + return true + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if game_state.pending_orders(crafter_id) != 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (order did not stay queued: %d pending)" % game_state.pending_orders(crafter_id)) + quit(1) + return true + if fleet.ships.size() != ships_at_start: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (a vessel appeared with an empty stock)") + quit(1) + return true + if game_state.rocks != bank_at_start: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (queueing took %d out of the bank - the building is supposed to be supplied)" % (bank_at_start - game_state.rocks)) + quit(1) + return true + print("empty stock -> queued=%d ships=%d bank=%d (untouched)" % [ + game_state.pending_orders(crafter_id), fleet.ships.size(), game_state.rocks + ]) + + # --- 5. a part-delivery is not enough ----------------------- + game_state.deposit_to_building(crafter_id, GameState.SHIP_COST - 1) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.3: + return false + if game_state.pending_orders(crafter_id) != 1 or fleet.ships.size() != ships_at_start: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (produced on a part-delivery of %d against a cost of %d)" % [ + GameState.SHIP_COST - 1, GameState.SHIP_COST + ]) + quit(1) + return true + if game_state.building_stock(crafter_id) != GameState.SHIP_COST - 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (part-delivered ore was consumed anyway)") + quit(1) + return true + print("part delivery (%d of %d) -> still queued, ore intact" % [ + game_state.building_stock(crafter_id), GameState.SHIP_COST + ]) + + # --- 3. topping it up completes the order ------------------- + game_state.deposit_to_building(crafter_id, 1) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.4: + return false + if game_state.pending_orders(crafter_id) != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (order not completed once the stock arrived)") + quit(1) + return true + if fleet.ships.size() != ships_at_start + 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (no vessel delivered: %d -> %d)" % [ + ships_at_start, fleet.ships.size() + ]) + quit(1) + return true + if game_state.building_stock(crafter_id) != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (stock not consumed: %d left)" % game_state.building_stock(crafter_id)) + quit(1) + return true + print("supplied -> ships %d->%d, stock spent" % [ships_at_start, fleet.ships.size()]) + + # --- 4. one delivery clears ALL the orders it covers --------- + # Measured in TICKS, not seconds, and with more orders than the + # ticks allowed: production that handled one order per frame + # would still clear them all inside any generous wall-clock wait, + # so a timed check cannot tell "cleared at once" from "dribbled + # out". Three orders, two ticks - only clearing them together + # passes. + ships_at_start = fleet.ships.size() + game_state.queue_order(crafter_id) + game_state.queue_order(crafter_id) + game_state.queue_order(crafter_id) + game_state.deposit_to_building(crafter_id, GameState.SHIP_COST * 3) + ticks = 0 + stage = 5 + return false + 5: + ticks += 1 + if ticks < 2: + return false + if game_state.pending_orders(crafter_id) != 0 or fleet.ships.size() != ships_at_start + 3: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (a delivery covering 3 orders left %d queued after %d ticks, ships %d->%d)" % [ + game_state.pending_orders(crafter_id), ticks, ships_at_start, fleet.ships.size() + ]) + quit(1) + return true + print("one delivery cleared 3 orders in %d ticks -> ships %d->%d" % [ + ticks, ships_at_start, fleet.ships.size() + ]) + + # --- 6 + 7. no scene, no fleet: production still completes -- + # The scene has to be GONE before the order is placed. With a + # fleet still alive and under its cap it claims the vessel the + # same frame production finishes, which would leave ships_owed + # back at 0 and prove nothing about orders surviving without it. + ships_at_start = fleet.ships.size() + scene.queue_free() + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.3: + return false + if is_instance_valid(fleet): + return false + game_state.queue_order(crafter_id) + game_state.deposit_to_building(crafter_id, GameState.SHIP_COST) + elapsed = 0.0 + stage = 61 + return false + 61: + if elapsed < 0.4: + return false + # The queue and stock live in the autoload, so tearing the space + # scene down must not touch them - and the order must complete + # even with no Fleet node anywhere to hand the vessel to. + if game_state.pending_orders(crafter_id) != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)") + quit(1) + return true + if game_state.ships_owed < 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)") + quit(1) + return true + print("no scene at all -> order completed, %d vessel owed" % game_state.ships_owed) + + # Come back to space: the fleet should claim what it is owed. + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 8 + return false + 8: + if elapsed < 0.5: + return false + if game_state.ships_owed != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.ships_owed) + quit(1) + return true + print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size()) + print("TEST_BUILDING_STOCK_QUEUE: PASS") + quit(0) + return true + return false