#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
244 lines
11 KiB
GDScript
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
|