spaceship/tests/test_carrier_vessel.gd
Loïc Wisniewski 296b49c8b8 Offer only orderable vessels, and read out crew condition
A ship crafter shows what can actually be ordered there: a hull whose
research is done, and - for the support craft - one with somewhere to be
assigned. A transporter with no hub and a drone with no workshop have
nowhere to go and would sit in the hangar as spent resources, so the
structure that uses one is part of its availability. Hidden rather than
greyed, as the build menu already treats an unresearched structure, and
gated in queue_order() too so the model refuses what the menu declines to
show.

The craft buttons bound their handlers to a definition captured at startup.
With a list that changes length that is a live bug - a slot would order
whatever hull was there when the menu was built - so they bind to the slot
and resolve through the current offer.

The hub and workshop crew menus now NAME what is left in each vessel:
"TRANSPORTER 2  9 / 20 RUNS", worst first, with one wide bar for the vessel
closest to spent. The per-vessel bars they replaced drew about seven pixels
tall on a phone and carried no figure, so the number the player is deciding
on could not be read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-06 11:58:15 +02:00

247 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 -------------------------
# An unresearched hull is now ABSENT from the crafter rather than
# drawn greyed: a row nobody can press says nothing its absence
# does not. What must hold is that it cannot be ordered from here.
controller.craft_building_id = crafter_id
controller._refresh_craft_status()
if controller.craft_menu.shown_types.has(GameState.VESSEL_CARRIER):
return _fail("an unresearched carrier is still offered 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