#219: fleet capacity is a built, supplied thing rather than a researched one. The eight fleet-size doctrines are gone; capacity is one berth free plus one per Fleet Office standing. An office charges every 25s for the vessel in its berth, priced off that vessel's own build cost so an expensive hull is expensive to keep, and drawn from its own store the way a gas burner draws gas. An office that cannot pay goes dark, its vessel is scuttled, and the player is told through the same queue quest lines use - which reaches them on a planet surface too. An empty berth is idle rather than starved: an office is only failing while there is something in it to fail, and holding it disabled would leave a berth that could never be crewed and so could never be paid for again. Ships have no hit points and a shield absorbs a hit wholesale, so the lost vessel is scuttled rather than damaged - otherwise a shielded hull would survive its own eviction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
247 lines
10 KiB
GDScript
247 lines
10 KiB
GDScript
extends SceneTree
|
|
|
|
# Headless gameplay test: run with
|
|
# godot --headless -s res://tests/test_build_menu_hides_locked.gd
|
|
# Forgejo #21: a structure the player has not researched should not be in the
|
|
# build menu at all, rather than sitting there greyed out.
|
|
#
|
|
# The menu is a fixed grid in Main.tscn, so simply hiding entries would leave
|
|
# holes where they used to be - it is repacked from whatever is researched.
|
|
#
|
|
# Verifies:
|
|
# 1. with nothing researched only the mothership and the Fleet Office are
|
|
# offered (the mothership is
|
|
# deliberately never gated - it is where research happens)
|
|
# 2. researching a structure adds it, and it is placed, not left at
|
|
# whatever offsets it happened to have in the scene file
|
|
# 3. no gaps: what is shown occupies consecutive grid slots
|
|
# 4. an entry alone on its row is centred, not hanging in the left column
|
|
# 5. researched-but-not-usable is still shown greyed (an extractor with no
|
|
# planet under the fleet is "not now", not "not yet")
|
|
# 6. #136 - with every structure researched, no two entries overlap. The
|
|
# grid used to be a fixed list of row positions read with a clamp, so
|
|
# the first row that would not fit was drawn on top of another one
|
|
# 7. #144 - and that holds at any window shape. The header is anchored to
|
|
# the top of the panel and the grid to its centre, so the gap between
|
|
# them is half the panel height - it was a constant measured at 1280,
|
|
# and at 1080 the grid started above the status line and drew through it
|
|
|
|
var game_state: Node
|
|
var scene: Node
|
|
var controller: Node
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
|
|
|
|
func _initialize() -> void:
|
|
game_state = root.get_node("GameState")
|
|
game_state.buildings.clear()
|
|
game_state.extractors.clear()
|
|
game_state.transporters.clear()
|
|
game_state.reset_progression()
|
|
game_state.rocks = 500
|
|
scene = load("res://world/Main.tscn").instantiate()
|
|
root.add_child(scene)
|
|
controller = scene.get_node("BuildController")
|
|
|
|
|
|
func _shown() -> Array:
|
|
var out: Array = []
|
|
for entry in controller.build_menu.entries:
|
|
if entry.button.visible:
|
|
out.append([entry.definition.definition_id, entry.button,
|
|
entry.label, entry.cost, entry.definition])
|
|
return out
|
|
|
|
|
|
func _rect(node: Control) -> Rect2:
|
|
return Rect2(Vector2(node.offset_left, node.offset_top),
|
|
Vector2(node.offset_right - node.offset_left,
|
|
node.offset_bottom - node.offset_top))
|
|
|
|
|
|
func _slots() -> Array:
|
|
# (row, x) of every visible button, in menu order.
|
|
var out: Array = []
|
|
for entry in _shown():
|
|
var b: Control = entry[1]
|
|
out.append([b.offset_top, b.offset_left + (b.offset_right - b.offset_left) / 2.0])
|
|
return out
|
|
|
|
|
|
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)
|
|
|
|
# --- 1. nothing researched ----------------------------------
|
|
controller._refresh_build_status()
|
|
# #219 put the Fleet Office beside the mothership at the opening:
|
|
# it is the only way the fleet grows now, so gating it behind
|
|
# research would gate fleet size behind research again - which is
|
|
# the thing that ticket removed.
|
|
var opening: Array = [GameState.BUILDING_MOTHERSHIP,
|
|
GameState.BUILDING_FLEET_OFFICE]
|
|
var shown: Array = _shown()
|
|
var shown_ids: Array = []
|
|
for e in shown:
|
|
shown_ids.append(e[0])
|
|
shown_ids.sort()
|
|
opening.sort()
|
|
if shown_ids != opening:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (opening offer was %s, expected %s)" \
|
|
% [shown_ids, opening])
|
|
quit(1)
|
|
return true
|
|
# --- 4. and the row is centred as a whole -------------------
|
|
# Two entries now, so "centred" is the pair straddling the middle
|
|
# rather than one sitting on it. The rule being checked is the
|
|
# same one: a short row is not left hanging off to one side.
|
|
var slots: Array = _slots()
|
|
var spread: float = 0.0
|
|
for slot in slots:
|
|
spread += float(slot[1])
|
|
if absf(spread) > 0.01:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the opening row is off-centre by %.1f)" % spread)
|
|
quit(1)
|
|
return true
|
|
print("nothing researched -> the mothership and a Fleet Office, centred")
|
|
|
|
# --- 2. research three more ---------------------------------
|
|
game_state.unlocked = ["shipyards", "logistics", "coordination"]
|
|
controller._refresh_build_status()
|
|
stage = 1
|
|
return false
|
|
1:
|
|
# Three doctrines add three structures to the two the opening
|
|
# already offers (#219's Fleet Office is one of those two).
|
|
var shown2: Array = _shown()
|
|
if shown2.size() != 5:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched 3, expected 5 entries, got %d)" % shown2.size())
|
|
quit(1)
|
|
return true
|
|
for e in shown2:
|
|
if e[0] == GameState.BUILDING_TRANSPORT_HUB:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the unresearched transport hub is still listed)")
|
|
quit(1)
|
|
return true
|
|
|
|
# --- 3. consecutive slots, no holes -------------------------
|
|
var slots: Array = _slots()
|
|
var columns: int = controller.menu_columns(slots.size())
|
|
var rows: int = controller.menu_rows(slots.size())
|
|
if columns != 2:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%d entries laid out in %d columns)" \
|
|
% [slots.size(), columns])
|
|
quit(1)
|
|
return true
|
|
for i in range(slots.size()):
|
|
var row: int = i / columns
|
|
var want_row: float = controller.menu_row_top(row, rows, columns)
|
|
if absf(slots[i][0] - want_row) > 0.01:
|
|
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], row, want_row])
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)")
|
|
quit(1)
|
|
return true
|
|
# Each ROW is centred on however many it holds, so a final row
|
|
# with one entry sits in the middle rather than in column 0.
|
|
# Written the way the layout writes it, so an odd count is not
|
|
# a special case here that is ordinary there.
|
|
var in_row: int = mini(slots.size() - row * columns, columns)
|
|
var spacing: float = controller.menu_column_spacing(columns)
|
|
var want_x: float = (float(i % columns) - (float(in_row) - 1.0) * 0.5) * spacing
|
|
if absf(slots[i][1] - want_x) > 0.01:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x])
|
|
quit(1)
|
|
return true
|
|
print("%d entries packed into consecutive slots, no gaps" % slots.size())
|
|
|
|
# --- 6. #136: nothing overlaps, with EVERY structure on offer -
|
|
# The bug the ticket reported: the row list ran out and the last
|
|
# entry was placed on top of one already there.
|
|
for node in GameState.SKILL_TREE:
|
|
game_state.unlocked.append(String(node.id))
|
|
controller._refresh_build_status()
|
|
var all_shown: Array = _shown()
|
|
if all_shown.size() < 9:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (only %d structures unlocked - not the crowded case)" % all_shown.size())
|
|
quit(1)
|
|
return true
|
|
for i in range(all_shown.size()):
|
|
for j in range(i + 1, all_shown.size()):
|
|
for part in [1, 2, 3]:
|
|
var a: Control = all_shown[i][part]
|
|
var b: Control = all_shown[j][part]
|
|
if _rect(a).intersects(_rect(b)):
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%s and %s overlap at %s / %s)" \
|
|
% [all_shown[i][0], all_shown[j][0], _rect(a), _rect(b)])
|
|
quit(1)
|
|
return true
|
|
print("%d structures on offer, in %d columns, nothing overlapping" \
|
|
% [all_shown.size(), controller.menu_columns(all_shown.size())])
|
|
|
|
# --- 7. #144: and at any window shape ------------------------
|
|
# The grid is anchored to the panel's CENTRE and the status line
|
|
# above it to the panel's TOP, so the gap between them is half
|
|
# the panel height. It used to be a written-down constant, right
|
|
# only at 1280 - at 1080 the grid started above the status line
|
|
# and drew through it, which is what the ticket reported.
|
|
var menu: Control = scene.get_node("UI/BuildMenu")
|
|
var status: Control = scene.get_node("UI/BuildMenu/StatusLabel")
|
|
var restore: float = menu.offset_bottom
|
|
for panel_height in [900.0, 1080.0, 1280.0, 1600.0]:
|
|
menu.anchor_bottom = 0.0
|
|
menu.offset_top = 0.0
|
|
menu.offset_bottom = panel_height
|
|
controller._refresh_build_status()
|
|
var half: float = menu.size.y * 0.5
|
|
# The status line's own bottom, in the grid's centre-anchored
|
|
# frame - which is the comparison that was never made.
|
|
var header_bottom: float = status.offset_bottom - half
|
|
var placed: Array = _shown()
|
|
for a in range(placed.size()):
|
|
for part in [1, 2, 3]:
|
|
var box: Rect2 = _rect(placed[a][part])
|
|
if box.position.y < header_bottom:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s is drawn at y=%.0f, above the status line at %.0f)" \
|
|
% [panel_height, placed[a][0], box.position.y, header_bottom])
|
|
quit(1)
|
|
return true
|
|
for b in range(a + 1, placed.size()):
|
|
for part2 in [1, 2, 3]:
|
|
if _rect(placed[a][part2]).intersects(_rect(placed[b][part2])):
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s and %s overlap)" \
|
|
% [panel_height, placed[a][0], placed[b][0]])
|
|
quit(1)
|
|
return true
|
|
print(" %.0fpx tall: %d columns, top row at %.0f, clear of the status line at %.0f" \
|
|
% [panel_height, controller.menu_columns(placed.size()),
|
|
_rect(placed[0][1]).position.y, header_bottom])
|
|
menu.anchor_bottom = 1.0
|
|
menu.offset_top = 0.0
|
|
menu.offset_bottom = restore
|
|
controller._refresh_build_status()
|
|
print("the grid clears the header at every window shape")
|
|
|
|
# --- 5. researched but not usable is shown, greyed ----------
|
|
var extractor_shown: bool = false
|
|
for e in shown2:
|
|
if e[0] == GameState.BUILDING_EXTRACTOR:
|
|
extractor_shown = true
|
|
if e[1].enabled:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (extractor offered as usable with no planet under the fleet)")
|
|
quit(1)
|
|
return true
|
|
if not extractor_shown:
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched extractor hidden - 'not now' is not 'not yet')")
|
|
quit(1)
|
|
return true
|
|
print("researched extractor shown but greyed, with no planet in reach")
|
|
print("TEST_BUILD_MENU_HIDES_LOCKED: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|