From 575556834660785ed187a06fc2a70905faec4cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Thu, 13 Aug 2026 16:38:41 +0200 Subject: [PATCH] Route extractor ore through transporters --- GameState.gd | 132 +++++++++++++++++------------- Main.tscn | 4 - tests/test_extractor_income.gd | 145 +++++++++++++++------------------ 3 files changed, 141 insertions(+), 140 deletions(-) diff --git a/GameState.gd b/GameState.gd index f19e4b7..0ab114f 100644 --- a/GameState.gd +++ b/GameState.gd @@ -767,26 +767,13 @@ func rotate_formation(radians: float) -> void: formation[i] = formation[i].rotated(radians) -# --- Extractors and their haulers ----------------------------------------- +# --- Extractors ------------------------------------------------------------ -# An extractor sits on a planet and runs a single automated hauler back and -# forth to the mothership, delivering ore on each arrival. -# -# The round trip is SIMULATED HERE, in the autoload, rather than by any -# node: it advances whether the player is in space, on a planet's surface, -# or far enough away that nothing about the route is loaded. `progress` is -# a fraction of one full there-and-back cycle, so a hauler's drawn position -# is derived from the same number that decides when ore is delivered - the -# visual arrival and the payout cannot drift apart, because they are the -# same event. -# -# Entries are {"position": Vector2 (the planet's position in SPACE), -# "planet_name": String, "progress": float}. -const HAULER_SPEED: float = 260.0 -const HAULER_YIELD: int = 4 -# Progress value at which the hauler reaches the mothership: exactly half -# way through the cycle, the outbound leg being the first half. -const HAULER_ARRIVAL: float = 0.5 +# Extractors mine into their OWN local stock. They do not own a magical +# hauler: ore becomes spendable only when a hub-stationed transporter picks +# up a full load and physically delivers it to the mothership. Entries are +# {"position", "planet_name", "stock", "mining_progress"}. +const EXTRACTOR_RATE: float = 2.0 # ore per second var extractors: Array = [] @@ -805,7 +792,8 @@ func add_extractor(planet_name: String, at_position: Vector2) -> void: extractors.append({ "position": at_position, "planet_name": planet_name, - "progress": 0.0, + "stock": 0, + "mining_progress": 0.0, }) # An extractor is built from SPACE, with the fleet parked on the planet - # so the Planet node is already in the tree with its _ready() long since @@ -814,49 +802,23 @@ func add_extractor(planet_name: String, at_position: Vector2) -> void: extractor_added.emit(planet_name) -func get_hauler_route_time(extractor: Dictionary) -> float: - # Full cycle: out to the mothership and back again. - var mothership: Variant = get_mothership_position() - if mothership == null: - return 0.0 - var distance: float = extractor.position.distance_to(mothership) - if distance < 1.0: - return 0.0 - return (distance * 2.0) / HAULER_SPEED - - -func get_hauler_position(extractor: Dictionary) -> Variant: - # Where the hauler is right now, derived purely from `progress`. Returns - # null when there is no route (no mothership built yet). - var mothership: Variant = get_mothership_position() - if mothership == null: - return null - var t: float = extractor.progress - if t <= HAULER_ARRIVAL: - return extractor.position.lerp(mothership, t / HAULER_ARRIVAL) - return mothership.lerp(extractor.position, (t - HAULER_ARRIVAL) / (1.0 - HAULER_ARRIVAL)) +func extractor_stock(index: int) -> int: + if index < 0 or index >= extractors.size(): + return 0 + return int(extractors[index].get("stock", 0)) func _process(delta: float) -> void: - # 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. + # Runs in the autoload, so mining and transport continue while the player + # is on a planet's surface or nowhere near either. _advance_production(delta) - _advance_transporters(delta) - if extractors.is_empty(): - return for e in extractors: - var cycle: float = get_hauler_route_time(e) - if cycle <= 0.0: - continue # nothing to deliver to yet - var before: float = e.progress - e.progress += delta / cycle - # Ore lands the instant the hauler reaches the mothership - the same - # instant get_hauler_position() puts it there. - if before < HAULER_ARRIVAL and e.progress >= HAULER_ARRIVAL: - rocks += HAULER_YIELD - while e.progress >= 1.0: - e.progress -= 1.0 + e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE + var whole: int = int(floor(float(e.mining_progress))) + if whole > 0: + e.stock = int(e.get("stock", 0)) + whole + e.mining_progress = float(e.mining_progress) - whole + _advance_transporters(delta) # --- transporters (#19) --------------------------------------------------- @@ -882,6 +844,8 @@ const TRANSPORTER_IDLE: String = "idle" const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership" const TRANSPORTER_TO_BUILDING: String = "to_building" const TRANSPORTER_RETURNING: String = "returning" +const TRANSPORTER_TO_EXTRACTOR: String = "to_extractor" +const TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: String = "extractor_to_mothership" var transporters: Array = [] @@ -980,10 +944,42 @@ func _transporter_inbound_to(building_id: int) -> bool: return false +func _transporter_inbound_to_extractor(index: int) -> bool: + for t in transporters: + if t.state == TRANSPORTER_TO_EXTRACTOR \ + or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: + if int(t.get("extractor_index", -1)) == index: + return true + return false + + func _dispatch_transporters() -> void: var mothership: Variant = get_mothership_position() if mothership == null: return # nowhere to load from + # A distant extractor is worth visiting only once it has a full hold ready. + # This keeps the transporter system meaningful: mining is local until a + # vessel physically carries a researched-sized load home. + var load: int = transporter_load_limit() + for i in range(extractors.size()): + var extractor: Dictionary = extractors[i] + if int(extractor.get("stock", 0)) < load or _transporter_inbound_to_extractor(i): + continue + var chosen_extractor: Dictionary = {} + var best_extractor_distance: float = INF + for t in transporters: + if t.state != TRANSPORTER_IDLE: + continue + var d: float = transporter_position(t).distance_squared_to(extractor.position) + if d < best_extractor_distance: + best_extractor_distance = d + chosen_extractor = t + if chosen_extractor.is_empty(): + return + chosen_extractor.extractor_index = i + chosen_extractor.target_id = 0 + _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, + TRANSPORTER_TO_EXTRACTOR) for b in buildings: if outstanding_cost(int(b.id)) <= 0: continue @@ -1049,6 +1045,26 @@ func _arrive(t: Dictionary) -> void: t.carrying = 0 t.target_id = 0 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) + TRANSPORTER_TO_EXTRACTOR: + var extractor_index: int = int(t.get("extractor_index", -1)) + if extractor_index < 0 or extractor_index >= extractors.size(): + _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) + return + var extractor: Dictionary = extractors[extractor_index] + var load: int = transporter_load_limit() + if int(extractor.get("stock", 0)) < load: + t.extractor_index = -1 + _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) + return + extractor.stock = int(extractor.stock) - load + t.carrying = load + _begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR) + TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: + # Bank only on arrival; while in flight the ore remains in carrying. + rocks += int(t.carrying) + t.carrying = 0 + t.extractor_index = -1 + _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) TRANSPORTER_RETURNING: t.state = TRANSPORTER_IDLE t.progress = 1.0 diff --git a/Main.tscn b/Main.tscn index ee36119..9f8af89 100644 --- a/Main.tscn +++ b/Main.tscn @@ -17,7 +17,6 @@ [ext_resource type="Script" path="res://BuildController.gd" id="15"] [ext_resource type="Script" path="res://ResourceCounter.gd" id="16"] [ext_resource type="Script" path="res://FormationEditor.gd" id="17"] -[ext_resource type="Script" path="res://HaulerField.gd" id="18"] [ext_resource type="Script" path="res://NeonNumber.gd" id="19"] [ext_resource type="Script" path="res://NeonPanel.gd" id="20"] [ext_resource type="Script" path="res://SkillTreeView.gd" id="21"] @@ -71,9 +70,6 @@ script = ExtResource("11") [node name="BuildingField" type="Node2D" parent="."] script = ExtResource("14") -[node name="HaulerField" type="Node2D" parent="."] -script = ExtResource("18") - [node name="TransporterField" type="Node2D" parent="."] script = ExtResource("23") diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index b559bf5..894324b 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -1,28 +1,17 @@ extends SceneTree -# Headless gameplay test: run with -# godot --headless -s res://tests/test_extractor_income.gd -# Covers the extractor's automated hauler: -# 1. income accrues with NOTHING about the route loaded (no scene showing -# it), because the simulation lives in the GameState autoload -# 2. ore is delivered exactly when the hauler reaches the mothership - -# the payout and the drawn arrival are the same event, driven by the -# same `progress` value, so they cannot drift apart -# 3. no mothership means no route and therefore no income -# 4. the hauler's drawn position tracks that progress along the route +# Extractors now mine into local stock. A hub-stationed transporter is the +# only route to the bank, and it waits for a full researched-size load. var game_state: Node var stage: int = 0 var elapsed: float = 0.0 -# The test has to sit through a whole there-and-back hauler cycle, and the -# cycle length is distance/HAULER_SPEED - so this fixture distance IS the -# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s -# for the 4000 it used to be. Nothing here asserts on the distance itself, -# only on payout and drawn position tracking the same `progress`, so a short -# route exercises exactly the same code. -var mothership_pos: Vector2 = Vector2(600.0, 0.0) -var planet_pos: Vector2 = Vector2.ZERO -var rocks_before_arrival: int = 0 +var extractor_index: int = 0 +var base_load: int = 0 +var upgraded_load: int = 0 +const MOTHERSHIP_AT := Vector2.ZERO +const HUB_AT := Vector2(100.0, 0.0) +const EXTRACTOR_AT := Vector2(300.0, 0.0) func _initialize() -> void: @@ -30,83 +19,83 @@ func _initialize() -> void: game_state.rocks = 0 game_state.buildings.clear() game_state.extractors.clear() - - # An extractor with NO mothership: there is nowhere to deliver, so it - # must not pay out. - game_state.add_extractor("Test Planet", planet_pos) + game_state.transporters.clear() + game_state.reset_progression() + game_state.add_extractor("Test Planet", EXTRACTOR_AT) + extractor_index = 0 func _process(delta: float) -> bool: elapsed += delta match stage: 0: - if elapsed < 1.0: + # Mining is local even without a mothership or transporter. + if elapsed < 1.2: return false - if game_state.rocks != 0: - print("earned %d rocks with no mothership" % game_state.rocks) - print("TEST_EXTRACTOR_INCOME: FAIL (income without a delivery point)") - quit(1) + if game_state.extractor_stock(extractor_index) < 2 or game_state.rocks != 0: + _fail("extractor did not hold locally: stock=%d bank=%d" % [ + game_state.extractor_stock(extractor_index), game_state.rocks]) return true - print("no mothership -> no income (rocks=%d)" % game_state.rocks) - - # Give it somewhere to deliver. - game_state.add_building(GameState.BUILDING_MOTHERSHIP, mothership_pos) - game_state.extractors[0].progress = 0.0 + print("extractor mined locally: stock=%d bank=%d" % [ + game_state.extractor_stock(extractor_index), game_state.rocks]) + game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) + # A manually seeded idle transporter is enough to exercise the same + # simulation used by a produced one. + game_state.transporters.append({"hub_id": hub_id, "state": GameState.TRANSPORTER_IDLE, + "target_id": 0, "extractor_index": -1, "carrying": 0, "progress": 1.0, + "from": HUB_AT, "to": HUB_AT}) + base_load = game_state.transporter_load_limit() + game_state.extractors[extractor_index].stock = base_load - 1 elapsed = 0.0 stage = 1 return false 1: - var e: Dictionary = game_state.extractors[0] - var cycle: float = game_state.get_hauler_route_time(e) - if cycle <= 0.0: - print("TEST_EXTRACTOR_INCOME: FAIL (route never established)") - quit(1) - return true - - # Nothing in this scene tree draws the route at all - no Main - # scene, no HaulerField, no hauler node. Income must still tick. - if e.progress < GameState.HAULER_ARRIVAL: - rocks_before_arrival = game_state.rocks - # The drawn position must track progress along the route. - var pos: Variant = game_state.get_hauler_position(e) - var expected: Vector2 = planet_pos.lerp(mothership_pos, e.progress / GameState.HAULER_ARRIVAL) - if pos == null or pos.distance_to(expected) > 1.0: - print("hauler at %s, expected %s" % [pos, expected]) - print("TEST_EXTRACTOR_INCOME: FAIL (drawn position does not follow the simulation)") - quit(1) - return true + # No partial pickup: it stays local until it can fill the hold. + if elapsed < 0.2: return false - - # Just crossed the arrival point: the payout must have landed on - # the very same step, and the hauler must be AT the mothership. - var paid: bool = game_state.rocks == rocks_before_arrival + GameState.HAULER_YIELD - var at_mothership: Variant = game_state.get_hauler_position(e) - var arrived: bool = at_mothership != null and at_mothership.distance_to(mothership_pos) < 40.0 - print("arrival: rocks %d -> %d (paid=%s) hauler at %s (arrived=%s)" % [ - rocks_before_arrival, game_state.rocks, paid, at_mothership, arrived - ]) - if not (paid and arrived): - print("TEST_EXTRACTOR_INCOME: FAIL (payout and arrival did not coincide)") - quit(1) + if game_state.transporters[0].state != GameState.TRANSPORTER_IDLE or game_state.rocks != 0: + _fail("transporter left for a partial load") return true - - rocks_before_arrival = game_state.rocks + game_state.extractors[extractor_index].stock = base_load elapsed = 0.0 stage = 2 return false 2: - # It must not pay again until it has been back and returned. - var e2: Dictionary = game_state.extractors[0] - if e2.progress > GameState.HAULER_ARRIVAL: - if game_state.rocks != rocks_before_arrival: - print("paid %d extra mid-return" % (game_state.rocks - rocks_before_arrival)) - print("TEST_EXTRACTOR_INCOME: FAIL (paid while returning empty)") - quit(1) + # It must lift exactly the capacity amount, then bank it only when + # it reaches the mothership. + if game_state.rocks >= base_load: + print("full transporter load banked: %d ore" % base_load) + # Fund the lab purchase independently of the delivery assertion. + game_state.rocks = 100 + game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-100.0, 0.0)) + if not game_state.buy_upgrade("trn_capacity"): + _fail("could not research Expanded Hold at a ship lab") return true + upgraded_load = game_state.transporter_load_limit() + if upgraded_load <= base_load: + _fail("Expanded Hold did not raise capacity") + return true + game_state.extractors[extractor_index].stock = upgraded_load + elapsed = 0.0 + stage = 3 return false - # Wrapped past the planet and started a new outbound leg. - print("returned empty without paying again (rocks=%d)" % game_state.rocks) - print("TEST_EXTRACTOR_INCOME: PASS") - quit(0) - return true + if elapsed > 10.0: + _fail("full extractor load was never collected") + return true + return false + 3: + if game_state.rocks >= 100 - 30 + upgraded_load: + print("Expanded Hold %d -> %d, upgraded load banked" % [base_load, upgraded_load]) + print("TEST_EXTRACTOR_INCOME: PASS") + quit(0) + return true + if elapsed > 10.0: + _fail("upgraded load was not transported") + return true return false + + +func _fail(message: String) -> void: + print("TEST_EXTRACTOR_INCOME: FAIL (%s)" % message) + quit(1)