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:
parent
6e13b6397c
commit
1f5fecd371
60 changed files with 695 additions and 243 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue