OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / mode_gate.gd

234 lines · gd ref:
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)