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
This commit is contained in:
Loïc Wisniewski 2026-09-05 17:09:27 +02:00
commit 1f5fecd371
60 changed files with 695 additions and 243 deletions

View file

@ -1,7 +1,7 @@
class_name BuildingDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "planet_scanner", "extractor", "transport_hub", "ship_lab", "gas_burner"]
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "planet_scanner", "extractor", "transport_hub", "ship_lab", "gas_burner", "fleet_office"]
const DEFINITIONS: Dictionary = {
"mothership": preload("res://buildings/MothershipBuildingDefinition.tres"),
"ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"),
@ -14,6 +14,7 @@ const DEFINITIONS: Dictionary = {
"transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"),
"ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"),
"gas_burner": preload("res://buildings/GasBurnerBuildingDefinition.tres"),
"fleet_office": preload("res://buildings/FleetOfficeBuildingDefinition.tres"),
}
static func get_definition(definition_id: String) -> Resource:

View file

@ -35,6 +35,9 @@ const PLANNING_COLOR: Color = Color(0.62, 0.74, 0.92)
# #116. Warm steel: the workshop is the only structure whose whole job is
# looking after the others, and it should read as a yard rather than a plant.
const WORKSHOP_COLOR: Color = Color(0.5, 0.95, 0.8)
# #219: the fleet office. A cool administrative blue, deliberately unlike
# the crafter's amber - one builds hulls, this one pays for them.
const FLEET_OFFICE_COLOR: Color = Color(0.45, 0.78, 1.0)
# Workshop: an open service gantry with a cradle under it and drone berths
# either side. Deliberately unenclosed - what it holds is other people's
@ -434,6 +437,37 @@ func _draw_command_center() -> void:
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
func _draw_fleet_office() -> void:
# A berth, and the ledger that keeps it. The frame is the berth - an open
# bay with a hull sitting in it - and the stack of bars beside it is the
# supply the office is spending. Read together they say "this structure
# holds a place in the fleet, and it costs something".
var c: Color = FLEET_OFFICE_COLOR
var lit: Color = Color(0.88, 0.97, 1.0)
# The berth: three sides, open at the top, so what sits in it reads as
# parked rather than enclosed.
NeonDraw.draw_glowing_path(self, PackedVector2Array([
Vector2(-52.0, -46.0), Vector2(-52.0, 34.0),
Vector2(18.0, 34.0), Vector2(18.0, -46.0),
]), c, 0.95, 0.5)
# The hull in it, small and lit: the vessel this office is paying for.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
Vector2(-17.0, -22.0), Vector2(-3.0, 14.0),
Vector2(-17.0, 6.0), Vector2(-31.0, 14.0),
]), lit, 0.9)
# The ledger: four bars of decreasing length, the running cost.
for i in range(4):
var y: float = -34.0 + float(i) * 20.0
var length: float = 44.0 - float(i) * 7.0
NeonDraw.draw_glowing_line(self, Vector2(34.0, y),
Vector2(34.0 + length, y), c, 0.8 - float(i) * 0.12, 0.32)
# The spine they hang off, tying the ledger to the berth.
NeonDraw.draw_glowing_line(self, Vector2(30.0, -42.0),
Vector2(30.0, 34.0), c, 0.7, 0.3)
func _draw_workshop() -> void:
var c: Color = WORKSHOP_COLOR
var lit: Color = Color(0.86, 1.0, 0.95)

View file

@ -0,0 +1,39 @@
[gd_resource type="Resource" script_class="BuildingDefinition" format=3]
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://buildings/FleetOfficeBuildingVisual.tscn" id="2"]
[ext_resource type="Script" path="res://buildings/BuildingLogisticsProfile.gd" id="3"]
[sub_resource type="Resource" id="Resource_office_logistics"]
script = ExtResource("3")
input_buffers = {
"energy": 30,
"gas": 30,
"ore": 60
}
collected_outputs = PackedStringArray()
conversion_input = ""
conversion_output = ""
conversion_rate = 0.0
conversion_yield = 1
[resource]
script = ExtResource("1")
definition_id = "fleet_office"
display_name = "Fleet Office"
description = "Holds one berth in the fleet and pays for the vessel in it. Hub transporters keep it stocked; an office that runs dry loses its vessel."
cost = 35
build_resource_costs = {
"ore": 35
}
action_icon = 2
menu_icon = 2
visual_scene = ExtResource("2")
capabilities = PackedStringArray("fleet_office")
logistics = SubResource("Resource_office_logistics")
map_color = Color(0.45, 0.78, 1, 1)
map_sides = 4
menu_button_name = "FleetOfficeButton"
menu_label_name = "FleetOfficeLabel"
menu_cost_name = "FleetOfficeCost"
max_hp = 45

View file

@ -0,0 +1,12 @@
extends "res://buildings/BuildingVisualBase.gd"
# Drawn once, like the command centre and the workshop: nothing on this
# structure moves, so there is nothing for #117's idle gate to stop.
func _ready() -> void:
set_process(false)
queue_redraw()
func _draw() -> void:
register_draw()
_draw_fleet_office()

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/FleetOfficeBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("transfer")

View file

@ -0,0 +1 @@
uid://df2ahttimnr5k

View file

