spaceship/tests/test_carrier_vessel.gd

247 lines
11 KiB
GDScript3
Raw Normal View History

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)
#84: move 215 root files into a folder hierarchy Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
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
#84: move 215 root files into a folder hierarchy Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
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