spaceship/tests/test_mothership_replacement.gd

150 lines
5.7 KiB
GDScript3
Raw Normal View History

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_mothership_replacement.gd
# Only one mothership may exist. Building another replaces it, which moves
# where a wiped-out fleet respawns and re-routes every extractor hauler, so
# it must be confirmed rather than done silently. Verifies:
# 1. the first mothership's info popup carries no destruction warning, and
# confirming it builds one
# 2. with one already up, the popup DOES warn, and builds nothing until
# confirmed
# 3. dismissing the popup cancels - confirming afterwards does nothing
# 4. confirming replaces it: still exactly one, at the new position
# 5. an unaffordable replacement offers no confirm button at all, and
# cannot destroy the existing one
var main_scene: Node
var fleet: Node
var game_state: Node
var controller: Node
var popup: Control
var stage: int = 0
var elapsed: float = 0.0
var first_pos: Vector2 = Vector2(600.0, 200.0)
var second_pos: Vector2 = Vector2(-900.0, 400.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
#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
main_scene = load("res://world/Main.tscn").instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
controller = main_scene.get_node("BuildController")
popup = main_scene.get_node("UI/BuildMenu/InfoPopup")
func _place_fleet(at: Vector2) -> void:
for ship in fleet.ships:
ship.position = at
fleet.move_to(at)
func _count_motherships() -> int:
var n: int = 0
for b in game_state.buildings:
if b.type == GameState.BUILDING_MOTHERSHIP:
n += 1
return n
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
_place_fleet(first_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- first one: informed, then built -------------------------
controller._on_build_pressed()
controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower()
var first_warned: bool = "destroy" in first_text
print("first: popup=%s warned=%s built_before_confirm=%d" % [
popup.visible, first_warned, _count_motherships()
])
if not popup.visible or first_warned or _count_motherships() != 0:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (first build warned, or built without confirming)")
quit(1)
return true
controller._confirm_build()
if _count_motherships() != 1:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (confirming the first one did not build it)")
quit(1)
return true
# --- second: warns, builds nothing ---------------------------
_place_fleet(second_pos)
controller._on_build_pressed()
controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower()
print("second press: warned=%s motherships=%d" % [warned, _count_motherships()])
if not warned or _count_motherships() != 1:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (no warning, or built without confirming)")
quit(1)
return true
# --- dismissing cancels --------------------------------------
controller._close_info()
controller._confirm_build()
print("after cancel, confirm pressed anyway: motherships=%d at %s" % [
_count_motherships(), game_state.get_mothership_position()
])
if _count_motherships() != 1 or game_state.get_mothership_position().distance_to(first_pos) > 60.0:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (cancel did not drop the pending build)")
quit(1)
return true
# --- confirming replaces -------------------------------------
var rocks_before: int = game_state.rocks
controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
controller._confirm_build()
var only_one: bool = _count_motherships() == 1
var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0
#138: structures and doctrines cost more than rocks, and more the deeper they are Both halves of the ticket, in the shape vessels have used since gas bombers and laser ships: a definition carries an ore figure and, when it wants more than ore, a dictionary that IS the price. `building_costs()` and `skill_costs()` answer it the way `product_costs()` already did, so there is one question with one answer whatever is being bought, and every payment path now goes through `spend_resources` rather than `spend`. Seven of ten structures and thirteen of seventeen doctrines now want gas, energy or dark matter alongside their rocks, and the totals climb with tree depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530. The constraint that shaped the table is REACHABILITY, and it is the reason some prices stayed ore-only. Energy exists only where a gas burner stands, and a gas burner needs Power Systems - so nothing at or before that depth may be priced in energy, or it is a wall with no way through. Dark matter is rarer still and appears once, on the deepest node in the game. The extractor stays rocks-only despite its depth allowing gas, because the quest chain reaches it before it has sent the player for any. test_progressive_costs checks those as properties rather than transcribing the numbers, so rebalancing a price costs nothing while breaking the shape fails: nothing unreachable at its own depth, prices rising with depth, the ore field agreeing with the dictionary (repairs quote the field alone, since a repair drone carries ore and nothing else), and the quest chain never asking for a structure it has not funded. Two things cleaned up on the way: - The per-building cost constants in GameState are gone. Nothing but tests used them, and they were a second copy of a number the definitions already hold - exactly what would drift once a price gained a resource. - research_for_building() returns a display NAME, and the id-keyed half of the tree wants an id. That mix-up is silent - skill_depth() of a name finds no node and answers 0, so a deep structure reads as an ungated one, which is how the first draft of the test let an unreachable price through. research_node_for_building() is the id, and the name is expressed from it. PriceTag no longer rebuilds its labels when re-told the same price; it refreshes the held figures instead. The craft menu re-states prices every frame and the build menu now does too, so that was a dozen Labels being torn down and rebuilt sixty times a second.
2026-08-28 15:30:30 +02:00
var charged: bool = game_state.rocks == rocks_before - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])
print("confirmed: motherships=%d moved=%s rocks %d->%d charged=%s" % [
_count_motherships(), moved, rocks_before, game_state.rocks, charged
])
if not (only_one and moved and charged):
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (replacement did not take effect cleanly)")
quit(1)
return true
# --- unaffordable replacement must not destroy the old one ---
#138: structures and doctrines cost more than rocks, and more the deeper they are Both halves of the ticket, in the shape vessels have used since gas bombers and laser ships: a definition carries an ore figure and, when it wants more than ore, a dictionary that IS the price. `building_costs()` and `skill_costs()` answer it the way `product_costs()` already did, so there is one question with one answer whatever is being bought, and every payment path now goes through `spend_resources` rather than `spend`. Seven of ten structures and thirteen of seventeen doctrines now want gas, energy or dark matter alongside their rocks, and the totals climb with tree depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530. The constraint that shaped the table is REACHABILITY, and it is the reason some prices stayed ore-only. Energy exists only where a gas burner stands, and a gas burner needs Power Systems - so nothing at or before that depth may be priced in energy, or it is a wall with no way through. Dark matter is rarer still and appears once, on the deepest node in the game. The extractor stays rocks-only despite its depth allowing gas, because the quest chain reaches it before it has sent the player for any. test_progressive_costs checks those as properties rather than transcribing the numbers, so rebalancing a price costs nothing while breaking the shape fails: nothing unreachable at its own depth, prices rising with depth, the ore field agreeing with the dictionary (repairs quote the field alone, since a repair drone carries ore and nothing else), and the quest chain never asking for a structure it has not funded. Two things cleaned up on the way: - The per-building cost constants in GameState are gone. Nothing but tests used them, and they were a second copy of a number the definitions already hold - exactly what would drift once a price gained a resource. - research_for_building() returns a display NAME, and the id-keyed half of the tree wants an id. That mix-up is silent - skill_depth() of a name finds no node and answers 0, so a deep structure reads as an ungated one, which is how the first draft of the test let an unreachable price through. research_node_for_building() is the id, and the name is expressed from it. PriceTag no longer rebuilds its labels when re-told the same price; it refreshes the held figures instead. The craft menu re-states prices every frame and the build menu now does too, so that was a dozen Labels being torn down and rebuilt sixty times a second.
2026-08-28 15:30:30 +02:00
game_state.rocks = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) - 1
_place_fleet(Vector2(2500.0, -800.0))
controller._on_build_pressed()
controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
# With nothing affordable there is no confirm button to press;
# pressing it anyway must still do nothing.
var offered: bool = popup.get_node("Panel/BuildButton").visible
print("unaffordable: confirm offered=%s" % offered)
if offered:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (offered a build it cannot pay for)")
quit(1)
return true
controller._confirm_build()
var survived: bool = _count_motherships() == 1
var still_there: bool = game_state.get_mothership_position() != null \
and game_state.get_mothership_position().distance_to(second_pos) < 60.0
print("unaffordable: motherships=%d kept_old_position=%s" % [
_count_motherships(), still_there
])
if survived and still_there:
print("TEST_MOTHERSHIP_REPLACEMENT: PASS")
quit(0)
else:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (destroyed the old one without affording a new one)")
quit(1)
return true
return false