#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it

Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.

#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.

#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.

"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.

Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.

The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 14:02:20 +02:00
commit 3c632276af
12 changed files with 909 additions and 1 deletions

View file

@ -0,0 +1,146 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_outpost_building.gd
# Forgejo #102 (#97d): "A new building should be available, pretty early in
# the tech tree, to create outposts."
#
# Covers:
# A. the outpost is in the building catalog, with its own visual, its own
# icon and its own map colour - not a reused one
# B. a skill node unlocks it, and it is EARLY: shallower than the deep
# branches, and reachable for less than the mid-game structures cost
# C. it is hidden from the build menu until that node is researched, and
# appears afterwards, like every other gated structure
# D. it can be placed, it stands up in the world, and it has enough health
# to be worth attacking - it exists to be shot at
const MAX_EARLY_DEPTH: int = 2
var game_state: Node
var scene: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.reset_waves()
game_state.rocks = 5000
func _fail(message: String) -> bool:
print("TEST_OUTPOST_BUILDING: FAIL (%s)" % message)
quit(1)
return true
func _catalog_cases() -> bool:
var definitions: Script = load("res://buildings/BuildingDefinitions.gd")
var outpost: Resource = definitions.get_definition(GameState.BUILDING_OUTPOST)
if outpost == null:
return _fail("no \"outpost\" definition in the catalog")
if outpost.visual_scene == null:
return _fail("the outpost has no visual - it would be an invisible structure")
if outpost.menu_button_name == "":
return _fail("the outpost declares no build-menu entry, so it could never be built")
# --- A. its own look -------------------------------------------------
for other in definitions.all():
if other.definition_id == GameState.BUILDING_OUTPOST:
continue
if other.menu_icon == outpost.menu_icon:
return _fail("the outpost shares its icon with the %s" % other.definition_id)
if other.visual_scene == outpost.visual_scene:
return _fail("the outpost shares its visual with the %s" % other.definition_id)
if other.map_color.is_equal_approx(outpost.map_color):
return _fail("the outpost shares its map colour with the %s" % other.definition_id)
print("the outpost has its own icon, visual and map colour")
# --- B. researched, and early ----------------------------------------
var node_name: String = game_state.research_for_building(GameState.BUILDING_OUTPOST)
if node_name == "":
return _fail("nothing in the skill tree unlocks the outpost")
var node_id: String = ""
for node in GameState.SKILL_TREE:
if node.get("unlocks", "") == GameState.BUILDING_OUTPOST:
node_id = String(node.id)
var depth: int = game_state.skill_depth(node_id)
if depth > MAX_EARLY_DEPTH:
return _fail("\"%s\" sits %d prerequisites deep - that is not early" % [node_name, depth])
var cost: int = int(game_state.skill_node(node_id).cost)
for node in GameState.SKILL_TREE:
if String(node.get("unlocks", "")) in [GameState.BUILDING_TRANSPORT_HUB,
GameState.BUILDING_SHIP_LAB, GameState.BUILDING_GAS_BURNER]:
if int(node.cost) <= cost:
return _fail("\"%s\" costs %d, no less than the mid-game %s at %d" \
% [node_name, cost, node.name, node.cost])
print("\"%s\" is %d deep and costs %d ore - earlier and cheaper than the mid-game structures" \
% [node_name, depth, cost])
# --- C. gated until researched ---------------------------------------
if game_state.is_building_unlocked(GameState.BUILDING_OUTPOST):
return _fail("the outpost is buildable before its node is researched")
game_state.unlocked.append(node_id)
if not game_state.is_building_unlocked(GameState.BUILDING_OUTPOST):
return _fail("researching \"%s\" did not unlock the outpost" % node_name)
print("hidden until \"%s\" is researched, then offered" % node_name)
return false
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if _catalog_cases():
return true
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- C (continued). the menu shows it ------------------------
var controller: Node = scene.get_node("BuildController")
var button: Control = scene.get_node("UI/BuildMenu/OutpostButton")
controller._layout_build_menu()
if not button.visible:
return _fail("the outpost is researched but its build-menu button is hidden")
scene.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0))
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
# --- D. it stands up, and can be shot at ---------------------
var node: Node2D = null
for b in scene.get_node("BuildingField").spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_OUTPOST:
node = b
if node == null:
return _fail("placing an outpost produced no node")
if node.get_node_or_null("VisualHost").get_child_count() == 0:
return _fail("the outpost node has no visual under it")
if node.get_node_or_null("Hitbox") == null:
return _fail("the outpost has no hitbox - a wave could not touch it")
var hp: int = game_state.building_hp(node.building_id)
var crafter_hp: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER)
if hp < crafter_hp:
return _fail("the outpost has %d hp, less than a crafter's %d - it is meant to take hits" \
% [hp, crafter_hp])
print("an outpost stands at %d hp, against a crafter's %d" % [hp, crafter_hp])
print("TEST_OUTPOST_BUILDING: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1,206 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_outpost_garrison_menu.gd
# Forgejo #103 (#97e): "Outposts can be occupied by ships. Only ships that
# attack can be assigned. A dedicated menu on each outpost should let you
# compose how you want the outpost to be filled. There's a default of 2
# vessel per outpost, upgrades can increase this number."
#
# Two things live on an outpost's record and conflating them is the first
# thing to get wrong: the ORDER (what the player asked for) and the CREW
# (who is actually there). This menu edits the order only; #104 fills the
# crew. Keeping the order as a wish is what lets an outpost ask for a hull
# nobody has built yet and simply wait.
#
# Covers:
# A. only armed vessels can be assigned - a shield, a hauler or an engine
# block cannot hold a position however much the player wants it there
# B. two berths by default, and the order cannot exceed them
# C. researching the berth upgrade raises the ceiling at outposts ALREADY
# standing, not only at new ones
# D. the menu opens from the outpost, drives the real order through the
# real buttons, and reads back "here / ordered" rather than one number
# E. an outpost with an order nobody can fill reports it as waiting, and
# the order survives - it is not silently dropped
var game_state: Node
var scene: Node
var controller: Node
var menu: Control
var panel: Control
var outpost_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.reset_waves()
game_state.rocks = 5000
game_state.unlocked.append("forward_bases")
func _fail(message: String) -> bool:
print("TEST_OUTPOST_GARRISON_MENU: FAIL (%s)" % message)
quit(1)
return true
func _state_cases() -> bool:
# --- A. only armed vessels ------------------------------------------
var eligible: PackedStringArray = game_state.garrison_vessel_types()
if eligible.is_empty():
return _fail("no vessel at all may be garrisoned")
if not (GameState.VESSEL_INTERCEPTOR in eligible):
return _fail("the interceptor, which carries the fleet's gun, cannot be garrisoned")
for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER,
GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER]:
if game_state.vessel_can_garrison(unarmed):
return _fail("%s can be garrisoned - it does not attack" % unarmed)
print("garrisonable: %s" % str(eligible))
var id: int = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0))
outpost_id = id
if not game_state.is_outpost(game_state.find_building(id)):
return _fail("the outpost does not carry the garrison capability")
if game_state.set_garrison_desired(id, GameState.VESSEL_SHIELD, 1) != 0:
return _fail("a shield vessel was accepted into a garrison")
# --- B. two berths, and the ceiling bites ---------------------------
if GameState.GARRISON_BASE_SLOTS != 2:
return _fail("the default is %d berths, not the 2 the ticket asks for" \
% GameState.GARRISON_BASE_SLOTS)
if game_state.outpost_slot_capacity() != 2:
return _fail("an unresearched outpost offers %d berths" % game_state.outpost_slot_capacity())
if game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 5) != 2:
return _fail("an order of 5 was not clamped to the 2 berths available")
if game_state.garrison_desired_total(id) != 2:
return _fail("the outpost holds an order of %d against 2 berths" \
% game_state.garrison_desired_total(id))
print("2 berths by default; an order of 5 clamps to 2")
# --- C. the upgrade reaches existing outposts -----------------------
var upgrade_id: String = ""
for node in GameState.SKILL_TREE:
if int(node.get("outpost_bonus", 0)) > 0:
upgrade_id = String(node.id)
if upgrade_id == "":
return _fail("no skill node raises the berth count")
game_state.unlocked.append(upgrade_id)
if game_state.outpost_slot_capacity() != 3:
return _fail("researching \"%s\" left the capacity at %d" \
% [upgrade_id, game_state.outpost_slot_capacity()])
# The outpost was placed BEFORE the research, which is the case that
# matters: a bonus that only reached new buildings would mean demolishing
# and rebuilding to collect it.
if game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 3) != 3:
return _fail("an outpost built before the upgrade did not gain the berth")
print("\"%s\" raises an already-standing outpost to 3 berths" % upgrade_id)
game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 0)
return false
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if _state_cases():
return true
game_state.buildings.clear()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
menu = scene.get_node("UI/GarrisonMenu")
panel = scene.get_node("UI/GarrisonMenu/Panel")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
scene.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0))
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
# --- D. the real menu, through the real buttons --------------
var outpost: Node2D = null
for b in scene.get_node("BuildingField").spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_OUTPOST:
outpost = b
if outpost == null:
return _fail("no outpost node was placed")
outpost_id = int(outpost.building_id)
if outpost.primary_action() != "garrison":
return _fail("the outpost's primary action is \"%s\", not \"garrison\"" \
% outpost.primary_action())
controller.current_building = outpost
controller._on_action_pressed()
if not menu.visible:
return _fail("the outpost did not open the garrison menu")
if panel.building_id != outpost_id:
return _fail("the panel opened without knowing which outpost it belongs to")
var row: Dictionary = {}
for candidate in panel._rows:
if String(candidate.type) == GameState.VESSEL_INTERCEPTOR:
row = candidate
if row.is_empty():
return _fail("the panel has no row for the interceptor")
for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER]:
for candidate in panel._rows:
if String(candidate.type) == unarmed:
return _fail("the panel offers a row for the unarmed %s" % unarmed)
row.plus.pressed.emit()
row.plus.pressed.emit()
if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 2:
return _fail("two presses of + ordered %d vessels" \
% game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR))
if row.count.text != "0/2":
return _fail("the row reads \"%s\" - it should show crew over order" % row.count.text)
row.minus.pressed.emit()
if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1:
return _fail("- did not take the order back down")
print("the menu drives the real order: + + - leaves 1, shown as \"%s\"" % row.count.text)
# The ceiling is enforced through the buttons too, not only in
# GameState - a + that silently did nothing would be worse than a
# disabled one.
row.plus.pressed.emit()
row.plus.pressed.emit()
row.plus.pressed.emit()
var capacity: int = game_state.outpost_slot_capacity()
if game_state.garrison_desired_total(outpost_id) != capacity:
return _fail("pressing + past the ceiling ordered %d against %d berths" \
% [game_state.garrison_desired_total(outpost_id), capacity])
if row.plus.enabled:
return _fail("+ is still offered with every berth already spoken for")
print("+ stops at %d berths and greys itself out" % capacity)
# --- E. an unfillable order waits ---------------------------
if game_state.garrison_crew(outpost_id).size() != 0:
return _fail("the outpost is crewed already, so waiting proves nothing")
controller._refresh_garrison()
var status: String = scene.get_node("UI/GarrisonMenu/StatusLabel").text
if status.strip_edges() == "":
return _fail("an outpost waiting on vessels says nothing at all")
if game_state.garrison_desired_total(outpost_id) != capacity:
return _fail("the order was dropped because it could not be filled")
print("waiting, and saying so: \"%s\"" % status)
print("TEST_OUTPOST_GARRISON_MENU: PASS")
quit(0)
return true
return false