@ -472,6 +472,8 @@ const BUILDING_SHIP_LAB: String = "ship_lab"
# Converts gas into energy, both stored/moved through the building's own
# inventory exactly like every other building (#54).
const BUILDING_GAS_BURNER: String = "gas_burner"
# #219: the structure that pays for a berth in the fleet.
const BUILDING_FLEET_OFFICE: String = "fleet_office"
# A map-revealing structure, not a passive sensor bonus. Its menu searches
# the same deterministic WorldGen catalogue that PlanetField spawns from.
const BUILDING_PLANET_SCANNER: String = "planet_scanner"
@ -597,89 +599,18 @@ const SKILL_TREE: Array = [
"x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2,
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
},
{
# Cheaper than it was (30 ore / 1 point): the fleet now starts at
# BASE_FLEET_SIZE 3 rather than 5, so the first two doctrines are no
# longer a mid-game luxury but the ordinary way out of the opening.
# Pricing them as a luxury made the early game feel stuck.
"id": "wing", "name": "Wing Doctrine", "cost": 15, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
"icon": 2,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
# #70: was x=0.50 - the same lane freight's branch already occupies at
# this same depth (power_systems), so the two nodes were drawn
# directly on top of each other. Kept in line with wing (its own
# prerequisite, also x=0.82) instead, so the whole fleet-size
# doctrine branch now runs straight rather than zigzagging into
# someone else's lane.
# Also cut (70 ore / 2 points), for the same reason as wing.
"id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing",
"costs": {"ore": 35, "gas": 12},
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
"icon": 2,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron",
"costs": {"ore": 90, "gas": 30, "energy": 15},
"x": 0.82, "fleet_bonus": 1, "research_points": 2,
"icon": 2,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
# The last two tiers grant +2 rather than +1. Seven single-vessel
# nodes in one lane would be a long, monotonous chain for a branch
# that only ever does one thing; two larger, costlier steps carry the
# ceiling from 6 to 10 while keeping the branch a readable length.
"id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada",
"costs": {"ore": 180, "gas": 60, "energy": 40},
"x": 0.82, "fleet_bonus": 2, "research_points": 4,
"icon": 2,
"desc": "Permanent: +2 vessels in the fleet.",
},
{
"id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup",
"costs": {"ore": 320, "gas": 110, "energy": 80, "dark_matter": 20},
"x": 0.82, "fleet_bonus": 2, "research_points": 6,
"icon": 2,
"desc": "Permanent: +2 vessels in the fleet.",
},
{
# #206 carries the branch on to twenty. The step size keeps growing
# for the reason the +2 tiers were introduced: ten more single-vessel
# nodes would be a very long chain for a lane that only ever does one
# thing. These three cost late-game money and are meant to be the
# thing a finished economy is finally FOR.
"id": "task_force", "name": "Task Force Doctrine", "cost": 520, "requires": "grand_fleet",
"costs": {"ore": 520, "gas": 180, "energy": 130, "dark_matter": 35},
"x": 0.82, "fleet_bonus": 3, "research_points": 8,
"icon": 2,
"desc": "Permanent: +3 vessels in the fleet.",
},
{
"id": "star_legion", "name": "Star Legion Doctrine", "cost": 820, "requires": "task_force",
"costs": {"ore": 820, "gas": 280, "energy": 200, "dark_matter": 60},
"x": 0.82, "fleet_bonus": 3, "research_points": 10,
"icon": 2,
"desc": "Permanent: +3 vessels in the fleet.",
},
{
"id": "void_armada", "name": "Void Armada Doctrine", "cost": 1300, "requires": "star_legion",
"costs": {"ore": 1300, "gas": 440, "energy": 320, "dark_matter": 100},
"x": 0.82, "fleet_bonus": 4, "research_points": 14,
"icon": 2,
"desc": "Permanent: +4 vessels in the fleet. The fleet reaches twenty.",
},
]
# How many SLOTS the fleet has before any doctrine is researched. Distinct
# from STARTING_SHIP_COUNT below: this is capacity, that is how many vessels
# are actually flying. The two used to be one constant, which forced a new
# game to open with a full fleet and left the first crafter with nothing to
# do - there was no room to put a vessel until a doctrine was researched.
const BASE_FLEET_SIZE: int = 3
# How many SLOTS the fleet has before a single Fleet Office is built.
#
# #219 replaced the fleet-size doctrines with a structure that has to be
# supplied: every berth past the first is a Fleet Office standing somewhere,
# paying for the vessel in it. So this is 1 - the hull the survivors came out
# of the breach with, which the ticket exempts from needing an office at all.
#
# Distinct from STARTING_SHIP_COUNT below: this is capacity, that is how many
# vessels are actually flying.
const BASE_FLEET_SIZE: int = 1
# A new game opens with a SINGLE vessel. Building the fleet up is the early
# game, so the two empty slots are the first thing a ship crafter is for.
const STARTING_SHIP_COUNT: int = 1
@ -812,13 +743,109 @@ func research_for_building(type: String) -> String:
return "" if id == "" else String(skill_node(id).name)
# #219: every berth past the first is a Fleet Office standing somewhere, and
# an office only counts while it can still pay for the vessel in it. A starved
# one has already had its vessel destroyed; leaving it counting would let the
# player rebuild straight into a berth nothing is supplying.
signal fleet_upkeep_failed(slot_index: int, vessel_type: String)
# How often an office is charged, and how much of the vessel's own build price
# it charges. Derived from the build cost rather than authored per vessel: a
# hull that was expensive to make is expensive to keep, which is the rule the
# ticket asks for and needs no second table to fall out of step with.
const FLEET_OFFICE_UPKEEP_SECONDS: float = 25.0
const FLEET_OFFICE_UPKEEP_DIVISOR: int = 8
# Offices in the order they were built, which is the order they are matched to
# the fleet: the first vessel needs none, the second is kept by the first
# office, and so on.
func fleet_offices() -> Array:
var offices: Array = []
for building in buildings:
if String(building.get("type", "")) == BUILDING_FLEET_OFFICE:
offices.append(building)
return offices
func supplied_fleet_office_count() -> int:
var n: int = 0
for office in fleet_offices():
if not bool(office.get("starved", false)):
n += 1
return n
# What one berth costs per charge, for the vessel actually sitting in it. An
# empty answer means this vessel is free to keep - which is true of nothing
# the crafter builds, and is what an unknown type falls back to.
func fleet_office_upkeep(vessel_type: String) -> Dictionary:
var upkeep: Dictionary = {}
for resource_type in product_costs(vessel_type):
var share: int = int(ceil(float(product_costs(vessel_type)[resource_type])
/ float(FLEET_OFFICE_UPKEEP_DIVISOR)))
if share > 0:
upkeep[resource_type] = share
return upkeep
# The vessel an office is answerable for, or "" when the fleet is smaller than
# the berths built for it - an office with nothing in it costs nothing.
func fleet_office_vessel(slot_index: int) -> String:
if slot_index < 0 or slot_index >= fleet_vessel_types.size():
return ""
return String(fleet_vessel_types[slot_index])
func _advance_fleet_office_upkeep(delta: float) -> void:
var offices: Array = fleet_offices()
for i in range(offices.size()):
var office: Dictionary = offices[i]
# Office i keeps the (i + 1)th vessel: the first one needs no office.
var vessel_type: String = fleet_office_vessel(i + BASE_FLEET_SIZE)
if vessel_type == "":
# Nothing to keep. The clock is not advanced either, so a berth
# that stood empty does not present a bill the moment it is used.
#
# An empty berth is IDLE, not starved, including one that just
# lost its vessel: an office is only failing while there is
# something in it to fail. Holding it disabled would leave the
# player unable to use the berth they paid for, with no way back
# - it can never pay for a vessel it is not allowed to hold.
office.upkeep_progress = 0.0
office.starved = false
continue
var progress: float = float(office.get("upkeep_progress", 0.0)) + delta
if progress < FLEET_OFFICE_UPKEEP_SECONDS:
office.upkeep_progress = progress
continue
office.upkeep_progress = progress - FLEET_OFFICE_UPKEEP_SECONDS
var upkeep: Dictionary = fleet_office_upkeep(vessel_type)
var inventory: RefCounted = _inventory_for_record(office)
var affordable: bool = true
for resource_type in upkeep:
if inventory.amount(String(resource_type)) < int(upkeep[resource_type]):
affordable = false
break
if affordable:
for resource_type in upkeep:
inventory.withdraw(String(resource_type), int(upkeep[resource_type]))
# A restocked office goes back to work on its own: the player
# fixing their supply line should not also have to rebuild it.
office.starved = false
continue
office.starved = true
say_quest_line(("A Fleet Office has run dry. The %s berthed there is gone - "
+ "nothing is reaching it, and a berth nobody supplies keeps nobody.")
% vessel_name(vessel_type))
fleet_upkeep_failed.emit(i + BASE_FLEET_SIZE, vessel_type)
func max_fleet_size() -> int:
if intro_fleet_size > 0:
return mini(intro_fleet_size, DEFAULT_FORMATION.size())
var n: int = BASE_FLEET_SIZE
for id in unlocked:
n += int(skill_node(id).get("fleet_bonus", 0))
return mini(n, DEFAULT_FORMATION.size())
return mini(BASE_FLEET_SIZE + supplied_fleet_office_count(),
DEFAULT_FORMATION.size())
func reset_progression() -> void:
@ -3071,6 +3098,7 @@ func _process(delta: float) -> void:
_advance_planning()
_advance_repairs(delta)
_advance_resource_converters(delta)
_advance_fleet_office_upkeep(delta)
_advance_distorder_drain(delta, _distorder_boost_in_use())
_advance_quests()
for e in extractors:

