spaceship/tests/test_skill_tree.gd
Loïc Wisniewski 1f5fecd371 Buy fleet berths with Fleet Offices
#219: fleet capacity is a built, supplied thing rather than a researched
one. The eight fleet-size doctrines are gone; capacity is one berth free
plus one per Fleet Office standing. An office charges every 25s for the
vessel in its berth, priced off that vessel's own build cost so an expensive
hull is expensive to keep, and drawn from its own store the way a gas burner
draws gas. An office that cannot pay goes dark, its vessel is scuttled, and
the player is told through the same queue quest lines use - which reaches
them on a planet surface too.

An empty berth is idle rather than starved: an office is only failing while
there is something in it to fail, and holding it disabled would leave a
berth that could never be crewed and so could never be paid for again.

Ships have no hit points and a shield absorbs a hit wholesale, so the lost
vessel is scuttled rather than damaged - otherwise a shielded hull would
survive its own eviction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 17:09:27 +02:00

226 lines
9 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_skill_tree.gd
# The mothership's research tree (Forgejo issue #4). It gates which
# structures may be built at all, and carries permanent upgrades that have
# no building of their own.
#
# Verifies:
# 1. from a fresh start only the mothership is buildable - the others are
# not in the menu at all (#21 changed this from greyed-but-listed), and
# the gate still holds if the build is forced past the menu
# 2. a locked option cannot be built even if the confirm is forced
# 3. research costs rocks, needs its prerequisite, and unlocks its building
# 4. the mothership's action button opens the tree (it used to have no
# action at all)
# 5. fleet capacity upgrades raise the cap, and the extra vessel can
# actually be crafted afterwards
var scene: Node
var game_state: Node
var controller: Node
var fleet: Node
var field: Node
var popup: Control
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
# --- #70: no two nodes drawn on top of each other -------------------
# SkillTreeView.node_screen_pos() places a node purely from its own
# (depth, x) - two nodes sharing both values land on the exact same
# pixel with no visual way to tell them apart. depth is derived from
# `requires` chains, not authored directly, so this has to check the
# same way the view does rather than eyeballing the data.
var seen: Dictionary = {}
for node in GameState.SKILL_TREE:
var key: String = "%d:%s" % [game_state.skill_depth(node.id), node.x]
if seen.has(key):
print("TEST_SKILL_TREE: FAIL (%s and %s are both at depth/x %s - drawn on top of each other)" \
% [seen[key], node.id, key])
quit(1)
return
seen[key] = node.id
print("no two skill-tree nodes share the same (depth, x)")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_formation()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
popup = scene.get_node("UI/BuildMenu/InfoPopup")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- everything but the mothership starts locked --------------
var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP)
var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("fresh start: mothership buildable=%s crafter locked=%s" % [
mothership_free, crafter_locked
])
if not (mothership_free and crafter_locked):
print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)")
quit(1)
return true
controller._on_build_pressed()
# Since #21 an unresearched structure is not listed at all, rather
# than listed and greyed. It is therefore unreachable from the
# menu - but the popup must still refuse if something calls
# the generic definition entry point by another route.
var hidden: bool = not scene.get_node("UI/BuildMenu/CrafterButton").visible
controller._show_building_info(GameState.BUILDING_SHIP_CRAFTER)
var offered: bool = popup.get_node("Panel/BuildButton").visible
print("locked crafter: hidden_from_menu=%s confirm_offered=%s" % [hidden, offered])
if not hidden or offered:
print("TEST_SKILL_TREE: FAIL (unresearched option still on offer)")
quit(1)
return true
# Forcing the confirm must still build nothing.
var before: int = game_state.buildings.size()
controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER
controller._confirm_build()
if game_state.buildings.size() != before:
print("TEST_SKILL_TREE: FAIL (built a locked structure)")
quit(1)
return true
controller._close_info()
# --- the mothership opens the tree ----------------------------
var mothership: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
mothership = b
controller.current_building = mothership
controller._on_action_pressed()
var tree_open: bool = scene.get_node("UI/SkillMenu").visible
print("mothership action opens research: %s" % tree_open)
if not tree_open:
print("TEST_SKILL_TREE: FAIL (mothership has no research action)")
quit(1)
return true
# The tree is a tall, vertically scrolling canvas. A branch keeps a
# stable lane as it rises, and dragging exposes higher depths.
var freight: Vector2 = controller.skill_menu.tree.node_screen_pos(game_state.skill_node("freight"))
var yards: Vector2 = controller.skill_menu.tree.node_screen_pos(game_state.skill_node("freight_yards"))
if absf(freight.x - yards.x) > 1.0 or yards.y >= freight.y:
print("TEST_SKILL_TREE: FAIL (freight branch does not rise vertically)")
quit(1)
return true
var tree_view: Control = controller.skill_menu.tree
var before_scroll: float = tree_view.scroll_y
var drag: InputEventScreenDrag = InputEventScreenDrag.new()
# Start at the root at the bottom; pull the canvas down to reveal
# the branches rising above it.
drag.relative = Vector2(0.0, 180.0)
tree_view.handle_drag(drag)
# #219 removed the eight-deep doctrine lane, which was what made
# the canvas taller than the view. A tree that FITS has nothing to
# scroll, so what is checked depends on which it is - and the
# clamp is worth pinning either way, since a canvas that drifts
# off its own top edge is the bug this gesture used to expose.
if before_scroll > 0.0:
if tree_view.scroll_y >= before_scroll:
print("TEST_SKILL_TREE: FAIL (tree did not scroll upward)")
quit(1)
return true
print("tree scrolls: %.0f -> %.0f" % [before_scroll, tree_view.scroll_y])
else:
if tree_view.scroll_y != 0.0:
print("TEST_SKILL_TREE: FAIL (a tree that fits scrolled to %.1f)" \
% tree_view.scroll_y)
quit(1)
return true
print("tree fits the view; the pan clamps at the top")
# --- prerequisites --------------------------------------------
if game_state.can_unlock("power_systems"):
print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)")
quit(1)
return true
# --- research costs rocks and unlocks its building ------------
var rocks_before: int = game_state.rocks
var cost: int = int(game_state.skill_node("shipyards").cost)
var ok: bool = game_state.unlock("shipyards")
var charged: bool = game_state.rocks == rocks_before - cost
var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [
ok, rocks_before, game_state.rocks, charged, now_buildable
])
if not (ok and charged and now_buildable):
print("TEST_SKILL_TREE: FAIL (research did not unlock its building)")
quit(1)
return true
# The menu must show it as buildable now, without reopening.
controller._on_build_pressed()
if not scene.get_node("UI/BuildMenu/CrafterButton").enabled:
print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)")
quit(1)
return true
controller._close_info()
scene.get_node("UI/BuildMenu").visible = false
# --- fleet capacity -------------------------------------------
# #219 took capacity out of the tree entirely: a berth is a Fleet
# Office standing somewhere. What this checks now is that the
# tree does NOT move the cap, and that the structure does.
var base_cap: int = game_state.max_fleet_size()
game_state.research_points = 2
game_state.unlock("coordination")
if game_state.max_fleet_size() != base_cap:
print("TEST_SKILL_TREE: FAIL (research still moves the fleet cap)")
quit(1)
return true
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(2400.0, 0.0))
var raised_cap: int = game_state.max_fleet_size()
print("fleet cap %d -> %d after a Fleet Office" % [base_cap, raised_cap])
if raised_cap != base_cap + 1:
print("TEST_SKILL_TREE: FAIL (a Fleet Office did not raise the cap)")
quit(1)
return true
# And the extra vessel must actually be craftable.
fleet.spawn_ships(base_cap, Vector2.ZERO)
var added: bool = fleet.add_ship()
print("fleet %d ships, add one more -> %s (now %d)" % [
base_cap, added, fleet.ships.size()
])
if not (added and fleet.ships.size() == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)")
quit(1)
return true
if fleet.add_ship():
print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)")
quit(1)
return true
print("TEST_SKILL_TREE: PASS")
quit(0)
return true
return false