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.
231 lines
9.1 KiB
GDScript
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
|