21
tests/_berths.gd Normal file
View file

@ -0,0 +1,21 @@
extends RefCounted
# #219 made fleet capacity a BUILT thing: one berth free, one more per Fleet
# Office standing. A test that flies four vessels therefore needs three
# offices, where before it unlocked three doctrines.
#
# Loaded with load() at runtime rather than preloaded at the top of a test -
# these are script-mode entry points, and a top-level preload evaluates during
# their own early compilation, which can happen before the autoloads exist.
#
# The offices are put a long way from the origin so they cannot wander into
# whatever the test is actually looking at, and left unstocked: upkeep is not
# charged until FLEET_OFFICE_UPKEEP_SECONDS has passed, and no test here runs
# that long.
static func grant(game_state: Node, vessels: int) -> void:
var needed: int = maxi(vessels - int(game_state.BASE_FLEET_SIZE), 0)
for i in range(needed):
game_state.add_building(String(game_state.BUILDING_FLEET_OFFICE),
Vector2(40000.0 + 500.0 * float(i), 40000.0))

View file

@ -12,7 +12,8 @@ func _ready() -> void:
GameState.vessel_levels["unlock_laser"] = 1
GameState.vessel_levels["unlock_gas_bomber"] = 1
GameState.vessel_levels["unlock_distorder"] = 1
GameState.unlocked.append("wing")
# #219: a berth is an office now, not a doctrine.
GameState.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(2400.0, 0.0))
GameState.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 200)
GameState.deposit_fleet_resource(GameState.RESOURCE_GAS, 200)
var main: Node = load("res://world/Main.tscn").instantiate()

View file

@ -0,0 +1 @@
uid://bwusdnid203ma

