extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_transporter_supply.gd # Forgejo #19 (part 6 of #12): transporters stationed at a hub fetch ore from # the mothership and deliver it to whichever building is waiting on it, with # no player input at all. # # Verifies: # 1. a transporter is built at the CRAFTER and takes itself to the nearest # hub, rather than appearing at the hub out of nowhere # 2. a queued order the crafter cannot pay for is fulfilled end to end # automatically - the whole point of the ticket # 3. ore is conserved across the entire route (bank + hold + every # building's stock), at every step, not just at the end # 4. ore leaves the bank when it is LOADED, not when the order is placed - # the vessel is carrying something real # 5. transporters are not ordered when there is no hub to station them at # 6. no unbounded node accumulation: the fleet of transporters is exactly # what was built, however many runs they make var game_state: Node var scene: Node var fleet: Node var field: Node var crafter_id: int = 0 var hub_id: int = 0 var stage: int = 0 var elapsed: float = 0.0 var total_at_start: int = 0 var bank_when_ordered: int = 0 var conservation_broken: bool = false # Ore legitimately consumed by completed production. Conservation means # "nothing vanishes in TRANSIT", not "the total never falls" - building a # vessel spends ore, and that is the one way it is allowed to leave. var consumed: int = 0 const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) const HUB_AT: Vector2 = Vector2(600.0, 200.0) const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0) func _total() -> int: var sum: int = game_state.rocks + game_state.fleet_cargo for b in game_state.buildings: if b.type != GameState.BUILDING_MOTHERSHIP: sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) for t in game_state.transporters: sum += int(t.get("carrying", 0)) # Ore inside a build that has STARTED but not finished (#23). It has left # the building's stock and has not yet been consumed by a completed # vessel, so without counting it the total dips for the length of every # build - which is a gap in this sum, not a leak in the game. for b in game_state.buildings: for o in b.get("orders", []): if bool(o.started): sum += game_state.product_cost(String(o.product)) return sum func _initialize() -> void: game_state = root.get_node("GameState") game_state.buildings.clear() game_state.extractors.clear() game_state.transporters.clear() game_state.rocks = 200 game_state.fleet_cargo = 0 game_state.stored_vessels.clear() game_state.build_time_scale = 0.02 game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"] scene = load("res://Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") field = scene.get_node("BuildingField") game_state.order_completed.connect(_on_order_completed) func _on_order_completed(_building_id: int, product: String) -> void: consumed += game_state.product_cost(product) func _process(delta: float) -> bool: elapsed += delta # --- 3. conservation, checked EVERY tick ------------------------------ # Ore in flight is the easy thing to lose: a leg that loads without # debiting, or delivers without crediting, would show up here and nowhere # else. Checked continuously rather than at the end, so it cannot be # masked by a later correction. if stage >= 2 and _total() + consumed != total_at_start: conservation_broken = true match stage: 0: scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) for a in get_nodes_in_group("asteroids"): if is_instance_valid(a): a.queue_free() field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) crafter_id = int(game_state.buildings[-1].id) # --- 5. no hub yet: a transporter cannot be ordered ---------- if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): print("TEST_TRANSPORTER_SUPPLY: FAIL (ordered a transporter with no hub to station it at)") quit(1) return true print("no hub -> transporter order refused") field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) hub_id = int(game_state.buildings[-1].id) elapsed = 0.0 stage = 1 return false 1: if elapsed < 0.2: return false # --- 1. build a transporter at the crafter ------------------ if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): print("TEST_TRANSPORTER_SUPPLY: FAIL (could not order a transporter with a hub built)") quit(1) return true game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST) elapsed = 0.0 stage = 2 total_at_start = _total() return false 2: if elapsed < game_state.build_time(GameState.PRODUCT_TRANSPORTER) * 3.0: return false if game_state.transporters.size() != 1: print("TEST_TRANSPORTER_SUPPLY: FAIL (built %d transporters from one order)" % game_state.transporters.size()) quit(1) return true var t: Dictionary = game_state.transporters[0] if int(t.hub_id) != hub_id: print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter did not take the nearest hub)") quit(1) return true # It must fly FROM the crafter TO the hub, not appear at the hub. # Checked on the leg's endpoints rather than its current position, # which is already partway there by now - and being partway is # the correct behaviour. if (t.from as Vector2).distance_to(CRAFTER_AT) > 1.0: print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg starts at %s, not at the crafter that built it)" % t.from) quit(1) return true if (t.to as Vector2).distance_to(HUB_AT) > 1.0: print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg does not end at the hub: %s)" % t.to) quit(1) return true print("transporter built at the crafter, heading for hub %d" % hub_id) elapsed = 0.0 stage = 3 return false 3: # Give it time to reach the hub and go idle. if elapsed < 4.0: return false if game_state.transporters[0].state != GameState.TRANSPORTER_IDLE: print("TEST_TRANSPORTER_SUPPLY: FAIL (never settled at its hub: state=%s)" % game_state.transporters[0].state) quit(1) return true print("stationed at the hub, idle") # --- 2 + 4. an order it cannot pay for ---------------------- game_state.queue_order(crafter_id, GameState.PRODUCT_SHIP) bank_when_ordered = game_state.rocks elapsed = 0.0 stage = 4 return false 4: # One tick: placing the order must not touch the bank. if elapsed < 0.1: return false if game_state.rocks != bank_when_ordered: print("TEST_TRANSPORTER_SUPPLY: FAIL (bank moved on placing the order, before anything was loaded)") quit(1) return true print("order placed, bank untouched at %d" % game_state.rocks) elapsed = 0.0 stage = 5 return false 5: # --- 2. the whole run, with no player input ----------------- if game_state.pending_orders(crafter_id) > 0: if elapsed > 25.0: print("TEST_TRANSPORTER_SUPPLY: FAIL (order never fulfilled: transporter state=%s carrying=%d crafter stock=%d)" % [ game_state.transporters[0].state, int(game_state.transporters[0].carrying), game_state.building_stock(crafter_id), ]) quit(1) return true return false if conservation_broken: print("TEST_TRANSPORTER_SUPPLY: FAIL (ore created or destroyed somewhere along the route)") quit(1) return true print("order fulfilled automatically in %.1fs, no player input" % elapsed) # --- 6. no vessel accumulation ------------------------------ if game_state.transporters.size() != 1: print("TEST_TRANSPORTER_SUPPLY: FAIL (%d transporters exist after the run, one was built)" % game_state.transporters.size()) quit(1) return true print("still exactly 1 transporter after a full round trip") print("bank=%d crafter stock=%d total=%d + %d consumed = %d (started %d)" % [ game_state.rocks, game_state.building_stock(crafter_id), _total(), consumed, _total() + consumed, total_at_start ]) print("TEST_TRANSPORTER_SUPPLY: PASS") quit(0) return true return false