spaceship/tests/test_building_health.gd
Loïc Wisniewski 265e121cb0 Build the logistics crews, and let them burn energy
#216: transport hubs and spacial workshops no longer conjure their crews.
Transporters and repair drones are ordered at a ship crafter, assigned per
building through a new Station menu, flown out from the mothership hangar,
and spent by the work they do - 20 runs, 12 repairs, counted where the run
ENDS so nothing retires with cargo in the air or a building half repaired.
A spent vessel is gone, so the structure is short again and the player has
to build another. The menu shows what each one has left, worst first.

Craftability needed its own flag: "the crafter can make it" and "it can take
a berth in the fleet" stopped being the same question.

#217: transporters crawl at 120 rather than 150, and a Burn Drive lets them
carry energy and spend it to travel far faster. Fetching it is a leg of its
own, to the nearest store that has any - so a refuelling transporter cannot
be dispatched mid-errand - and the leg flown to COLLECT fuel is not charged
for it. Unresearched, nothing detours and nothing changes.

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

230 lines
11 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_building_health.gd
# Forgejo #99 (#97a): structures can be hurt, and can come down.
#
# Health lives on the RECORD in GameState rather than on the Building node,
# because Main.tscn is destroyed and rebuilt on every trip to a planet
# surface - damage kept on a node would heal itself the moment the player
# landed. Most of this test therefore talks to GameState directly, and only
# the last case looks at what the scene does with it.
#
# Covers:
# A. every building definition declares a positive max_hp, and a fresh
# record starts at full health
# B. damage subtracts, reports survival, and enough of it destroys the
# record outright
# C. a record written before this existed reads as undamaged, not as
# rubble - an existing save must not open on a field of wrecks
# D. #159 - NOTHING is exempt from coming down, the mothership included.
# It used to floor at 1hp to avoid a soft-lock, and a battered one sat
# there reading as dead and never exploded. The soft-lock does not
# exist: what is IN the mothership outlives it, and rebuilding it is
# always reachable - so the way back is checked here rather than the
# exemption that used to stand in for it
# E. destroying a transport hub takes its transporters with it, and
# nothing else keyed on the id is left pointing at a hole
# F. the Building node follows its record out of existence, and shows
# damage while it is still standing
var game_state: Node
var scene: Node
var stage: int = 0
var elapsed: float = 0.0
var destroyed_events: Array = []
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.rocks = 5000
func _fail(message: String) -> bool:
print("TEST_BUILDING_HEALTH: FAIL (%s)" % message)
quit(1)
return true
func _run_state_cases() -> bool:
var definitions: Script = load("res://buildings/BuildingDefinitions.gd")
# --- A. every structure declares what it can take -------------------
for definition in definitions.all():
if int(definition.max_hp) <= 0:
return _fail("'%s' has max_hp %d - it would be rubble the moment it was placed" \
% [definition.definition_id, definition.max_hp])
var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(300.0, 0.0))
var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER)
if game_state.building_hp(crafter_id) != full:
return _fail("a new crafter opened at %d of %d hp" \
% [game_state.building_hp(crafter_id), full])
if not is_equal_approx(game_state.building_health_fraction(crafter_id), 1.0):
return _fail("a new crafter is not at full health")
print("%d definitions all declare max_hp; a new crafter starts at %d" \
% [definitions.all().size(), full])
# --- B. damage, then death ------------------------------------------
game_state.building_destroyed.connect(func(id: int, type: String):
destroyed_events.append({"id": id, "type": type}))
if game_state.damage_building(crafter_id, full - 1):
return _fail("one hit point short of dead reported the crafter destroyed")
if game_state.building_hp(crafter_id) != 1:
return _fail("hp is %d after taking max_hp - 1" % game_state.building_hp(crafter_id))
if game_state.building_health_fraction(crafter_id) >= 0.5:
return _fail("a crafter on its last hit point still reads as healthy")
if not game_state.damage_building(crafter_id, 1):
return _fail("the last hit point did not destroy the crafter")
if not game_state.find_building(crafter_id).is_empty():
return _fail("the destroyed crafter is still in GameState.buildings")
if destroyed_events.size() != 1 or int(destroyed_events[0].id) != crafter_id:
return _fail("building_destroyed did not fire once for the crafter")
# Fired AFTER the record went, so a listener sees the world without it.
print("a crafter takes %d damage and comes down, announcing itself" % full)
# --- C. a record from before this existed ---------------------------
var legacy_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-300.0, 0.0))
game_state.find_building(legacy_id).erase("hp")
var lab_full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_LAB)
if game_state.building_hp(legacy_id) != lab_full:
return _fail("a record with no hp field reads as %d, not the %d it should" \
% [game_state.building_hp(legacy_id), lab_full])
if game_state.damage_building(legacy_id, 1):
return _fail("one point of damage levelled a building carried over from an old save")
print("a pre-#99 record reads as undamaged (%d hp), not as rubble" % lab_full)
# --- D. #159: the mothership comes down like anything else ----------
var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
if game_state.building_is_indestructible(game_state.find_building(mothership_id)):
return _fail("the mothership is still exempt - it would sit at 1hp reading as dead")
# Stock the things that used to be the reason for the exemption, so
# their survival is observed rather than assumed.
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 400)
game_state.stored_vessels.append(GameState.VESSEL_INTERCEPTOR)
if not game_state.damage_building(mothership_id, 999999):
return _fail("enough damage did not destroy the mothership")
if not game_state.find_building(mothership_id).is_empty():
return _fail("the mothership record outlived its own destruction")
# ...and the way back. This is what the exemption was protecting, so it
# is what has to be true without it.
if game_state.banked_resource(GameState.RESOURCE_ORE) != 400:
return _fail("the bank died with the building - it holds %d" \
% game_state.banked_resource(GameState.RESOURCE_ORE))
if game_state.stored_vessels.is_empty():
return _fail("the hangar died with the building")
if game_state.research_for_building(GameState.BUILDING_MOTHERSHIP) != "":
return _fail("rebuilding the mothership needs research, which needs a mothership")
var rebuild: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP).get(
GameState.RESOURCE_ORE, 0))
var one_hold: int = game_state.fleet_cargo_capacity(
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
if rebuild > one_hold:
return _fail("a rebuild costs %d ore and one surviving hull carries %d - unreachable with the bank gone" \
% [rebuild, one_hold])
print("the mothership comes down; bank, hangar and an ungated %d-ore rebuild all survive it" \
% rebuild)
# Repair, on a fresh one - the last was destroyed on purpose above.
var rebuilt_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
var at_full: int = game_state.building_max_hp(GameState.BUILDING_MOTHERSHIP)
if game_state.damage_building(rebuilt_id, at_full - 5):
return _fail("damage short of the last few points still destroyed it")
if game_state.building_hp(rebuilt_id) != 5:
return _fail("a battered mothership sits at %d hp, expected 5" \
% game_state.building_hp(rebuilt_id))
if game_state.repair_building(rebuilt_id, 999999) <= 0:
return _fail("a battered mothership could not be repaired")
if game_state.building_hp(rebuilt_id) != at_full:
return _fail("repair did not take the mothership back to full")
print("a rebuilt one takes damage down to %d and repairs back to %d" % [5, at_full])
# --- E. a hub takes its transporters with it ------------------------
var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(800.0, 0.0))
# #216: crews are built and flown in now, not provisioned with the
# structure. This test is about what a crew DOES, so it is put
# straight at its post - test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff(game_state, hub_id, game_state.hub_capacity())
var hub_transporters: int = 0
for transporter in game_state.transporters:
if int(transporter.get("hub_id", 0)) == hub_id:
hub_transporters += 1
if hub_transporters <= 0:
return _fail("the hub provisioned no transporters, so this case proves nothing")
var others: int = game_state.transporters.size() - hub_transporters
if not game_state.destroy_building(hub_id):
return _fail("the hub could not be destroyed")
for transporter in game_state.transporters:
if int(transporter.get("hub_id", 0)) == hub_id:
return _fail("a transporter is still flying for a hub that no longer exists")
if game_state.transporters.size() != others:
return _fail("destroying the hub took %d unrelated transporters with it" \
% (others - game_state.transporters.size()))
print("destroying a hub retires its %d transporters and no others" % hub_transporters)
return false
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if _run_state_cases():
return true
# --- F. what the scene does with it -------------------------
game_state.buildings.clear()
game_state.transporters.clear()
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)
var field: Node = scene.get_node("BuildingField")
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0))
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
var field: Node = scene.get_node("BuildingField")
var node: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
node = b
if node == null:
return _fail("no crafter node was spawned")
if node.is_damaged():
return _fail("a freshly placed crafter already shows damage")
game_state.damage_building(node.building_id, game_state.building_max_hp(
GameState.BUILDING_SHIP_CRAFTER) / 2)
node._process(0.0)
if not node.is_damaged():
return _fail("a crafter at half health does not show it")
print("a half-wrecked crafter reads %.0f%%" % (node.health_fraction() * 100.0))
game_state.damage_building(node.building_id, 999999)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.2:
return false
var field: Node = scene.get_node("BuildingField")
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
return _fail("the crafter node outlived the record it was built from")
print("the node follows its record out of existence")
print("TEST_BUILDING_HEALTH: PASS")
quit(0)
return true
return false