View file

@ -0,0 +1 @@
uid://cvpu8gyb5757i

View file

@ -20,6 +20,10 @@ func _initialize() -> void:
state.fleet_inventory.total_capacity = -1
state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500)
state.deposit_fleet_resource(GameState.RESOURCE_GAS, 500)
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main

View file

@ -0,0 +1 @@
uid://cwxeg5i0betsn

View file

@ -47,6 +47,10 @@ func _initialize() -> void:
game_state.reset_progression()
game_state.intro_active = false
game_state.reset_quests()
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main

View file

@ -52,6 +52,10 @@ func _initialize() -> void:
game_state.build_time_scale = 0.02
game_state.reset_progression()
game_state.rocks = 2000
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -9,7 +9,8 @@ extends SceneTree
# holes where they used to be - it is repacked from whatever is researched.
#
# Verifies:
# 1. with nothing researched only the mothership is offered (it is
# 1. with nothing researched only the mothership and the Fleet Office are
# offered (the mothership is
# deliberately never gated - it is where research happens)
# 2. researching a structure adds it, and it is placed, not left at
# whatever offsets it happened to have in the scene file
@ -79,21 +80,36 @@ func _process(delta: float) -> bool:
# --- 1. nothing researched ----------------------------------
controller._refresh_build_status()
# #219 put the Fleet Office beside the mothership at the opening:
# it is the only way the fleet grows now, so gating it behind
# research would gate fleet size behind research again - which is
# the thing that ticket removed.
var opening: Array = [GameState.BUILDING_MOTHERSHIP,
GameState.BUILDING_FLEET_OFFICE]
var shown: Array = _shown()
if shown.size() != 1 or shown[0][0] != GameState.BUILDING_MOTHERSHIP:
var names: Array = []
for e in shown:
names.append(e[0])
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (unresearched structures on offer: %s)" % [names])
var shown_ids: Array = []
for e in shown:
shown_ids.append(e[0])
shown_ids.sort()
opening.sort()
if shown_ids != opening:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (opening offer was %s, expected %s)" \
% [shown_ids, opening])
quit(1)
return true
# --- 4. and it is centred, being alone on its row -----------
var only: Array = _slots()[0]
if absf(only[1]) > 0.01:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the only entry sits at x=%.1f instead of centred)" % only[1])
# --- 4. and the row is centred as a whole -------------------
# Two entries now, so "centred" is the pair straddling the middle
# rather than one sitting on it. The rule being checked is the
# same one: a short row is not left hanging off to one side.
var slots: Array = _slots()
var spread: float = 0.0
for slot in slots:
spread += float(slot[1])
if absf(spread) > 0.01:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the opening row is off-centre by %.1f)" % spread)
quit(1)
return true
print("nothing researched -> only the mothership, centred")
print("nothing researched -> the mothership and a Fleet Office, centred")
# --- 2. research three more ---------------------------------
game_state.unlocked = ["shipyards", "logistics", "coordination"]
@ -101,9 +117,11 @@ func _process(delta: float) -> bool:
stage = 1
return false
1:
# Three doctrines add three structures to the two the opening
# already offers (#219's Fleet Office is one of those two).
var shown2: Array = _shown()
if shown2.size() != 4:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched 3, expected 4 entries, got %d)" % shown2.size())
if shown2.size() != 5:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched 3, expected 5 entries, got %d)" % shown2.size())
quit(1)
return true
for e in shown2:
@ -117,23 +135,30 @@ func _process(delta: float) -> bool:
var columns: int = controller.menu_columns(slots.size())
var rows: int = controller.menu_rows(slots.size())
if columns != 2:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (4 entries laid out in %d columns)" % columns)
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%d entries laid out in %d columns)" \
% [slots.size(), columns])
quit(1)
return true
for i in range(slots.size()):
var want_row: float = controller.menu_row_top(i / columns, rows, columns)
var row: int = i / columns
var want_row: float = controller.menu_row_top(row, rows, columns)
if absf(slots[i][0] - want_row) > 0.01:
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / columns, want_row])
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], row, want_row])
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)")
quit(1)
return true
# Each ROW is centred on however many it holds, so a final row
# with one entry sits in the middle rather than in column 0.
# Written the way the layout writes it, so an odd count is not
# a special case here that is ordinary there.
var in_row: int = mini(slots.size() - row * columns, columns)
var spacing: float = controller.menu_column_spacing(columns)
var want_x: float = -spacing * 0.5 if i % 2 == 0 else spacing * 0.5
var want_x: float = (float(i % columns) - (float(in_row) - 1.0) * 0.5) * spacing
if absf(slots[i][1] - want_x) > 0.01:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x])
quit(1)
return true
print("4 entries packed into consecutive slots, no gaps")
print("%d entries packed into consecutive slots, no gaps" % slots.size())
# --- 6. #136: nothing overlaps, with EVERY structure on offer -
# The bug the ticket reported: the row list ran out and the last

View file

@ -51,7 +51,7 @@ func _initialize() -> void:
# Real build times are 10s+; the timed path is what matters here, not the
# wall clock. See GameState.build_time_scale.
game_state.build_time_scale = 0.02
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
game_state.unlocked = ["shipyards", "coordination"]
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -49,6 +49,10 @@ func _initialize() -> void:
game_state.build_time_scale = 0.02
game_state.rocks = 500
game_state.research_points = 1
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -47,7 +47,7 @@ func _initialize() -> void:
# real-tap checks below. A full fleet correctly DISABLES it, and a
# disabled button not reacting to a tap is not the bug those checks are
# looking for.
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
game_state.unlocked = ["shipyards", "coordination"]
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.stored_vessels.clear()

