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>
146 lines
5.9 KiB
GDScript
146 lines
5.9 KiB
GDScript
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
|