extends Node
## HEADLESS GATE — modes, and the things a mode is supposed to imply.
##
## A mode is not just a label: entering build must take the player's legs away (the
## exclusive keymap context), show the build UI, and be reachable and leavable from
## inside itself. Each of those is a separate way to get it wrong, so each is asserted.
##
## Run: "$HYPER_TPS_GODOT" --headless res://scenes/test/mode_gate.tscn
var fails := 0
func _ok(what: String, cond: bool) -> void:
if not cond:
fails += 1
print(" %-58s %s" % [what, "OK" if cond else "**FAIL**"])
func _eqs(what: String, got: String, want: String) -> void:
var c := got == want
if not c:
fails += 1
print(" %-58s %-14s want %-14s %s" % [what, got, want, "OK" if c else "**FAIL**"])
func _ready() -> void:
await get_tree().process_frame
print("=== Mode gate ===\n")
# ONE key for the mode. mode_play (F2) and mode_build (F3) are GONE: three keys
# for two states, and Tab additionally collided with Godot's ui_focus_next once
# the editor grew focusable panels, which trapped you in build mode.
print("-- one key enters and leaves build mode --")
_eqs("toggle", String(Keymap.describe("global.mode_toggle")), "F4")
_ok("mode_play is retired", String(Keymap.describe("global.mode_play")) == "")
_ok("mode_build is retired", String(Keymap.describe("global.mode_build")) == "")
_eqs("help stays on F1", String(Keymap.describe("global.help")), "F1")
print("\n-- the row was cleared to make room, by rule --")
_eqs("respawn is off the row", String(Keymap.describe("respawn")), "Backspace")
_eqs("metrics gained Ctrl (F3 is build now)", String(Keymap.describe("toggle_metrics")), "Ctrl+F3")
_eqs("climb debug", String(Keymap.describe("player.climb_debug")), "Ctrl+F6")
_eqs("camera unstick", String(Keymap.describe("player.camera_unstick")), "Ctrl+F8")
_ok("fly has no key of its own — build mode IS fly",
String(Keymap.describe("player.fly")) == "")
_ok("and no action claims bare F3 twice", Keymap.conflicts().is_empty())
print("\n-- switching --")
# The level map pushes `builder` while it is loaded; this harness has no map, so it
# stands in for one. Build mode is only ever entered from there.
Keymap.push_context("builder")
_eqs("starts in play", String(GameMode.current), "play")
_ok("play is not an exclusive context", Keymap.is_context_active("player"))
GameMode.set_mode(GameMode.BUILD)
_eqs("now building", String(GameMode.current), "build")
_ok("build_mode context is active", Keymap.is_context_active("build_mode"))
_ok("the player is suppressed — you have no legs", not Keymap.is_context_active("player"))
_ok("jump does nothing here", not Keymap.tapped(_press(KEY_SPACE), "jump"))
# ...but the tools the mode exists for MUST survive it.
_ok("the editing keys are KEPT", Keymap.is_context_active("builder"))
# K is the knife since keymap step 3 (build-mode carve is menu / toolbar only).
_ok("the editor tools still work while building", Keymap.tapped(_press(KEY_K), "builder.knife"))
_ok("but help still works", Keymap.is_context_active("global"))
print("\n-- setting is idempotent, toggling is not a parity --")
GameMode.set_mode(GameMode.BUILD)
_eqs("pressing build again changes nothing", String(GameMode.current), "build")
_ok("and the context stack did not accumulate",
Keymap.active_contexts().count("build_mode") == 1)
GameMode.toggle()
_eqs("Tab leaves", String(GameMode.current), "play")
_ok("legs are back", Keymap.is_context_active("player"))
GameMode.toggle()
_eqs("Tab returns", String(GameMode.current), "build")
# THE BUILD UI MOVED INTO A DOCK. BuildHud still GENERATES the key list from
# Keymap.bindings() — that property is the point and is unchanged — but it no
# longer draws its own overlay: it registers content into BuildDock, which owns
# where panels sit, their chrome, their height cap and their visibility. So the
# assertions below ask the DOCK whether the UI is up, and search the dock for
# the generated text. Asking BuildHud.visible now measures nothing.
print("\n-- the build UI exists, is visible, and lists live keys only --")
var hud: Node = get_tree().root.get_node_or_null("/root/BuildHud")
var dock: Node = get_tree().root.get_node_or_null("/root/BuildDock")
_ok("BuildHud is autoloaded", hud != null)
_ok("BuildDock is autoloaded", dock != null)
if dock != null:
_ok("visible in build mode", bool(dock.visible))
# The status line is the STATUS BAR now (editor redesign E1), not a panel.
_ok("the status bar carries the mode", String(hud.status_text()).contains("BUILD"))
_ok("it has the hotkeys panel", bool(dock.has_panel("hotkeys")))
var body := ""
if dock.has_panel("hotkeys"):
body = (dock._panels["hotkeys"]["body"] as RichTextLabel).text
_ok("it lists a builder key", body.contains("Knife")) # carve has no key since step 3
_ok("it lists the grid keys", body.contains("grid") or body.contains("Grid"))
_ok("it does NOT list suppressed player actions", not body.contains("Jump"))
GameMode.set_mode(GameMode.PLAY)
_ok("and it hides when you stop building", not bool(dock.visible))
print("\n-- THE BRUSH VIEW: a split is invisible in the mesh, visible in the wireframe --")
# The reported bug was "C does nothing, Delete works". A split produces two
# brushes that tile the original exactly, so the RENDERED MESH IS UNCHANGED — the
# cut was fine and there was no feedback. This asserts the fix at the level that
# matters: same triangles, more edges.
var lvl: Node3D = ClassDB.instantiate("DCLevel")
add_child(lvl)
await get_tree().process_frame
var planes := PackedFloat32Array([
-1, 0, 0, 0, 1, 0, 0, 10,
0, -1, 0, 0, 0, 1, 0, 4,
0, 0, -1, 0, 0, 0, 1, 1])
var wall: int = lvl.add_brush(planes, 1, PackedInt32Array(), 0, false, "")
lvl.rebuild()
var area_before: float = float((lvl.get_stats() as Dictionary)["surface_area"])
var tris_before: int = int((lvl.get_stats() as Dictionary)["triangles"])
var edges_before: int = lvl.get_brush_edges().size()
_ok("the wall has a wireframe", edges_before > 0)
_ok("cut it", lvl.split_brush(wall, Vector3(1, 0, 0), 4.0, "gate:1") > 0)
lvl.rebuild()
var area_after: float = float((lvl.get_stats() as Dictionary)["surface_area"])
var tris_after: int = int((lvl.get_stats() as Dictionary)["triangles"])
var edges_after: int = lvl.get_brush_edges().size()
print(" area %.3f -> %.3f, triangles %d -> %d, wireframe points %d -> %d"
% [area_before, area_after, tris_before, tris_after, edges_before, edges_after])
# THE SURFACE is what the player sees, and it is unchanged — same shape, same
# texture, nothing to notice. (The TRIANGLE COUNT does move: the same surface is
# re-tessellated as two brushes minus the hidden shared pair. Measured 12 -> 20,
# which corrects "the mesh is byte-identical" — it is the same SURFACE, not the
# same triangles.) That is why a correct cut read as a broken tool.
_ok("the visible SURFACE is unchanged — this is why C looked broken",
absf(area_after - area_before) < 1e-3)
_ok("but the WIREFRAME gained edges — now you can see the cut",
edges_after > edges_before)
_ok("one brush's outline is a subset of the level's",
lvl.get_brush_edges_of(wall).size() < edges_after)
# Plain statements, not an inline lambda: a multi-line lambda inside a call hangs
# the headless run instead of failing to parse. Learned once already in this gate.
var edges_live: int = lvl.get_brush_edges().size()
lvl.remove_brush(wall)
_ok("and a deleted brush stops being outlined",
lvl.get_brush_edges().size() < edges_live)
lvl.queue_free()
print("\n-- VERTEX HANDLES: distinct corners, deduped, and they follow the shape --")
# Handles are what you aim at. Every corner appears once per face meeting there, so
# without the dedup a cube would offer 24 handles for 8 corners.
var verts: PackedVector3Array = lvl.get_brush_vertices_of(lvl.brush_by_key("gate:1"))
_ok("the cut-off half has corners", verts.size() > 0)
var dup := false
for i in range(verts.size()):
for j in range(i + 1, verts.size()):
if verts[i].distance_to(verts[j]) < 1e-3:
dup = true
_ok("no duplicates among them", not dup)
_ok("a box has eight", verts.size() == 8)
_ok("handles are a subset of the wireframe points",
verts.size() * 2 <= lvl.get_brush_edges_of(lvl.brush_by_key("gate:1")).size())
print("\n-- BUILD MODE IS A CAMERA, NOT A FLYING PLAYER --")
# The report: "tab fly mode doesn't properly switch to player-less flying mode".
# It was flying — set_fly_mode was being called — but you were still a BODY: third
# person, your own model in front of the crosshair, and a collider that still
# blocked people. Three things make it a camera, and each must restore exactly.
var ps: PackedScene = load("res://scenes/player/player_procedural.tscn")
var ply: Node = ps.instantiate()
add_child(ply)
await get_tree().process_frame
var arm: SpringArm3D = ply.get_node_or_null("CameraHead/CameraPivot/SpringArm3D")
var model: Node3D = ply.get_node_or_null("Model")
var stand: CollisionShape3D = ply.get_node_or_null("CollisionStanding")
_ok("the player scene loads standalone", arm != null and model != null and stand != null)
if arm != null:
var arm_before: float = arm.spring_length
var stand_before: bool = stand.disabled
_ok("third person to begin with", arm_before > 0.0)
_ok("and visible", model.visible)
ply.set_fly_mode(true)
_ok("first person while building", is_equal_approx(arm.spring_length, 0.0))
_ok("the body is hidden", not model.visible)
_ok("and it no longer collides with anyone", stand.disabled)
ply.set_fly_mode(false)
_ok("the camera comes back", is_equal_approx(arm.spring_length, arm_before))
_ok("so does the body", model.visible)
_ok("and the collider, exactly as it was", stand.disabled == stand_before)
ply.queue_free()
print("\n-- THE SEAM: does GameMode actually TELL the player? --")
# The gap in my own testing. The section above proves the player does the right
# thing WHEN TOLD; this proves it gets told. GameMode looks for Game/Players and
# calls set_fly_mode on the authority child — a path that exists nowhere else in
# the tests, so it could have been wrong in any of three ways (node path, the
# authority check, the has_method guard) and every other gate would still pass.
var game := Node.new()
game.name = "Game"
var players := Node3D.new()
players.name = "Players"
game.add_child(players)
get_tree().root.add_child(game)
var spy := FlySpy.new()
spy.name = "SpyPlayer"
players.add_child(spy)
await get_tree().process_frame
GameMode.set_mode(GameMode.PLAY)
spy.calls.clear()
GameMode.set_mode(GameMode.BUILD)
_ok("entering build told the player to fly", spy.calls == [true])
GameMode.set_mode(GameMode.PLAY)
_ok("leaving told it to stop", spy.calls == [true, false])
game.queue_free()
print("\n%s" % ("FAILED" if fails else "PASS"))
get_tree().quit(1 if fails else 0)
func _press(key: int) -> InputEventKey:
var e := InputEventKey.new()
e.physical_keycode = key
e.keycode = key # both, as a real (QWERTY) key event (keymap step 5)
e.pressed = true
return e
## Stands in for the local player: records what GameMode asks of it.
class FlySpy extends Node3D:
var calls: Array = []
func set_fly_mode(enabled: bool) -> void:
calls.append(enabled)