BuildingField.place() dropped a structure at the fleet centroid with no
overlap check of any kind, so they could be stacked.
It reads worse than it looks. BuildingField decides which structure the
fleet is standing on by testing the centroid against interact_radius and
returning the FIRST match, so two structures inside one another make that a
question of list order rather than of where the player flew - you could fly
to your workshop and open the crafter.
The separation is interact_radius itself (150) rather than a new number,
because that is exactly the circle the ambiguity comes from. Buildings draw
to a radius of roughly 60, so this leaves a visible gap rather than letting
them touch.
Gated in three places: the info popup (the button greys out and says which
structure is in the way), _confirm_build, and _try_place - the preview goes
stale as the fleet keeps moving.
Ordering matters in one case. A unique structure replaces itself by
demolishing the old one BEFORE placing the new one, so a placement refused
afterwards would leave the player with neither. The check runs before the
demolition and excludes the structure being replaced. Case D pins it.
Two existing tests encoded the old permissive behaviour and now fly clear
first; neither is about placement:
test_build_info_popup builds every menu option from one position, so
each structure blocked the next
test_payments_use_fleet_hold builds a mothership on the ship lab that
an earlier case injects at the origin
Also fixes a bug from 37e2e89: Sfx.warm() called add_child() from _ready(),
which Godot refuses outright - it printed an error and never pre-built the
pool, defeating the point of warming it. Deferred, like Music.ensure().
173/173.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
145 lines
5.2 KiB
GDScript
145 lines
5.2 KiB
GDScript
extends SceneTree
|
|
|
|
# Headless gameplay test: run with
|
|
# godot --headless -s res://tests/test_build_info_popup.gd
|
|
# Every build option explains itself before anything is spent. Picking a
|
|
# structure costs ore and, for a mothership, can destroy the one you already
|
|
# have, so no option may build straight off the menu press.
|
|
#
|
|
# Verifies, for EVERY option in the build menu:
|
|
# 1. pressing it opens the popup with that building's own title and a
|
|
# non-empty description, and shows its price
|
|
# 2. nothing is built and no ore is spent until the confirm is pressed
|
|
# 3. confirming builds exactly that structure and charges exactly its cost
|
|
# 4. closing the popup instead drops the pending build entirely - a later
|
|
# confirm press does nothing
|
|
|
|
var scene: Node
|
|
var game_state: Node
|
|
var controller: Node
|
|
var fleet: Node2D
|
|
var popup: Control
|
|
var title: Label
|
|
var message: Label
|
|
# Control, not PriceTag: naming a class whose body reads the GameState
|
|
# autoload does not compile inside an `extends SceneTree` script.
|
|
var cost_number: Control
|
|
var build_button: IconButton
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
|
|
# Extractor is excluded: it needs a planet underneath, which
|
|
# test_extractor_building covers in full.
|
|
const OPTIONS: Array = [
|
|
{"type": "mothership", "title": "Mothership"},
|
|
{"type": "ship_crafter", "title": "Ship Crafter"},
|
|
{"type": "command_center", "title": "Command Center"},
|
|
]
|
|
|
|
|
|
func _initialize() -> void:
|
|
game_state = root.get_node("GameState")
|
|
game_state.rocks = 500
|
|
# #138: some structures want gas as well as rocks, and the popup
|
|
# only offers a build button for something the player can pay for.
|
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500)
|
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 500)
|
|
game_state.buildings.clear()
|
|
game_state.extractors.clear()
|
|
# Structures are gated behind the mothership's research tree. That gate
|
|
# is test_skill_tree's subject, not this one's - here every option needs
|
|
# to be available so the popup itself can be exercised.
|
|
game_state.reset_progression()
|
|
for node in GameState.SKILL_TREE:
|
|
game_state.unlocked.append(node.id)
|
|
|
|
scene = load("res://world/Main.tscn").instantiate()
|
|
root.add_child(scene)
|
|
controller = scene.get_node("BuildController")
|
|
fleet = scene.get_node("Fleet")
|
|
popup = scene.get_node("UI/BuildMenu/InfoPopup")
|
|
title = popup.get_node("Panel/TitleLabel")
|
|
message = popup.get_node("Panel/MessageLabel")
|
|
cost_number = popup.get_node("Panel/CostNumber")
|
|
build_button = popup.get_node("Panel/BuildButton")
|
|
|
|
|
|
func _count(type: String) -> int:
|
|
var n: int = 0
|
|
for b in game_state.buildings:
|
|
if b.type == type:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
elapsed += delta
|
|
match stage:
|
|
0:
|
|
scene.get_node("AsteroidField").set_process(false)
|
|
scene.get_node("EnemySpawner").set_process(false)
|
|
elapsed = 0.0
|
|
stage = 1
|
|
return false
|
|
1:
|
|
if elapsed < 0.2:
|
|
return false
|
|
|
|
for i in range(OPTIONS.size()):
|
|
var option: Dictionary = OPTIONS[i]
|
|
var type: String = option.type
|
|
# #224: structures may not be stacked, and this loop BUILDS
|
|
# each option in turn. Without moving between them the second
|
|
# one is refused for sitting on the first, which is the rule
|
|
# working - but this test is about what the popup says, not
|
|
# about where things may go.
|
|
fleet.teleport_to(Vector2(float(i + 2) * GameState.MIN_BUILDING_SEPARATION * 2.0, 0.0))
|
|
var expected_cost: int = controller.cost_of(type)
|
|
var rocks_before: int = game_state.rocks
|
|
var built_before: int = _count(type)
|
|
|
|
controller._on_build_pressed()
|
|
controller._show_building_info(type)
|
|
|
|
var informed: bool = popup.visible \
|
|
and title.text == option.title \
|
|
and message.text.length() > 40 \
|
|
and cost_number.priced(GameState.RESOURCE_ORE) == expected_cost \
|
|
and build_button.visible
|
|
var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before
|
|
print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [
|
|
type, popup.visible, title.text,
|
|
cost_number.priced(GameState.RESOURCE_ORE),
|
|
build_button.visible, untouched
|
|
])
|
|
if not (informed and untouched):
|
|
print("TEST_BUILD_INFO_POPUP: FAIL (%s did not explain itself before building)" % type)
|
|
quit(1)
|
|
return true
|
|
|
|
# --- closing drops it, it must not build later ------------
|
|
controller._close_info()
|
|
controller._confirm_build()
|
|
if _count(type) != built_before or game_state.rocks != rocks_before:
|
|
print("TEST_BUILD_INFO_POPUP: FAIL (%s built after the popup was dismissed)" % type)
|
|
quit(1)
|
|
return true
|
|
|
|
# --- reopening and confirming builds it ------------------
|
|
controller._on_build_pressed()
|
|
controller._show_building_info(type)
|
|
controller._confirm_build()
|
|
var built: bool = _count(type) == built_before + 1
|
|
var charged: bool = game_state.rocks == rocks_before - expected_cost
|
|
print("%s: confirmed -> built=%s rocks %d->%d charged=%s" % [
|
|
type, built, rocks_before, game_state.rocks, charged
|
|
])
|
|
if not (built and charged):
|
|
print("TEST_BUILD_INFO_POPUP: FAIL (%s did not build on confirmation)" % type)
|
|
quit(1)
|
|
return true
|
|
|
|
print("TEST_BUILD_INFO_POPUP: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|