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.
177 lines
6.8 KiB
GDScript
177 lines
6.8 KiB
GDScript
extends SceneTree
|
|
|
|
const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd")
|
|
|
|
# Headless gameplay test: run with
|
|
# godot --headless -s res://tests/test_transport_hub_building.gd
|
|
# Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of
|
|
# its own. Only the building here - the vessels that do the hauling are #19.
|
|
#
|
|
# Verifies:
|
|
# 1. it is gated behind its research node, like every other structure
|
|
# 2. researching it makes it buildable, and placing it charges its cost
|
|
# 3. it is drawn as ITSELF, not as the crafter - Building._draw()'s match
|
|
# has a catch-all that renders anything unrecognised as a ship crafter,
|
|
# so a missing case is silent and looks almost right
|
|
# 4. its geometry is rasterised ONCE however long it animates (#10), and
|
|
# its stationed transports really do move
|
|
# 5. it has its own build-menu icon and its own map marker rather than
|
|
# borrowing another building's - the menu was once unreadable because
|
|
# two buildings shared a glyph
|
|
|
|
var game_state: Node
|
|
var scene: Node
|
|
var field: Node
|
|
var controller: Node
|
|
var map_content: Node2D
|
|
var hub_building: Node2D
|
|
var hub: Node2D
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
var spin_at_start: float = 0.0
|
|
|
|
const HUB_AT: Vector2 = Vector2(700.0, 0.0)
|
|
|
|
|
|
func _initialize() -> void:
|
|
game_state = root.get_node("GameState")
|
|
game_state.buildings.clear()
|
|
game_state.extractors.clear()
|
|
game_state.reset_progression()
|
|
game_state.rocks = 500
|
|
game_state.fleet_cargo = 0
|
|
scene = load("res://world/Main.tscn").instantiate()
|
|
root.add_child(scene)
|
|
field = scene.get_node("BuildingField")
|
|
controller = scene.get_node("BuildController")
|
|
map_content = scene.get_node("UI/WorldMap/MapContent")
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
elapsed += delta
|
|
match stage:
|
|
0:
|
|
scene.get_node("AsteroidField").set_process(false)
|
|
scene.get_node("EnemySpawner").set_process(false)
|
|
|
|
# --- 1. locked until researched ------------------------------
|
|
if game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (buildable with no research at all)")
|
|
quit(1)
|
|
return true
|
|
var gate: String = game_state.research_for_building(GameState.BUILDING_TRANSPORT_HUB)
|
|
if gate == "":
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no research node gates it)")
|
|
quit(1)
|
|
return true
|
|
print("locked behind research: '%s'" % gate)
|
|
|
|
# --- 5. its own icon, distinct from every other building -----
|
|
var icons: Dictionary = {}
|
|
for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER,
|
|
GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]:
|
|
var icon: int = BUILDING_DEFINITIONS.get_definition(type).action_icon
|
|
if icons.has(icon):
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]])
|
|
quit(1)
|
|
return true
|
|
icons[icon] = type
|
|
print("action icons distinct across %d buildings" % icons.size())
|
|
|
|
# --- 2. research it, then build it ---------------------------
|
|
game_state.unlocked = ["shipyards", "logistics", "freight"]
|
|
if not game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate)
|
|
quit(1)
|
|
return true
|
|
var hub_cost: int = int(game_state.building_costs(GameState.BUILDING_TRANSPORT_HUB)[GameState.RESOURCE_ORE])
|
|
var before: int = game_state.rocks
|
|
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
|
|
game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB))
|
|
if game_state.rocks != before - hub_cost:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [
|
|
before - game_state.rocks, hub_cost
|
|
])
|
|
quit(1)
|
|
return true
|
|
print("researched and placed for %d ore" % hub_cost)
|
|
elapsed = 0.0
|
|
stage = 1
|
|
return false
|
|
1:
|
|
if elapsed < 0.3:
|
|
return false
|
|
for b in field.spawned:
|
|
if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB:
|
|
hub_building = b
|
|
hub = b.visual
|
|
if hub == null:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)")
|
|
quit(1)
|
|
return true
|
|
if not hub_building.available_actions().is_empty():
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (offers a manual resource transfer)")
|
|
quit(1)
|
|
return true
|
|
for ship in scene.get_node("Fleet").ships:
|
|
ship.position = HUB_AT
|
|
scene.get_node("Fleet").move_to(HUB_AT)
|
|
|
|
# --- 3. drawn as itself, not via the crafter catch-all -------
|
|
# _hub_traffic only exists if _ready() took the transport-hub
|
|
# branch; a missing case falls through to the crafter and would
|
|
# leave it null while still rendering something plausible.
|
|
if hub._hub_traffic == null:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (rendered through the crafter catch-all, not as a hub)")
|
|
quit(1)
|
|
return true
|
|
|
|
# --- 5. its own map marker ----------------------------------
|
|
var markers: Array = map_content.building_markers()
|
|
var found: bool = false
|
|
for m in markers:
|
|
if m.type == GameState.BUILDING_TRANSPORT_HUB:
|
|
found = true
|
|
if m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_SHIP_CRAFTER).map_color \
|
|
or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_MOTHERSHIP).map_color \
|
|
or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_COMMAND_CENTER).map_color:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)")
|
|
quit(1)
|
|
return true
|
|
if not found:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no map marker - see #11)")
|
|
quit(1)
|
|
return true
|
|
print("has its own map marker")
|
|
|
|
spin_at_start = hub._hub_traffic.rotation
|
|
elapsed = 0.0
|
|
stage = 2
|
|
return false
|
|
2:
|
|
# --- 4. animates without ever redrawing (#10) ----------------
|
|
if elapsed < 2.0:
|
|
return false
|
|
if controller.current_building != hub_building \
|
|
or controller.action_button.visible or controller.transfer_button.visible:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (actionless hub context current=%s expected=%s action=%s transfer=%s)" % [
|
|
controller.current_building, hub_building, controller.action_button.visible,
|
|
controller.transfer_button.visible,
|
|
])
|
|
quit(1)
|
|
return true
|
|
print("rasterisations after %.1fs: %d" % [elapsed, hub.draw_count])
|
|
if hub.draw_count != 1:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (redrew its geometry %d times to animate)" % hub.draw_count)
|
|
quit(1)
|
|
return true
|
|
var spun: float = absf(hub._hub_traffic.rotation - spin_at_start)
|
|
print("stationed transports turned %.3f rad" % spun)
|
|
if spun < 0.05:
|
|
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (nothing moved - a frozen hub trivially passes the redraw check)")
|
|
quit(1)
|
|
return true
|
|
print("TEST_TRANSPORT_HUB_BUILDING: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|