View file

@ -47,6 +47,10 @@ func _initialize() -> void:
game_state.reset_formation()
game_state.intro_active = false
game_state.vessel_levels["unlock_distorder"] = 1
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene

View file

@ -45,8 +45,18 @@ func _process(delta: float) -> bool:
for skill in GameState.SKILL_TREE:
if not state.is_unlocked(String(skill.id)):
return _fail("unlock-all missed %s" % skill.id)
# #219: capacity is Fleet Offices, not research, so unlocking the
# whole tree must NOT move it. The sandbox tool is about doctrines.
if state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
return _fail("unlock-all moved the fleet cap to %d - capacity is not research any more" \
% state.max_fleet_size())
# The berths the rest of this test needs come from offices.
for berth in range(GameState.DEFAULT_FORMATION.size() - GameState.BASE_FLEET_SIZE):
state.add_building(GameState.BUILDING_FLEET_OFFICE,
Vector2(3000.0 + 300.0 * float(berth), 3000.0))
if state.max_fleet_size() != GameState.DEFAULT_FORMATION.size():
return _fail("unlock-all did not expose the complete fleet capacity")
return _fail("offices for every formation position gave %d berths" \
% state.max_fleet_size())
for definition in VESSEL_DEFINITIONS.fleet():
menu.fleet_add_requested.emit(String(definition.definition_id))

View file

@ -109,6 +109,10 @@ func _process(delta: float) -> bool:
# --- C continued: the SpeedFader visibly follows it down ----------
if elapsed < 0.2:
return false
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -32,6 +32,10 @@ func _initialize() -> void:
game_state.reset_progression()
game_state.vessel_levels["unlock_distorder"] = 1
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500)
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
@ -47,8 +51,6 @@ func _process(delta: float) -> bool:
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
# Room for a fleet of distorders.
game_state.unlocked.append_array(["wing", "squadron", "armada"])
# --- A. one vessel is unchanged ------------------------------
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER]))

View file

@ -22,6 +22,10 @@ var started: bool = false
func _initialize() -> void:
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
var packed: PackedScene = load("res://world/Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
@ -42,7 +46,6 @@ func _physics_process(delta: float) -> bool:
# can be undone by the engine's deferred tree-entry finalization. A
# new game flies one vessel into three slots now, and this test needs
# a full one.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
target_ship = fleet.ships[0]
var enemy_scene: PackedScene = load("res://enemies/Enemy.tscn")

View file

@ -23,6 +23,10 @@ var started: bool = false
func _initialize() -> void:
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
var packed: PackedScene = load("res://world/Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
@ -43,7 +47,6 @@ func _physics_process(delta: float) -> bool:
# can be undone by the engine's deferred tree-entry finalization. A
# new game flies one vessel into three slots now, and this test needs
# a full one.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
var enemy_scene: PackedScene = load("res://enemies/Enemy.tscn")
enemy = enemy_scene.instantiate()

View file

@ -56,7 +56,13 @@ func _initialize() -> void:
beam_script = load("res://enemies/EnemyBigLaserBeam.gd")
# Four vessels are crewed below, past BASE_FLEET_SIZE's 3 slots, so the
# capacity doctrines are granted rather than assumed.
game_state.unlocked.append_array(["wing", "squadron", "armada"])
# #219: berths come from Fleet Offices now, not from research. Three of
# them buy the same four-vessel fleet the doctrines used to. Left
# unstocked deliberately - upkeep is not charged for the first
# 25 seconds, and nothing here runs that long.
for _berth in range(3):
game_state.add_building(GameState.BUILDING_FLEET_OFFICE,
Vector2(3000.0 + 400.0 * float(_berth), 3000.0))
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -24,6 +24,10 @@ func _initialize() -> void:
game_state.build_time_scale = 0.02
game_state.reset_progression()
game_state.rocks = 2000
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -28,6 +28,10 @@ func _initialize() -> void:
state.vessel_levels["unlock_distorder"] = 1
state.fleet_inventory.total_capacity = -1
state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, DASH_COST * 3)
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main

View file

@ -33,6 +33,10 @@ func _initialize() -> void:
game_state.rocks = 500
game_state.fleet_cargo = 0
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene

View file

@ -2,31 +2,33 @@ 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.
# 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.
#
# 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.
# 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 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", "task_force", "star_legion", "void_armada"]
# 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:
@ -41,6 +43,12 @@ func _initialize() -> void:
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:
@ -48,74 +56,43 @@ func _process(delta: float) -> bool:
# --- 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])
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())
# --- 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
# 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")
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])
# --- 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. 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()])
# #206 named the destination, so it is pinned rather than left to
# whatever the chain happens to add up to.
if expected != 20:
return _fail("the doctrine chain reaches %d vessels, not the 20 it is meant to" \
% expected)
print("full doctrine chain reaches %d slots, with %d formation positions defined" \
% [expected, GameState.DEFAULT_FORMATION.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()])
# --- 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 - and the
# quest chain with it.
game_state.intro_active = false
game_state.reset_quests()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
elapsed = 0.0
stage = 1
@ -123,27 +100,70 @@ func _process(delta: float) -> bool:
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):
# 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 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.
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 %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])
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)
@ -151,6 +171,9 @@ func _process(delta: float) -> bool:
return false
var _lost_before: int = 0
func _fail(message: String) -> bool:
print("TEST_FLEET_GROWTH: FAIL (%s)" % message)
quit(1)

