spaceship/tests/test_carrier_vessel.gd
Loïc Wisniewski 1f5fecd371 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
2026-09-05 17:09:27 +02:00

244 lines
11 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_carrier_vessel.gd
# Forgejo #57: an unarmed hauler that substantially raises how much the
# fleet can carry (#56).
#
# Covers:
# A. crewing a carrier raises GameState.fleet_cargo_capacity() by its stat
# B. a carrier never fires - no weapon component, no projectile ever
# originates from it even with an enemy parked in interceptor fire range
# C. it is unbuildable until researched
# D. it appears in the crafter, hangar and ship lab with correct name,
# cost, icon and colour, and is buildable once researched
var game_state: Node
var scene: Node
var fleet: Node
var controller: Node
var field: Node
var crafter_id: int = 0
var mothership_id: int = 0
var definition: Resource
var capacity_before: int = 0
var enemy: Node
var stage: int = 0
var elapsed: float = 0.0
const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0)
# Same slot the crafter/hangar UI arrays give carrier: VesselDefinitions.ORDER
# appends carrier after transporter, and VesselDefinitions.fleet() filters
# transporter out, so carrier lands last among the 4 fleet-eligible vessels
# (interceptor, shield, gas_bomber, carrier) in both craft_vessel_* and the
# hangar_* arrays.
const FLEET_UI_INDEX: int = 3
# The ship lab shows every vessel including the transporter, so carrier is
# the 5th and last slot there.
const LAB_UI_INDEX: int = 4
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.stored_vessels.clear()
game_state.reset_progression()
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")
controller = scene.get_node("BuildController")
func _clear_hazards() -> void:
for a in get_nodes_in_group("asteroids"):
if is_instance_valid(a):
a.queue_free()
for e in get_nodes_in_group("enemies"):
if is_instance_valid(e):
e.queue_free()
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
_clear_hazards()
# --- structural: unarmed, cargo-capable, fleet-eligible ---------
definition = game_state.vessel_definition(GameState.VESSEL_CARRIER)
if definition == null or not definition.fleet_eligible:
return _fail("carrier is not a fleet-eligible vessel definition")
if definition.capabilities.has("weapon"):
return _fail("carrier declares a weapon capability")
if not definition.capabilities.has("cargo"):
return _fail("carrier does not declare the cargo capability")
# --- A. capacity math, directly -----------------------------------
if game_state.fleet_cargo_capacity(PackedStringArray(["carrier"])) != 120:
return _fail("one carrier's capacity contribution is %d, expected 120" \
% game_state.fleet_cargo_capacity(PackedStringArray(["carrier"])))
print("structural: unarmed, cargo capability, contributes 120 alone")
# --- C. gated until researched --------------------------------
field = scene.get_node("BuildingField")
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_SHIP_LAB, CRAFTER_AT + Vector2(0.0, 400.0))
if game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER):
return _fail("carrier starts unlocked")
if game_state.queue_order(crafter_id, GameState.VESSEL_CARRIER):
return _fail("queue_order accepted an unresearched carrier")
print("unresearched carrier is unbuildable, as expected")
# --- D. locked state in the crafter UI -------------------------
controller.craft_building_id = crafter_id
controller._refresh_craft_status()
if controller.craft_menu.vessel_buttons[FLEET_UI_INDEX].enabled:
return _fail("a locked carrier shows enabled in the crafter menu")
stage = 1
elapsed = 0.0
return false
1:
if elapsed < 0.1:
return false
# --- C continued: unlocking ------------------------------------
if not game_state.unlock_vessel(GameState.VESSEL_CARRIER):
return _fail("unlock_vessel(carrier) failed with 500 ore in the bank")
if not game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER):
return _fail("carrier still reports locked after unlocking")
if game_state.product_costs(GameState.VESSEL_CARRIER) != {GameState.RESOURCE_ORE: 24}:
return _fail("carrier's build cost is %s, expected 24 ore" \
% game_state.product_costs(GameState.VESSEL_CARRIER))
print("researched: unlocked, costs %s" % game_state.product_costs(GameState.VESSEL_CARRIER))
# --- D. unlocked state in the crafter UI ------------------------
controller._refresh_craft_status()
var craft_button: IconButton = controller.craft_menu.vessel_buttons[FLEET_UI_INDEX]
var craft_label: Label = controller.craft_menu.vessel_labels[FLEET_UI_INDEX]
if not craft_button.enabled:
return _fail("an unlocked carrier still shows disabled in the crafter menu")
if craft_button.icon != definition.icon or craft_button.glow_color != definition.glow_color:
return _fail("crafter carrier button does not match its definition's icon/colour")
if not craft_label.text.begins_with(definition.display_name):
return _fail("crafter carrier label reads '%s', expected to start with '%s'" \
% [craft_label.text, definition.display_name])
if not game_state.queue_order(crafter_id, GameState.VESSEL_CARRIER):
return _fail("queue_order rejected a researched carrier")
print("crafter: enabled, correct icon/colour, correct label, buildable")
# Bypass the build timer/delivery wait - that queue mechanism is
# generic and already covered for every other vessel
# (test_building_stock_queue.gd, test_resources_and_buildings.gd
# etc.); this ticket is about the carrier's OWN behaviour, not
# re-proving crafting works.
game_state.stored_vessels.append(GameState.VESSEL_CARRIER)
# --- D. hangar display -------------------------------------------
controller._refresh_hangar()
var available_slot: IconButton = controller.hangar_menu.available_slots[FLEET_UI_INDEX]
var available_label: Label = controller.hangar_menu.available_labels[FLEET_UI_INDEX]
if available_slot.icon != definition.icon or available_slot.glow_color != definition.glow_color:
return _fail("hangar carrier slot does not match its definition's icon/colour")
var expected_available: String = "%s\n1 available" % definition.display_name
if available_label.text != expected_available:
return _fail("hangar carrier label reads '%s', expected '%s'" \
% [available_label.text, expected_available])
print("hangar: correct icon/colour, shows 1 available")
# --- A continued: crewing it raises capacity ---------------------
# The default fleet (5 interceptors) already sits at
# max_fleet_size() with reset_progression()'s cleared skills, which
# would make _on_hangar_crew_type refuse for being full - shrink
# to 1 ship first so there is room to crew the carrier at all.
fleet.spawn_ships(1, MOTHERSHIP_AT, PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
capacity_before = game_state.fleet_cargo_total_capacity()
controller._on_hangar_crew_type(FLEET_UI_INDEX)
if not fleet.has_vessel_type(GameState.VESSEL_CARRIER):
return _fail("crewing from the hangar did not add a carrier to the fleet")
var capacity_after: int = game_state.fleet_cargo_total_capacity()
if capacity_after != capacity_before + 120:
return _fail("crewing a carrier moved capacity %d -> %d, expected +120" \
% [capacity_before, capacity_after])
print("crewed: capacity %d -> %d (+120)" % [capacity_before, capacity_after])
# --- D. ship lab display -------------------------------------------
controller.lab_vessel = GameState.VESSEL_CARRIER
controller._refresh_lab()
var lab_button: IconButton = controller.lab_menu.vessel_buttons[LAB_UI_INDEX]
var lab_label: Label = controller.lab_menu.vessel_labels[LAB_UI_INDEX]
if lab_button.icon != definition.icon or lab_button.glow_color != definition.glow_color:
return _fail("ship lab carrier slot does not match its definition's icon/colour")
if lab_label.text != definition.display_name:
return _fail("ship lab carrier label reads '%s', expected '%s'" \
% [lab_label.text, definition.display_name])
print("ship lab: correct icon/colour/label")
stage = 2
elapsed = 0.0
return false
2:
# --- B. never fires, even with a target in range -----------------
# Isolate: JUST a carrier in the fleet, nothing else nearby that
# could fire instead and make the check meaningless.
fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_CARRIER]))
stage = 3
elapsed = 0.0
return false
3:
# A settling tick before the enemy appears: queue_free() on the
# ships spawn_ships() just replaced does not remove them from the
# tree instantly, so a stale interceptor's weapon could otherwise
# still get one last physics tick against a target added in the
# same frame - a false positive, not the carrier firing.
if elapsed < 0.3:
return false
var enemy_scene: PackedScene = load("res://enemies/Enemy.tscn")
enemy = enemy_scene.instantiate()
enemy.position = fleet.get_centroid() + Vector2(220.0, 0.0)
scene.add_child(enemy)
# Frozen in place: a chaser closes distance and kills on contact,
# and an unarmed carrier has nothing to stop that. Letting it
# approach would wipe the fleet, trip the "rebuild at the
# mothership" safety net (a default INTERCEPTOR), and that
# interceptor firing would look like a carrier-firing false
# positive - this test is about whether the carrier fires, not
# whether it can survive unescorted.
enemy.set_physics_process(false)
stage = 4
elapsed = 0.0
return false
4:
if elapsed < 2.0:
return false
var projectiles: Array = get_nodes_in_group("player_projectiles")
if not projectiles.is_empty():
return _fail("%d projectile(s) originated from a fleet made only of a carrier" \
% projectiles.size())
print("no projectiles after %.1fs with an enemy in interceptor fire range" % elapsed)
print("TEST_CARRIER_VESSEL: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_CARRIER_VESSEL: FAIL (%s)" % message)
quit(1)
return true