Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
6.1 KiB
GDScript
139 lines
6.1 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()
|
|
|
|
|
|
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()
|
|
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
|