View file

@ -77,6 +77,10 @@ func _initialize() -> void:
return
game_state.fleet_gas_cargo = 20
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -43,6 +43,10 @@ func _initialize() -> void:
game_state.rocks = 0
game_state.gas = 0
game_state.fleet_gas_cargo = 0
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -14,6 +14,10 @@ func _initialize() -> void:
state.vessel_levels["unlock_gas_bomber"] = 1
# #60: shots draw from the fleet hold now - stock enough for both fires.
state.fleet_gas_cargo = 20
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -19,6 +19,10 @@ func _initialize() -> void:
game_state.transporters.clear()
game_state.stored_vessels.clear()
game_state.rocks = 100
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -58,6 +58,10 @@ func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
@ -124,9 +128,17 @@ func _process(delta: float) -> bool:
if panels.is_empty():
return _fail("no modal panels found to be drawn over")
for menu in panels:
if menu.get_index() > banner_at:
return _fail("%s is a later child than the dialog, so it draws on top of it" \
% menu.name)
# A panel nested inside another panel - the build menu's own
# info popup - indexes against ITS parent, not the UI layer,
# so comparing that number against the banner's is comparing
# two different sibling lists. What actually has to sit under
# the banner is the UI-level ancestor it is drawn inside.
var top_level: Node = menu
while top_level.get_parent() != ui:
top_level = top_level.get_parent()
if top_level.get_index() > banner_at:
return _fail("%s is drawn inside %s, a later child than the dialog" \
% [menu.name, top_level.name])
if banner_at != ui.get_child_count() - 1:
return _fail("the dialog is child %d of %d - something still draws over it" \
% [banner_at, ui.get_child_count()])

View file

@ -42,6 +42,10 @@ func _initialize() -> void:
game_state.rocks = 0
game_state.gas = 0
game_state.fleet_gas_cargo = 0
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -58,6 +58,10 @@ func _initialize() -> void:
# fleet composition exists), so this deposit can't be clamped by the
# soon-to-exist all-laser fleet having zero "cargo" capability.
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 20)
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -60,7 +60,7 @@ func _initialize() -> void:
game_state.fleet_cargo = 0
game_state.stored_vessels.clear()
game_state.build_time_scale = 0.02
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
game_state.unlocked = ["shipyards", "coordination"]
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -40,6 +40,10 @@ func _initialize() -> void:
game_state.reset_formation()
game_state.intro_active = false
game_state.rocks = 5000
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene

View file

@ -51,7 +51,13 @@ func _process(delta: float) -> bool:
# same reason the set_process(false) calls above sit at this
# point. A new game flies one vessel now, and the checks below
# index ships[1].
game_state.unlocked.append_array(["wing", "squadron", "armada"])
# #219: berths come from Fleet Offices now, not from research. Three of
# them buy the same four-vessel fleet the doctrines used to. Left
# unstocked deliberately - upkeep is not charged for the first
# 25 seconds, and nothing here runs that long.
for _berth in range(3):
game_state.add_building(GameState.BUILDING_FLEET_OFFICE,
Vector2(3000.0 + 400.0 * float(_berth), 3000.0))
fleet.spawn_ships(5, Vector2.ZERO)
# Hold the button down without releasing it.
angle_at_hold_start = _angle()

View file

@ -55,6 +55,10 @@ func _initialize() -> void:
return
game_state = root.get_node("GameState")
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
var packed: PackedScene = load("res://world/Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
@ -68,7 +72,6 @@ func _initialize() -> void:
# (3) slots. This test needs a crewed fleet, so it asks for one outright
# rather than inheriting whatever a fresh game happens to open with -
# which is exactly what changed underneath it.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
planet_field = main_scene.get_node("PlanetField")
landing_controller = main_scene.get_node("LandingController")

View file

@ -38,6 +38,10 @@ func _initialize() -> void:
quit(1)
return
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
var packed: PackedScene = load("res://world/Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
@ -67,7 +71,6 @@ func _process(delta: float) -> bool:
# there can be undone by the engine's deferred tree-entry
# finalization, the same reason the set_process(false) calls
# above live at this point. A new game flies one vessel now.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
# Silencing the field does not remove what it already placed, and
# those keep drifting - a rock taking the fleet out mid-test reads

View file

@ -0,0 +1 @@
uid://bthdk3qrxamks

View file

@ -51,7 +51,13 @@ func _initialize() -> void:
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.stored_vessels.clear()
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
game_state.unlocked = ["shipyards", "coordination"]
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
# Eight, which is what every doctrine used to allow: the amounts below are
# hand-picked to sit under a hold that size (8 interceptors x 20 = 160).
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 8)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
@ -59,7 +65,6 @@ func _initialize() -> void:
# (3) slots. This test needs a crewed fleet, so it asks for one outright
# rather than inheriting whatever a fresh game happens to open with -
# which is exactly what changed underneath it.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
controller = scene.get_node("BuildController")
@ -96,9 +101,9 @@ func _process(delta: float) -> bool:
_clear_hazards()
# #56 gave the hold a finite, composition-driven capacity. This
# test is about the transfer UI, not that cap - test_fleet_cargo_
# limit.gd owns the cap itself - so it spawns the largest fleet
# every unlocked doctrine allows (8 interceptors x 20 = 160),
# comfortably above every hand-picked amount below.
# limit.gd owns the cap itself - so it spawns into every berth the
# offices above bought (8 interceptors x 20 = 160), comfortably
# above every hand-picked amount below.
fleet.spawn_ships(game_state.max_fleet_size(), MOTHERSHIP_AT)
var field: Node = scene.get_node("BuildingField")
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)

