spaceship/tests/test_fleet_growth.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

180 lines
7.3 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_fleet_growth.gd
# A new game opens with a SINGLE vessel and a single berth, and the fleet
# grows by building Fleet Offices - one berth each, each of which has to be
# kept supplied.
#
# Forgejo #219 replaced the fleet-size doctrine chain with this. Researching
# a permanent +1 asked nothing of the player afterwards; a berth that has to
# be fed makes fleet size a standing cost against their supply lines rather
# than a purchase they make once.
#
# BASE_FLEET_SIZE and STARTING_SHIP_COUNT remain separate constants - the
# first is capacity, the second is how many hulls are actually flying - and
# this test still pins the distinction.
#
# Covers:
# A. a new game starts with one vessel and one berth, and no research
# anywhere in the tree can raise that
# B. each supplied office adds exactly one berth, and the cap bites
# C. an office that cannot pay loses its vessel, and says so
# D. a restocked office goes back to work without being rebuilt
# E. capacity can never outrun the formation's defined positions
var game_state: Node
var scene: Node
var fleet: Node
var stage: int = 0
var elapsed: float = 0.0
var office_ids: Array = []
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.reset_new_game()
# #108: reset_new_game() arms the prologue, and the prologue opens with a
# convoy rather than the game's own starting fleet. This test is about
# what the GAME starts from, which is where the prologue hands over -
# see test_intro_scene.gd for the prologue itself. The quest chain goes
# with it: its briefings are dialogs, and a dialog stops the tree (#128).
game_state.intro_active = false
game_state.reset_quests()
func _stock(office_id: int, amount: int) -> void:
var inventory: RefCounted = game_state.building_inventory(office_id)
for resource_type in game_state.resource_types():
inventory.set_amount(String(resource_type), amount)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. the opening position ---------------------------------
if GameState.STARTING_SHIP_COUNT != 1:
return _fail("a new game starts with %d vessels, expected 1" % GameState.STARTING_SHIP_COUNT)
if GameState.BASE_FLEET_SIZE != 1:
return _fail("a new game opens with %d berths, expected the one that needs no office" \
% GameState.BASE_FLEET_SIZE)
if game_state.max_fleet_size() != 1:
return _fail("an unbuilt fleet caps at %d, expected 1" % game_state.max_fleet_size())
# The whole tree, researched, must not add a single berth: #219
# moved fleet size out of research entirely.
for node in GameState.SKILL_TREE:
if int(node.get("fleet_bonus", 0)) != 0:
return _fail("skill node '%s' still grants fleet capacity" % node.id)
game_state.unlocked.append(String(node.id))
if game_state.max_fleet_size() != 1:
return _fail("researching the entire tree raised the fleet cap to %d" \
% game_state.max_fleet_size())
print("a new game: 1 vessel, 1 berth, and no research anywhere raises it")
# --- B. offices are the berths -------------------------------
for i in range(3):
var id: int = game_state.add_building(GameState.BUILDING_FLEET_OFFICE,
Vector2(400.0 * float(i + 1), 0.0))
office_ids.append(id)
_stock(id, 500)
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE + i + 1:
return _fail("%d offices gave %d berths, expected %d" % [
i + 1, game_state.max_fleet_size(),
GameState.BASE_FLEET_SIZE + i + 1])
print("3 supplied offices -> %d berths" % game_state.max_fleet_size())
# --- E. and never more than the formation can seat ------------
if game_state.max_fleet_size() > GameState.DEFAULT_FORMATION.size():
return _fail("capacity %d exceeds the %d positions the formation defines" \
% [game_state.max_fleet_size(), GameState.DEFAULT_FORMATION.size()])
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("a fresh scene spawned %d vessels, expected %d" \
% [fleet.ships.size(), GameState.STARTING_SHIP_COUNT])
# The berths the offices bought are fillable, and the cap bites
# exactly one past them.
for i in range(3):
if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
return _fail("could not crew the berth bought by office %d" % (i + 1))
if fleet.ships.size() != 4:
return _fail("the fleet holds %d after filling its berths, expected 4" \
% fleet.ships.size())
if fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
return _fail("a 5th vessel joined a fleet with 4 berths")
print("the fleet fills to %d and refuses a 5th" % fleet.ships.size())
# --- C. an office that cannot pay loses its vessel ------------
# Emptied and its clock wound past due, so the next tick bills it.
_stock(office_ids[2], 0)
var starved: Dictionary = game_state.find_building(int(office_ids[2]))
starved.upkeep_progress = GameState.FLEET_OFFICE_UPKEEP_SECONDS
game_state.take_quest_messages()
var before: int = fleet.ships.size()
game_state._advance_fleet_office_upkeep(0.1)
if not bool(starved.get("starved", false)):
return _fail("an office with an empty store kept paying")
if game_state.max_fleet_size() != 3:
return _fail("a starved office still counts: %d berths" % game_state.max_fleet_size())
if game_state.take_quest_messages().is_empty():
return _fail("the fleet lost a vessel to supply and nobody was told")
elapsed = 0.0
_lost_before = before
stage = 2
return false
2:
if elapsed < 0.3:
return false
if fleet.ships.size() != _lost_before - 1:
return _fail("a starved office left %d vessels flying, expected %d" \
% [fleet.ships.size(), _lost_before - 1])
print("a dry office destroyed its vessel and warned the player")
# --- D. the berth comes back, and a supplied office keeps it -
# The office is idle now that its vessel is gone, so the berth is
# offered again on the next tick - an office that could never be
# crewed again could never pay again either.
var revived: Dictionary = game_state.find_building(int(office_ids[2]))
game_state._advance_fleet_office_upkeep(0.1)
if bool(revived.get("starved", false)):
return _fail("an office with an empty berth stayed disabled")
if game_state.max_fleet_size() != 4:
return _fail("an emptied office did not offer its berth again")
# Restocked and re-crewed, it pays and the vessel stays.
_stock(office_ids[2], 500)
if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
return _fail("could not re-crew the berth after restocking")
revived.upkeep_progress = GameState.FLEET_OFFICE_UPKEEP_SECONDS
game_state._advance_fleet_office_upkeep(0.1)
if bool(revived.get("starved", false)):
return _fail("a restocked office starved with a full store")
if fleet.ships.size() != 4:
return _fail("a supplied office lost its vessel anyway")
print("restocked and re-crewed: the office pays and the vessel stays")
print("TEST_FLEET_GROWTH: PASS")
quit(0)
return true
return false
var _lost_before: int = 0
func _fail(message: String) -> bool:
print("TEST_FLEET_GROWTH: FAIL (%s)" % message)
quit(1)
return true