spaceship/tests/test_resources_and_buildings.gd
Loïc Wisniewski 1ece202731 #138: structures and doctrines cost more than rocks, and more the deeper they are
Both halves of the ticket, in the shape vessels have used since gas bombers
and laser ships: a definition carries an ore figure and, when it wants more
than ore, a dictionary that IS the price. `building_costs()` and
`skill_costs()` answer it the way `product_costs()` already did, so there is
one question with one answer whatever is being bought, and every payment
path now goes through `spend_resources` rather than `spend`.

Seven of ten structures and thirteen of seventeen doctrines now want gas,
energy or dark matter alongside their rocks, and the totals climb with tree
depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530.

The constraint that shaped the table is REACHABILITY, and it is the reason
some prices stayed ore-only. Energy exists only where a gas burner stands,
and a gas burner needs Power Systems - so nothing at or before that depth
may be priced in energy, or it is a wall with no way through. Dark matter is
rarer still and appears once, on the deepest node in the game. The extractor
stays rocks-only despite its depth allowing gas, because the quest chain
reaches it before it has sent the player for any.

test_progressive_costs checks those as properties rather than transcribing
the numbers, so rebalancing a price costs nothing while breaking the shape
fails: nothing unreachable at its own depth, prices rising with depth, the
ore field agreeing with the dictionary (repairs quote the field alone, since
a repair drone carries ore and nothing else), and the quest chain never
asking for a structure it has not funded.

Two things cleaned up on the way:

- The per-building cost constants in GameState are gone. Nothing but tests
  used them, and they were a second copy of a number the definitions already
  hold - exactly what would drift once a price gained a resource.
- research_for_building() returns a display NAME, and the id-keyed half of
  the tree wants an id. That mix-up is silent - skill_depth() of a name finds
  no node and answers 0, so a deep structure reads as an ungated one, which
  is how the first draft of the test let an unreachable price through.
  research_node_for_building() is the id, and the name is expressed from it.

PriceTag no longer rebuilds its labels when re-told the same price; it
refreshes the held figures instead. The craft menu re-states prices every
frame and the build menu now does too, so that was a dozen Labels being torn
down and rebuilt sixty times a second.
2026-08-28 15:30:30 +02:00