View file

@ -107,7 +107,13 @@ func _process(delta: float) -> bool:
# Crafting adds a FOURTH vessel below, one past the 3 slots a
# new game has, so make room first - the subject here is the
# crafter, not the fleet cap.
game_state.unlocked.append_array(["wing", "squadron", "armada"])
# #219: berths come from Fleet Offices now, not from research. Three of
# them buy the same four-vessel fleet the doctrines used to. Left
# unstocked deliberately - upkeep is not charged for the first
# 25 seconds, and nothing here runs that long.
for _berth in range(3):
game_state.add_building(GameState.BUILDING_FLEET_OFFICE,
Vector2(3000.0 + 400.0 * float(_berth), 3000.0))
fleet.spawn_ships(3, Vector2.ZERO)
var before_ships: int = fleet.ships.size()
var before_rocks: int = game_state.rocks

View file

@ -43,6 +43,10 @@ func _initialize() -> void:
quit(1)
return
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
var packed: PackedScene = load("res://world/Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
@ -63,7 +67,6 @@ func _process(delta: float) -> bool:
# there can be undone by the engine's deferred tree-entry
# finalization, the same reason the set_process(false) calls
# above live at this point. A new game flies one vessel now.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
# Silencing the field does not remove what it already placed, and
# those keep drifting - a rock taking the fleet out mid-test reads

View file

@ -38,6 +38,10 @@ func _initialize() -> void:
game_state.build_time_scale = 0.02
game_state.reset_progression()
game_state.rocks = 2000
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -70,6 +70,10 @@ func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene

View file

@ -41,6 +41,10 @@ func _initialize() -> void:
game_state.reset_progression()
game_state.rocks = 1200
game_state.fleet_cargo = 0
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
@ -81,7 +85,7 @@ func _process(delta: float) -> bool:
return true
print("no lab -> no upgrades")
game_state.unlocked = ["shipyards", "vessel_science", "coordination", "wing", "squadron"]
game_state.unlocked = ["shipyards", "vessel_science", "coordination"]
game_state.research_points = 10
field.place(GameState.BUILDING_SHIP_LAB, Vector2(-700.0, 0.0))
lab_id = int(game_state.buildings[-1].id)

View file

@ -126,6 +126,18 @@ func _process(delta: float) -> bool:
print("a second click on the same node researches it")
# --- C. held drag scrolls, a bare hover does not --------------------
# #219 removed the eight-deep doctrine lane and the tree now fits its own
# view, so there is nothing left to scroll. What is under test here is the
# INPUT rule - drag only while the button is held, wheel both ways - not
# how tall the tree happens to be, so the view is shortened to give the
# gesture something to move. Scrolled to the bottom first, the way the
# menu opens when the content does overflow.
var authored_height: float = tree_view.size.y
tree_view.size.y = authored_height * 0.4
tree_view.scroll_y = tree_view._max_scroll()
if tree_view.scroll_y <= 0.0:
return _fail("the tree still has nothing to scroll, so this proves nothing")
var before: float = tree_view.scroll_y
_move(Vector2(0.0, 120.0))
if not is_equal_approx(tree_view.scroll_y, before):
@ -153,6 +165,10 @@ func _process(delta: float) -> bool:
print("the wheel scrolls both ways")
# --- E. the panel has no dead band at the bottom --------------------
# Back to the authored height: section C shortened the view to have
# something to scroll, and the layout below is about the real one.
tree_view.size.y = authored_height
tree_view.scroll_y = 0.0
var detail: Control = scene.get_node("UI/SkillMenu/DetailLabel")
var status: Control = scene.get_node("UI/SkillMenu/StatusLabel")
var tree_bottom: float = tree_view.position.y + tree_view.size.y

View file

@ -131,19 +131,34 @@ func _process(delta: float) -> bool:
print("TEST_SKILL_TREE: FAIL (freight branch does not rise vertically)")
quit(1)
return true
var before_scroll: float = controller.skill_menu.tree.scroll_y
var tree_view: Control = controller.skill_menu.tree
var before_scroll: float = tree_view.scroll_y
var drag: InputEventScreenDrag = InputEventScreenDrag.new()
# Start at the root at the bottom; pull the canvas down to reveal
# the branches rising above it.
drag.relative = Vector2(0.0, 180.0)
controller.skill_menu.tree.handle_drag(drag)
if controller.skill_menu.tree.scroll_y >= before_scroll:
print("TEST_SKILL_TREE: FAIL (tree did not scroll upward)")
quit(1)
return true
tree_view.handle_drag(drag)
# #219 removed the eight-deep doctrine lane, which was what made
# the canvas taller than the view. A tree that FITS has nothing to
# scroll, so what is checked depends on which it is - and the
# clamp is worth pinning either way, since a canvas that drifts
# off its own top edge is the bug this gesture used to expose.
if before_scroll > 0.0:
if tree_view.scroll_y >= before_scroll:
print("TEST_SKILL_TREE: FAIL (tree did not scroll upward)")
quit(1)
return true
print("tree scrolls: %.0f -> %.0f" % [before_scroll, tree_view.scroll_y])
else:
if tree_view.scroll_y != 0.0:
print("TEST_SKILL_TREE: FAIL (a tree that fits scrolled to %.1f)" \
% tree_view.scroll_y)
quit(1)
return true
print("tree fits the view; the pan clamps at the top")
# --- prerequisites --------------------------------------------
if game_state.can_unlock("wing"):
if game_state.can_unlock("power_systems"):
print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)")
quit(1)
return true
@ -172,16 +187,21 @@ func _process(delta: float) -> bool:
scene.get_node("UI/BuildMenu").visible = false
# --- fleet capacity -------------------------------------------
# #219 took capacity out of the tree entirely: a berth is a Fleet
# Office standing somewhere. What this checks now is that the
# tree does NOT move the cap, and that the structure does.
var base_cap: int = game_state.max_fleet_size()
game_state.research_points = 2
game_state.unlock("coordination")
var wing_ok: bool = game_state.unlock("wing")
if game_state.max_fleet_size() != base_cap:
print("TEST_SKILL_TREE: FAIL (research still moves the fleet cap)")
quit(1)
return true
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(2400.0, 0.0))
var raised_cap: int = game_state.max_fleet_size()
print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [
base_cap, raised_cap, wing_ok
])
if not (wing_ok and raised_cap == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)")
print("fleet cap %d -> %d after a Fleet Office" % [base_cap, raised_cap])
if raised_cap != base_cap + 1:
print("TEST_SKILL_TREE: FAIL (a Fleet Office did not raise the cap)")
quit(1)
return true

