max_fleet_size() clamped itself to DEFAULT_FORMATION.size(), so the ceiling on the fleet was a property of the LAYOUT - the wedge ran out of authored positions at twenty - rather than a rule anyone decided on. Capacity is the supplied Fleet Offices and nothing else now. Dropping the clamp alone would index off the end of the formation array, so the wedge continues by rule instead of stopping: formation_slot() grows it on demand, each further pair stepping 30 further out and 40 further back, which is what the authored rows already do - the first twenty slots come back unchanged. A grown slot is rotated by formation_rotation so a fleet that outgrows the array after the player has pivoted does not sprout hulls facing the original heading, and that angle is saved with the layout. Two "fleet is full" messages pointed at the vanished ceiling. Both now say what actually buys a berth, and the hangar's stops claiming it is research, which it has not been since #219. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
254 lines
11 KiB
GDScript
254 lines
11 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. the wedge has a place for every slot, however far out it is
|
|
# F. and nothing caps the fleet at the twenty positions the wedge authors
|
|
|
|
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 gate on the office ----------------------------------
|
|
# Fleet Offices are researched (Berth Authority), but the research
|
|
# only unlocks the STRUCTURE - it grants no capacity by itself.
|
|
if game_state.is_building_unlocked(GameState.BUILDING_FLEET_OFFICE):
|
|
return _fail("Fleet Offices are buildable with nothing researched")
|
|
var gate: Dictionary = game_state.skill_node("berth_authority")
|
|
if gate.is_empty():
|
|
return _fail("no skill node unlocks the Fleet Office")
|
|
# Ore only, deliberately: research points come solely from
|
|
# conquering a planet, and conquering one needs a fleet. Pricing
|
|
# the fleet's own gate in them would lock the player at their
|
|
# opening hull behind a fight that hull has to win first.
|
|
if int(gate.get("research_points", 0)) != 0:
|
|
return _fail("Berth Authority costs %d research points - unreachable with one vessel" \
|
|
% int(gate.get("research_points", 0)))
|
|
for cost_resource in game_state.skill_costs("berth_authority"):
|
|
if String(cost_resource) != GameState.RESOURCE_ORE:
|
|
return _fail("Berth Authority asks for %s, which needs a built economy first" \
|
|
% cost_resource)
|
|
|
|
# The whole tree, researched, must still not add a single berth:
|
|
# #219 moved fleet SIZE out of research and onto the structure.
|
|
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 not game_state.is_building_unlocked(GameState.BUILDING_FLEET_OFFICE):
|
|
return _fail("researching Berth Authority did not unlock the Fleet Office")
|
|
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; research unlocks the office, not the berth")
|
|
|
|
# --- 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. the wedge has a place for every slot ------------------
|
|
# The formation used to be the fleet's real ceiling: it defined
|
|
# twenty positions and capacity clamped itself to them. It now
|
|
# continues by rule instead, so what is pinned here is that the
|
|
# rule keeps the authored shape - the far slots are distinct,
|
|
# and each pair still steps 30 out and 40 back from the last.
|
|
var authored: int = GameState.DEFAULT_FORMATION.size()
|
|
var probe: int = authored * 3
|
|
var seen: Dictionary = {}
|
|
for i in range(probe):
|
|
var slot: Vector2 = game_state.formation_slot(i)
|
|
var key: String = "%.1f,%.1f" % [slot.x, slot.y]
|
|
if seen.has(key):
|
|
return _fail("slot %d sits on top of slot %d at %s" % [i, int(seen[key]), key])
|
|
seen[key] = i
|
|
for i in range(authored):
|
|
if not game_state.formation_slot(i).is_equal_approx(GameState.DEFAULT_FORMATION[i]):
|
|
return _fail("growing the wedge moved authored slot %d to %s" \
|
|
% [i, game_state.formation_slot(i)])
|
|
for i in range(authored, probe - 2):
|
|
var here: Vector2 = game_state.formation_slot(i)
|
|
var behind: Vector2 = game_state.formation_slot(i + 2)
|
|
var step: Vector2 = Vector2(GameState.FORMATION_PAIR_STEP.x * signf(here.x),
|
|
GameState.FORMATION_PAIR_STEP.y)
|
|
if not (behind - here).is_equal_approx(step):
|
|
return _fail("slot %d steps %s from slot %d, expected %s" \
|
|
% [i + 2, behind - here, i, step])
|
|
print("the wedge continues past its %d authored slots, %d checked and all distinct" \
|
|
% [authored, probe])
|
|
|
|
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")
|
|
|
|
# --- F. and nothing caps the fleet at twenty ------------------
|
|
# Offices past the wedge's authored positions buy real berths,
|
|
# and the fleet really flies them: every hull gets a place of
|
|
# its own rather than piling onto the last defined slot.
|
|
var target: int = GameState.DEFAULT_FORMATION.size() + 4
|
|
while game_state.max_fleet_size() < target:
|
|
var extra: int = game_state.add_building(GameState.BUILDING_FLEET_OFFICE,
|
|
Vector2(600.0 * float(game_state.max_fleet_size()), 900.0))
|
|
_stock(extra, 500)
|
|
if game_state.max_fleet_size() != target:
|
|
return _fail("%d supplied offices gave %d berths, expected %d" \
|
|
% [game_state.fleet_offices().size(), game_state.max_fleet_size(), target])
|
|
while fleet.ships.size() < target:
|
|
if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
|
|
return _fail("berth %d could not be crewed" % (fleet.ships.size() + 1))
|
|
var places: Dictionary = {}
|
|
for i in range(fleet.ships.size()):
|
|
var offset: Vector2 = fleet.formation_offsets[i]
|
|
var place: String = "%.1f,%.1f" % [offset.x, offset.y]
|
|
if places.has(place):
|
|
return _fail("vessel %d flies the same place as vessel %d" \
|
|
% [i, int(places[place])])
|
|
places[place] = i
|
|
print("%d berths, %d vessels, %d distinct places - no ceiling at %d" \
|
|
% [target, fleet.ships.size(), places.size(), GameState.DEFAULT_FORMATION.size()])
|
|
|
|
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
|