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