Landed as one piece across #122, #123 and #124: the frame is useless without a script, and the script has nowhere to run without the frame. The director drives the REAL scene rather than being a scene of its own. It wants the real fleet, the real enemies and the real asteroid field, and it makes "then the game starts" cost nothing - the director stops steering, clears the flag, and what is left running is the ordinary game. The script is a list of beats, each with a deadline as well as its own completion test, so a beat whose condition is never met - a player who refuses to fly at the ambush - runs out rather than wedging the prologue. skip() ends the whole thing at any point and lands in exactly the state playing it through does, so the game has one way to start rather than two that have to be kept in step. The test pins that equivalence. "No action button available, only movements" is enforced where the buttons are decided, not by the director reaching in: the ability controllers set their own visibility every frame and would have put them straight back, and the map button belongs to the map. The joystick, the pivot and the fader stay. The convoy is ten hulls where a new game's fleet is one, so it needs an explicit fleet-size override - a plain number rather than a fake doctrine, because nothing about the player's own progression should be bent to fit a cutscene. It is carriers rather than transporters: transporters belong to hubs and are not fleet-eligible, so a convoy of them would be silently filtered down to its escorts. The ambush is chasers and only chasers. "Easy enough" is the requirement, and a sniper against a convoy the player cannot yet fight properly is neither easy nor a good first impression. The breach takes the wheel through one check in GameController, covering the stick, the keyboard and the released-again path together - three separate gates would be three chances for one of them to keep steering. The shake is written to camera.offset, never to its position, because GameController owns where the camera is and would smooth a shake away. The vessel that survives is chosen as the interceptor rather than whichever happened to be first in the list, and the prologue ends on exactly a new game's opening position. Also: PlanetAtmosphereController no longer spawns the opening fleet while the prologue is arming, and the tests that are about what the GAME starts from now say so by turning the prologue off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
6.5 KiB
GDScript
147 lines
6.5 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 three slots, and the fleet grows
|
|
# from there through the doctrine branch of the skill tree.
|
|
#
|
|
# BASE_FLEET_SIZE used to be both "how many slots exist" and "how many ships
|
|
# you start with", which forced a new game to open with a full fleet - there
|
|
# was nowhere to put a vessel until a doctrine was researched, so the first
|
|
# ship crafter had nothing to do. The two are now separate constants, and
|
|
# this test pins the distinction.
|
|
#
|
|
# Covers:
|
|
# A. a new game starts with one vessel and three slots
|
|
# B. the fleet really spawns one ship, and can be grown into its slots
|
|
# C. every doctrine raises the ceiling by its own stated bonus
|
|
# D. the doctrine branch is a chain, in increasing cost order
|
|
# E. the tree cannot promise a slot the formation has no room for - the
|
|
# invariant that made extra doctrines silently worthless
|
|
|
|
const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup", "grand_fleet"]
|
|
|
|
var game_state: Node
|
|
var scene: Node
|
|
var fleet: Node
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
|
|
|
|
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.
|
|
game_state.intro_active = false
|
|
|
|
|
|
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 != 3:
|
|
return _fail("the fleet has %d slots by default, expected 3" % GameState.BASE_FLEET_SIZE)
|
|
if game_state.surviving_ship_count != GameState.STARTING_SHIP_COUNT:
|
|
return _fail("reset_new_game left %d surviving ships" % game_state.surviving_ship_count)
|
|
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
|
|
return _fail("an unresearched fleet caps at %d, expected %d" \
|
|
% [game_state.max_fleet_size(), GameState.BASE_FLEET_SIZE])
|
|
print("new game: %d vessel, %d slots" % [GameState.STARTING_SHIP_COUNT, GameState.BASE_FLEET_SIZE])
|
|
|
|
# --- C + D. the doctrine chain -------------------------------
|
|
var expected: int = GameState.BASE_FLEET_SIZE
|
|
var previous_cost: int = -1
|
|
var previous_points: int = -1
|
|
var previous_id: String = "coordination"
|
|
for id in DOCTRINE_CHAIN:
|
|
var node: Dictionary = game_state.skill_node(id)
|
|
if node.is_empty():
|
|
return _fail("no skill node '%s' - the doctrine chain is broken" % id)
|
|
var bonus: int = int(node.get("fleet_bonus", 0))
|
|
if bonus <= 0:
|
|
return _fail("'%s' is in the doctrine chain but grants no fleet_bonus" % id)
|
|
if String(node.get("requires", "")) != previous_id:
|
|
return _fail("'%s' requires '%s', expected it to follow '%s' - the branch is not one chain" \
|
|
% [id, node.get("requires", ""), previous_id])
|
|
# Later tiers must not be cheaper than earlier ones, or the
|
|
# order the player meets them stops matching what they cost.
|
|
var cost: int = int(node.get("cost", 0))
|
|
var points: int = int(node.get("research_points", 0))
|
|
if cost <= previous_cost:
|
|
return _fail("'%s' costs %d ore, not more than the tier before it (%d)" % [id, cost, previous_cost])
|
|
if points < previous_points:
|
|
return _fail("'%s' costs %d research points, fewer than the tier before it (%d)" \
|
|
% [id, points, previous_points])
|
|
previous_cost = cost
|
|
previous_points = points
|
|
previous_id = id
|
|
|
|
game_state.unlocked.append(id)
|
|
expected += bonus
|
|
if game_state.max_fleet_size() != expected:
|
|
return _fail("after researching '%s' the fleet caps at %d, expected %d" \
|
|
% [id, game_state.max_fleet_size(), expected])
|
|
print(" %-12s +%d -> %2d slots (%d ore, %d research)" % [id, bonus, expected, cost, points])
|
|
|
|
# --- E. no doctrine may be swallowed by the formation ---------
|
|
# max_fleet_size() clamps to the formation's length, so a doctrine
|
|
# added without extending DEFAULT_FORMATION would cost the player
|
|
# real resources and grant nothing.
|
|
if expected > GameState.DEFAULT_FORMATION.size():
|
|
return _fail("the doctrines promise %d slots but DEFAULT_FORMATION only defines %d - the last ones buy nothing" \
|
|
% [expected, GameState.DEFAULT_FORMATION.size()])
|
|
print("full doctrine chain reaches %d slots, with %d formation positions defined" \
|
|
% [expected, GameState.DEFAULT_FORMATION.size()])
|
|
|
|
# --- B. what actually flies ----------------------------------
|
|
game_state.reset_new_game()
|
|
# ... and again here: this is the reset the scene below is built
|
|
# from, so the prologue has to be off for THIS one too.
|
|
game_state.intro_active = false
|
|
scene = load("res://world/Main.tscn").instantiate()
|
|
root.add_child(scene)
|
|
fleet = scene.get_node("Fleet")
|
|
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)
|
|
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])
|
|
print("a fresh game really flies %d vessel" % fleet.ships.size())
|
|
|
|
# The two empty slots are what the opening is for, so they have to
|
|
# be fillable without researching anything.
|
|
for i in range(GameState.BASE_FLEET_SIZE - GameState.STARTING_SHIP_COUNT):
|
|
if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
|
|
return _fail("could not crew slot %d of the starting fleet" % (i + 2))
|
|
if fleet.ships.size() != GameState.BASE_FLEET_SIZE:
|
|
return _fail("the fleet holds %d after filling its slots, expected %d" \
|
|
% [fleet.ships.size(), GameState.BASE_FLEET_SIZE])
|
|
# And the cap has to actually bite past that.
|
|
if fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
|
|
return _fail("a %dth vessel joined an unresearched fleet of %d slots" \
|
|
% [fleet.ships.size(), GameState.BASE_FLEET_SIZE])
|
|
print("the starting slots fill to %d and refuse a %dth" \
|
|
% [GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 1])
|
|
|
|
print("TEST_FLEET_GROWTH: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
print("TEST_FLEET_GROWTH: FAIL (%s)" % message)
|
|
quit(1)
|
|
return true
|