231 lines
9.1 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_resources_and_buildings.gd
# Covers the resource/building loop end to end:
# 1. flying a ship over a surface rock loads it into the fleet's hold
# (banking it needs a trip to the mothership - see #14 and
# tests/test_fleet_cargo.gd)
# 2. buildings are paid for, persist in GameState, and respawn with the
# space scene (they must survive the trip to a planet and back)
# 3. a ship crafter adds a vessel to the fleet for rocks
# 4. wiping out the fleet rebuilds one vessel at the mothership
var stage: int = 0
var elapsed: float = 0.0
var scene: Node
var fleet: Node
var game_state: Node
var rock: Area2D
var mothership_pos: Vector2 = Vector2(900.0, -400.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 0
game_state.fleet_cargo = 0
game_state.buildings.clear()
game_state.has_return_position = false
scene = load("res://surface/PlanetSurface.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- gather a rock -------------------------------------------
scene.get_node("SurfaceFeatures/ResourceProvider").set_process(false)
rock = load("res://surface/Rock.tscn").instantiate()
rock.build_shape(1)
# Placed OUTSIDE the pickup shape but inside attract_radius, so
# this also proves the magnetic pull actually reels it in rather
# than the ship simply happening to overlap it on spawn.
rock.position = fleet.ships[0].position + Vector2(150.0, 0.0)
scene.add_child(rock)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 1.5:
return false
# Into the hold, not the bank: since #14 ore is cargo until the
# fleet delivers it to the mothership.
if game_state.fleet_cargo != 1:
print("fleet_cargo=%d after flying a ship onto one" % game_state.fleet_cargo)
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)")
quit(1)
return true
print("gathered rock -> hold=%d bank=%d" % [game_state.fleet_cargo, game_state.rocks])
# Bank enough to build with, then move to the space scene.
game_state.rocks = 60
change_scene_to_file("res://world/Main.tscn")
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
if current_scene == null or current_scene.name != "Main":
return false
scene = current_scene
fleet = scene.get_node("Fleet")
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- place both buildings -------------------------------------
var field: Node = scene.get_node("BuildingField")
var before: int = game_state.rocks
for ship in fleet.ships:
ship.position = mothership_pos
fleet.move_to(mothership_pos)
field.place(GameState.BUILDING_MOTHERSHIP, mothership_pos)
game_state.spend(int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]))
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(1400.0, 300.0))
game_state.spend(int(game_state.building_costs(GameState.BUILDING_SHIP_CRAFTER)[GameState.RESOURCE_ORE]))
var paid: bool = game_state.rocks == before - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) - int(game_state.building_costs(GameState.BUILDING_SHIP_CRAFTER)[GameState.RESOURCE_ORE])
var stored: bool = game_state.buildings.size() == 2
print("buildings=%d rocks %d->%d paid=%s" % [
game_state.buildings.size(), before, game_state.rocks, paid
])
if not (paid and stored):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (placing/paying wrong)")
quit(1)
return true
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.3:
return false
# --- crafter adds a ship --------------------------------------
# Crafting adds a FOURTH vessel below, one past the 3 slots a
# new game has, so make room first - the subject here is the
# crafter, not the fleet cap.
game_state.unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(3, Vector2.ZERO)
var before_ships: int = fleet.ships.size()
var before_rocks: int = game_state.rocks
game_state.spend(GameState.SHIP_COST)
var added: bool = fleet.add_ship()
print("craft: ships %d->%d rocks %d->%d" % [
before_ships, fleet.ships.size(), before_rocks, game_state.rocks
])
if not (added and fleet.ships.size() == before_ships + 1 and game_state.rocks == before_rocks - GameState.SHIP_COST):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (crafting a ship)")
quit(1)
return true
# --- command centre: drag a formation slot ---------------------
var editor: Control = scene.get_node("UI/FormationMenu/Editor")
var menu: Control = scene.get_node("UI/FormationMenu")
menu.visible = true
editor.size = Vector2(520, 520)
# The editor is driven from BuildController._unhandled_input,
# which never fires for Controls that consume touches through
# the GUI system first. The menu AND its full-screen backdrop
# must both be IGNORE, or dragging a slot silently does nothing
# on a real device - which is exactly what happened.
for node in [menu, menu.get_node("Backdrop"), editor]:
if node.mouse_filter != Control.MOUSE_FILTER_IGNORE:
print("%s has mouse_filter=%d, needs IGNORE" % [node.name, node.mouse_filter])
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation menu would swallow drags)")
quit(1)
return true
var slot_before: Vector2 = game_state.formation[1]
var down: InputEventScreenTouch = InputEventScreenTouch.new()
down.index = 0
down.pressed = true
down.position = editor.global_position + editor._slot_screen_pos(1)
var grabbed: bool = editor.handle_touch(down)
var move: InputEventScreenDrag = InputEventScreenDrag.new()
move.index = 0
move.position = editor.global_position + editor._slot_screen_pos(1) + Vector2(40.0, 25.0)
editor.handle_drag(move)
var slot_after: Vector2 = game_state.formation[1]
menu.visible = false
print("formation slot 1: %s -> %s (grabbed=%s)" % [slot_before, slot_after, grabbed])
if not (grabbed and slot_after != slot_before):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation slot did not drag)")
quit(1)
return true
# Editing must re-seat the ships ALREADY in flight, not just
# ones built later - otherwise the edit reads as doing nothing.
var live_target: Vector2 = fleet.ships[1].move_target
var expected_target: Vector2 = fleet.fleet_target + slot_after
var live_ok: bool = live_target.distance_to(expected_target) < 0.01
print("live ship 1 target %s, expected %s (ok=%s)" % [
live_target, expected_target, live_ok
])
if not live_ok:
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (existing ships ignored the edit)")
quit(1)
return true
# --- pivot the formation --------------------------------------
var before_pivot: Vector2 = game_state.formation[1]
game_state.rotate_formation(PI / 2.0)
fleet.apply_formation()
var after_pivot: Vector2 = game_state.formation[1]
var rotated_ok: bool = after_pivot.distance_to(before_pivot.rotated(PI / 2.0)) < 0.01
var length_kept: bool = abs(after_pivot.length() - before_pivot.length()) < 0.01
var ship_followed: bool = fleet.ships[1].move_target.distance_to(fleet.fleet_target + after_pivot) < 0.01
print("pivot: %s -> %s (rotated=%s shape_kept=%s ship_followed=%s)" % [
before_pivot, after_pivot, rotated_ok, length_kept, ship_followed
])
if not (rotated_ok and length_kept and ship_followed):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (pivot)")
quit(1)
return true
game_state.rotate_formation(-PI / 2.0)
slot_after = game_state.formation[1]
# A rebuilt fleet must adopt the edited layout, which is the
# whole point of storing the formation in GameState.
fleet.spawn_ships(2, Vector2.ZERO)
var uses_layout: bool = fleet.ships[1].position.distance_to(slot_after) < 0.01
print("respawned ship 1 at %s, formation says %s (matches=%s)" % [
fleet.ships[1].position, slot_after, uses_layout
])
if not uses_layout:
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet ignored the edited formation)")
quit(1)
return true
# --- wipe the fleet -------------------------------------------
for ship in fleet.ships.duplicate():
ship.queue_free()
fleet.ships.clear()
fleet.formation_offsets.clear()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.5:
return false
# BuildController._process should have rebuilt one vessel, at
# the mothership rather than the origin.
var respawned: bool = fleet.ships.size() == 1
var at_mothership: bool = respawned and fleet.ships[0].position.distance_to(mothership_pos) < 120.0
print("after wipe: ships=%d at_mothership=%s (pos=%s expected~%s)" % [
fleet.ships.size(), at_mothership,
fleet.ships[0].position if respawned else Vector2.ZERO, mothership_pos
])
if respawned and at_mothership:
print("TEST_RESOURCES_AND_BUILDINGS: PASS")
quit(0)
else:
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet not rebuilt at the mothership)")
quit(1)
return true
return false