View file

@ -41,6 +41,10 @@ func _initialize() -> void:
game_state.build_time_scale = 0.02
game_state.reset_progression()
game_state.rocks = 2000
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -43,6 +43,10 @@ func _initialize() -> void:
# hold has no capacity cap yet at this point, so this can't be clamped
# by the soon-to-exist fleet's cargo capability.
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500)
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")

View file

@ -126,20 +126,22 @@ func _state_cases() -> bool:
var patient: int = game_state.wave_number
for wave in [1, 5, 20, 60]:
game_state.wave_number = wave
for size in range(1, GameState.BASE_FLEET_SIZE + 1):
for size in range(1, 4):
for id in game_state.wave_composition(size):
if id != "chaser":
game_state.wave_number = patient
return _fail("wave %d against a fleet of %d fields a %s" \
% [wave, size, id])
game_state.wave_number = patient
print("a fleet of %d or fewer meets chasers only, at every wave number" \
% GameState.BASE_FLEET_SIZE)
print("a fleet of 3 or fewer meets chasers only, at every wave number")
# And each vessel past the starting three opens exactly one more kind, so
# the ramp is progressive rather than a cliff.
# Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting
# capacity to 1, and this is about the roster's own thresholds (4, 5, 6),
# not about where the fleet happens to begin.
var previous_kinds: int = 1
for size in range(GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 4):
for size in range(3, 8):
var kinds_here: Dictionary = {}
for id in game_state.wave_composition(size):
kinds_here[id] = true

View file

@ -0,0 +1 @@
uid://dxsx3ilfx442g

View file

@ -0,0 +1 @@
uid://b8opi6d0vr1ib

View file

@ -328,6 +328,46 @@ offset_top = 774
offset_bottom = 826
script = ExtResource("price_tag")
[node name="FleetOfficeButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = 856
offset_bottom = 984
script = ExtResource("10")
icon = 2
glow_color = Color(0.45, 0.78, 1, 1)
[node name="FleetOfficeLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -150
offset_right = 150
offset_top = 990
offset_bottom = 1056
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Fleet Office"
horizontal_alignment = 1
[node name="FleetOfficeCost" type="Control" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -60
offset_right = 60
offset_top = 1060
offset_bottom = 1112
script = ExtResource("price_tag")
[node name="OutpostButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5

View file

@ -0,0 +1 @@
uid://b0i5o58y5rycg

View file

@ -51,6 +51,23 @@ func _ready() -> void:
# Researching a vessel upgrade improves the ships already flying, not
# just the next one built (#5).
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
# #219: a Fleet Office that runs dry loses the vessel it was keeping.
# GameState decides THAT it happens - it owns the supply - and the fleet
# owns the hulls, so it is the one that gives this one up.
GameState.fleet_upkeep_failed.connect(_on_fleet_upkeep_failed)
# Destroyed rather than stored: the vessel is not being put away, it is being
# lost, and taking it out through the ordinary death path means it explodes,
# is heard, and leaves the formation exactly as a vessel shot down does.
func _on_fleet_upkeep_failed(slot_index: int, _vessel_type: String) -> void:
if slot_index < 0 or slot_index >= ships.size():
return
var lost: Node = ships[slot_index]
if is_instance_valid(lost) and not lost.is_queued_for_deletion():
# scuttle(), not take_damage(): a shield charge would ABSORB a hit,
# and there is no hit here - the berth simply stopped being paid for.
lost.scuttle()
func _on_vessel_upgraded(_id: String) -> void:

View file

@ -171,6 +171,15 @@ func take_damage(_amount: int) -> bool:
for child in get_children():
if child.has_method("absorb_hit") and child.absorb_hit():
return true
scuttle()
return false
# Lost for a reason no shield can answer - #219's Fleet Office running dry,
# rather than something hitting it. Same ending as a lethal hit, deliberately:
# what the player sees is a vessel they had and no longer have, and giving
# that its own quieter exit would read as the ship simply vanishing.
func scuttle() -> void:
# #130: the hull comes off the formation and explodes further out, rather
# than going up on the spot. The wreck is its own node - this one is
# still freed on this frame, so nothing about who is in the fleet, who
@ -182,7 +191,6 @@ func take_damage(_amount: int) -> bool:
Sfx.play(get_tree(), Sfx.SHIP_DEATH)
destroyed.emit()
queue_free()
return false
# Which way is "out of the fleet". Away from the formation's own centre, so a