OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / keymap_gate.gd

460 lines · gd ref:
extends Node
## HEADLESS GATE — the keymap layer holds the properties it exists for.
##
## The audit (scenes/test/keymap_audit.gd) reports what IS bound; this asserts the
## rules that stop the next Ctrl+Z-shaped bug:
##
##   1. every declared action is really registered with the engine;
##   2. no two actions in one context share a key (the thing nobody can grep for);
##   3. a plain-key action does NOT fire while a modifier is held — Godot's default
##      is the opposite, and that is exactly how undo also toggled slow motion;
##   4. a context that is not active dispatches nothing;
##   5. a rebind is refused when it would collide, accepted when it would not, and
##      survives a reset.
##
## Run as a SCENE (autoload singletons are not registered when --script compiles):
##   "$HYPER_TPS_GODOT" --headless res://scenes/test/keymap_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 _eqi(what: String, got: int, want: int) -> void:
	var c := got == want
	if not c:
		fails += 1
	print("  %-58s %-6d want %-6d %s" % [what, got, want, "OK" if c 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**"])


## Which project actions claim this key with NO modifier held. The question "is this
## key free" should have an answer that does not depend on grepping carefully enough.
func _claims_bare(key: int) -> PackedStringArray:
	var out := PackedStringArray()
	for action: StringName in InputMap.get_actions():
		if String(action).begins_with("ui_"):
			continue
		for ev: InputEvent in InputMap.action_get_events(action):
			if ev is InputEventKey:
				var k := ev as InputEventKey
				if k.physical_keycode == key and not k.ctrl_pressed \
						and not k.shift_pressed and not k.alt_pressed:
					out.append(String(action))
	return out


## A synthetic press, so matching can be measured instead of assumed.
func _lmb() -> InputEventMouseButton:
	var e := InputEventMouseButton.new()
	e.button_index = MOUSE_BUTTON_LEFT
	e.pressed = true
	return e


func _rmb() -> InputEventMouseButton:
	var e := InputEventMouseButton.new()
	e.button_index = MOUSE_BUTTON_RIGHT
	e.pressed = true
	return e


func _rmb_up() -> InputEventMouseButton:
	var e := _rmb()
	e.pressed = false
	return e


func _lmb_up() -> InputEventMouseButton:
	var e := _lmb()
	e.pressed = false
	return e


func _press(key: int, ctrl := false, shift := false, alt := false) -> InputEventKey:
	var e := InputEventKey.new()
	# BOTH, as a real key event carries both — QWERTY, where character = position. A
	# position-only event could never match a letter, which binds by character (§6).
	e.physical_keycode = key
	e.keycode = key
	e.pressed = true
	e.ctrl_pressed = ctrl
	e.shift_pressed = shift
	e.alt_pressed = alt
	return e


## The synthetic event a declared binding is pressed with.
func _event_for(b: Dictionary) -> InputEvent:
	if b.has("mouse"):
		var m := InputEventMouseButton.new()
		m.button_index = int(b["mouse"])
		m.pressed = true
		return m
	return _press(int(b.get("key", 0)), bool(b.get("ctrl", false)),
			bool(b.get("shift", false)), bool(b.get("alt", false)))


## Every declared action that a press of `b` fires on the CURRENT stack.
func _fired_by(km: Node, b: Dictionary) -> PackedStringArray:
	var ev := _event_for(b)
	var out := PackedStringArray()
	for row: Dictionary in km.bindings():
		var a := String(row["action"])
		if km.tapped(ev, a):
			out.append(a)
	return out


func _ready() -> void:
	await get_tree().process_frame
	print("=== Keymap gate ===\n")
	var km: Node = get_tree().root.get_node_or_null("/root/Keymap")
	if km == null:
		printerr("Keymap autoload missing")
		get_tree().quit(1)
		return

	print("-- every declared action is registered --")
	var declared: Array = km.bindings()
	_ok("the builder context declares actions", declared.size() >= 10)
	var missing: Array[String] = []
	for b: Dictionary in declared:
		if not InputMap.has_action(String(b["action"])):
			missing.append(String(b["action"]))
	_ok("all present in InputMap", missing.is_empty())
	# Blender's since keymap step 3 (docs/keymap-blender-279.md §3a).
	_eqs("undo reads as", String(km.describe("builder.undo")), "Ctrl+Z")
	_eqs("redo reads as", String(km.describe("builder.redo")), "Ctrl+Shift+Z")

	print("\n-- no two actions in one context share a key --")
	var cl: Array = km.conflicts()
	if not cl.is_empty():
		for c: String in cl:
			print("      %s" % c)
	_ok("no in-context conflicts", cl.is_empty())

	print("\n-- EXACT matching: a plain key does not fire with a modifier --")
	km.push_context("builder")
	km.push_context("build_mode")      # the editor tools are build-mode only
	# KEYMAP STEP 3 (docs/keymap-blender-279.md §3a): Blender's keys, exact-matched.
	_ok("K is the knife", km.tapped(_press(KEY_K), "builder.knife"))
	_ok("Ctrl+K is NOT the knife", not km.tapped(_press(KEY_K, true), "builder.knife"))
	_ok("build-mode carve has no key (menu / toolbar only)", not km.binding_of("builder.carve").has("key"))
	_ok("B is box select", km.tapped(_press(KEY_B), "builder.select_box"))
	_ok("A toggles select all", km.tapped(_press(KEY_A), "builder.select_all"))
	_ok("Shift+A is the add menu, not select all", km.tapped(_press(KEY_A, false, true), "builder.add_menu")
		and not km.tapped(_press(KEY_A, false, true), "builder.select_all"))
	_ok("S scales", km.tapped(_press(KEY_S), "builder.scale"))
	# Ctrl+Z since step 1 made Ctrl "descend" only while flying (and `fly` suppresses
	# every command). Exact matching keeps Ctrl+Z, Ctrl+Shift+Z and Z three actions.
	_ok("Ctrl+Z undoes", km.tapped(_press(KEY_Z, true), "builder.undo"))
	_ok("Ctrl+Shift+Z does NOT undo — exact match",
		not km.tapped(_press(KEY_Z, true, true), "builder.undo"))
	_ok("Ctrl+Shift+Z redoes", km.tapped(_press(KEY_Z, true, true), "builder.redo"))
	_ok("plain Z is the wireframe toggle, not undo", km.tapped(_press(KEY_Z), "builder.wireframe")
		and not km.tapped(_press(KEY_Z), "builder.undo"))
	km.pop_context("build_mode")

	# The regression that shipped was Ctrl+Z ALSO firing toggle_slowmo, which was
	# bound to plain Z, because Godot matches actions non-exact by default.
	#
	# Since keymap step 3 plain Z is Blender's wireframe toggle and undo is Ctrl+Z. The
	# shadow this guards against is unchanged: a SECOND action on plain Z would fire
	# together with it. Exactly one owner.
	print("\n-- plain Z belongs to the wireframe toggle, and to nothing else --")
	_ok("toggle_slowmo no longer exists", not InputMap.has_action("toggle_slowmo"))
	var z_claims: Array[String] = []
	for action: StringName in InputMap.get_actions():
		if String(action).begins_with("ui_"):
			continue
		for ev: InputEvent in InputMap.action_get_events(action):
			# Either field: a letter binds by CHARACTER (keycode) since keymap step 5.
			if ev is InputEventKey and ((ev as InputEventKey).physical_keycode == KEY_Z
					or (ev as InputEventKey).keycode == KEY_Z) \
					and not (ev as InputEventKey).ctrl_pressed \
					and not (ev as InputEventKey).shift_pressed:
				z_claims.append(String(action))
	if z_claims.size() != 1:
		print("      claimed by: %s" % ", ".join(z_claims))
	_eqi("exactly one action owns plain Z", z_claims.size(), 1)
	_ok("and it is the wireframe toggle", z_claims.size() == 1 and z_claims[0] == "builder.wireframe")

	print("\n-- EXCLUSIVITY: a mode suppresses the layer under it --")
	# The gap this closes: before exclusivity the builder context only ADDED
	# bindings, so every player action stayed live while building — you could jump
	# and shoot while placing geometry, and `B` could not safely mean two things.
	km.push_context("player")
	# Against build_mode, which is the context that MEANS to suppress. "builder" is
	# additive on purpose: the level map pushes it while you are still a player, and
	# making it exclusive stopped toggle_light and respawn working on that map.
	km.push_context("builder")
	_ok("the additive builder layer does NOT suppress the player",
		km.is_context_active("player"))
	_ok("so a player action still fires with the dev keys layered on",
		km.tapped(_press(KEY_X), "toggle_light"))
	km.push_context("build_mode")
	_ok("build_mode is active", km.is_context_active("build_mode"))
	_ok("player is SUPPRESSED under it", not km.is_context_active("player"))
	_ok("global survives regardless", km.is_context_active("global"))
	_ok("a player action does not fire in build mode",
		not km.tapped(_press(KEY_X), "toggle_light"))
	_ok("a global action still does",
		km.tapped(_press(KEY_ESCAPE), "ui_cancel"))
	# Chat is a PLAYER action since 2026-10-18 ("t still brings up chat menu in editor
	# which is very wrong"), so build mode suppresses it like any other.
	_ok("chat (T) does not fire in build mode",
		not km.tapped(_press(KEY_T), "toggle_chat"))
	km.pop_context("build_mode")
	_ok("popping the mode restores the player", km.is_context_active("player"))
	_ok("and the player action fires again", km.tapped(_press(KEY_X), "toggle_light"))

	print("\n-- an inactive context dispatches nothing --")
	km.pop_context("builder")            # the section above leaves it pushed
	_ok("K does not knife while not building", not km.tapped(_press(KEY_K), "builder.knife"))
	# Re-pushing must MOVE the entry, not stack a second copy: a map that reloads
	# would otherwise leave a stale "builder" underneath, and one pop would not
	# actually leave the mode.
	km.push_context("builder")
	km.push_context("builder")
	km.pop_context("builder")
	_ok("re-pushing does not duplicate the stack entry", not km.is_context_active("builder"))
	km.push_context("builder")

	print("\n-- the F5 and K double-bindings are gone --")
	# Both were hardcoded keycode comparisons, so no table could see them: F5 was
	# `respawn` AND fly-mode; K was the level editor's carve AND the climb-debug
	# visual. WHERE those keys ended up is asserted in mode_gate.gd, which owns the
	# F-row layout — restating it here would be two places to update. What belongs
	# here is that the collisions themselves cannot recur.
	_eqs("the knife owns K (keymap step 3)", String(km.describe("builder.knife")), "K")
	_ok("K does not fire climb debug", not km.tapped(_press(KEY_K), "player.climb_debug"))
	_ok("nothing is bound to bare F5 any more", _claims_bare(KEY_F5).is_empty())
	# F3 stopped being a MODE key — the mode collapsed onto F4 alone — and since keymap
	# step 3 the palette is Space (Blender_27x.py spacebar_action='SEARCH').
	_ok("bare F3 is free (search moved to Space)", _claims_bare(KEY_F3).is_empty())
	_eqs("search is Space", String(km.describe("builder.palette")), "Space")
	_ok("F4 is the mode toggle, alone",
		_claims_bare(KEY_F4) == PackedStringArray(["global.mode_toggle"]))

	print("\n-- the help screen can describe every bound action --")
	# A key list built from Keymap.bindings() is only useful if bindings() also covers
	# the actions whose keys live in project.godot — movement most of all.
	var listed: Dictionary = {}
	var blank: Array[String] = []
	var menu_only: Array[String] = []
	for b: Dictionary in km.bindings():
		listed[String(b["action"])] = true
		if String(b["keys"]) == "":
			# MENU-ONLY by design (no key, no button — the 3D cursor's snap commands, run
			# from the Shift+S menu): nothing to resolve. Anything else blank is a gap.
			var raw: Dictionary = km.binding_of(String(b["action"]))
			if not raw.is_empty() and not raw.has("key") and not raw.has("mouse"):
				menu_only.append(String(b["action"]))
			else:
				blank.append(String(b["action"]))
	if not menu_only.is_empty():
		print("      menu-only (no key by design): %s" % ", ".join(menu_only))
	_ok("movement is listed", listed.has("move_forward") and listed.has("jump"))
	_ok("builder actions are listed", listed.has("builder.carve"))
	_ok("help itself is listed", listed.has("global.help"))
	if not blank.is_empty():
		print("      no key resolved for: %s" % ", ".join(blank))
	_ok("every listed action resolves to a key", blank.is_empty())
	_eqs("and help is on F1", String(km.describe("global.help")), "F1")

	print("\n-- the overlay itself renders, not just its data --")
	# Asserting bindings() is not the same as asserting the SCREEN works: a BBCode
	# typo or a null label would leave a blank panel and pass every check above.
	var help: Node = get_tree().root.get_node_or_null("/root/HelpOverlay")
	_ok("the overlay is autoloaded", help != null)
	if help != null:
		help.show_help()
		# Keymap step 4: F1 shows the ACTIVE contexts; "show every context" all of them
		# (gate_help_contexts owns the active-only behaviour).
		help.set_show_all(true)
		var body: String = ""
		for child: Node in help.find_children("*", "RichTextLabel", true, false):
			body = (child as RichTextLabel).text
		_ok("it is visible", bool(help.visible))
		_ok("it lists a movement key", body.contains("Move forward"))
		_ok("it lists a builder key", body.contains("Knife"))
		_ok("it groups by context", body.contains("PLAYING") and body.contains("EDITOR TOOLS"))
		help.set_show_all(false)
		_ok("it says how to close itself", body.contains("F1"))
		help.hide_help()
		_ok("and it closes", not help.visible)

	print("\n-- rebinding --")
	km.push_context("build_mode")
	# The KNIFE as the example since keymap step 3 (carve has no key now). B is box select.
	_eqs("a colliding rebind is refused, with a reason",
		String(km.rebind("builder.knife", KEY_B)).left(13), "that key is a")
	_eqs("the knife is unchanged", String(km.describe("builder.knife")), "K")
	# Y, not J: J is build_mode.csg_merge, live at the same time since the editor
	# tools became build-mode only -- the rebind check is per context and would
	# accept it, and the same-moment section below would then (rightly) fail. (It was
	# M until M became Move to collection, #120 F1.) Y is bound nowhere.
	_eqs("a free key is accepted", String(km.rebind("builder.knife", KEY_Y)), "")
	_eqs("and takes effect", String(km.describe("builder.knife")), "Y")
	_ok("Y now is the knife", km.tapped(_press(KEY_Y), "builder.knife"))
	_ok("K no longer is", not km.tapped(_press(KEY_K), "builder.knife"))
	km.reset_binding("builder.knife")
	_eqs("reset restores the default", String(km.describe("builder.knife")), "K")
	_ok("K is the knife again", km.tapped(_press(KEY_K), "builder.knife"))
	km.pop_context("build_mode")

	print("\n-- THE MOUSE: left fires the carver, right grapples (key audit 2026-10-01) --")
	# The carver is a WEAPON now, not an editor action leaking into play: LMB fires
	# it, RMB is the grapple (moved off LMB). Both belong to `player`, so in build
	# mode -- where RMB-hold is the look and LMB selects -- neither fires.
	km.push_context("player")
	_ok("left click fires the carver while playing", km.tapped(_lmb(), "player.fire"))
	_ok("left click does NOT grapple", not km.tapped(_lmb(), "grapple"))
	_ok("right click grapples while playing", km.tapped(_rmb(), "grapple"))
	_ok("and the grapple release still matches", km.released(_rmb_up(), "grapple"))
	_ok("right click does NOT fire", not km.tapped(_rmb(), "player.fire"))
	km.push_context("build_mode")
	_ok("in build mode left click fires nothing of the player's",
		not km.tapped(_lmb(), "player.fire") and not km.tapped(_lmb(), "grapple"))
	_ok("nor does right click (it is the look there)", not km.tapped(_rmb(), "grapple"))
	km.pop_context("build_mode")
	_ok("both come back when you leave build mode",
		km.tapped(_lmb(), "player.fire") and km.tapped(_rmb(), "grapple"))

	print("\n-- A COMMAND NEVER FIRES WHILE FLYING (keymap step 1) --")
	# The CSG keys shipped as Ctrl+D / Ctrl+I / Ctrl+M and every one of them also
	# dropped you through the floor, because Ctrl was descend whenever you were in build
	# mode — descend is POLLED, so no input handler can consume it. The fix used to be
	# "no Ctrl / Shift in build_mode". Since keymap step 1 (docs/keymap-blender-279.md
	# §2a) the camera only moves while FLYING (RMB held / the Shift+F modal), and the
	# `fly` context is exclusive — so Ctrl is descend ONLY while no command can fire.
	# Assert that shape: fly suppresses every command; outside it Ctrl chords work.
	km.push_context("build_mode")
	_ok("the fly context is exclusive", bool(km.CONTEXTS.get("fly", {}).get("exclusive", false)))
	_ok("Ctrl+Numpad − subtracts (not flying)", km.tapped(_press(KEY_KP_SUBTRACT, true), "build_mode.csg_subtract"))
	_ok("Ctrl+Numpad * intersects", km.tapped(_press(KEY_KP_MULTIPLY, true), "build_mode.csg_intersect"))
	_ok("Ctrl+Numpad / slices", km.tapped(_press(KEY_KP_DIVIDE, true), "build_mode.csg_slice"))
	_ok("plain Numpad − does NOT subtract (exact matching)", not km.tapped(_press(KEY_KP_SUBTRACT), "build_mode.csg_subtract"))
	km.push_context("fly")
	_ok("WHILE FLYING Ctrl+Numpad − does nothing (descend + command cannot collide)",
		not km.tapped(_press(KEY_KP_SUBTRACT, true), "build_mode.csg_subtract"))
	_ok("... nor does any build_mode key", not km.tapped(_press(KEY_J), "build_mode.csg_merge"))
	km.pop_context("fly")
	# H IS BLENDER'S HIDE since 2026-10-12 (docs/cutting-and-operations.md §9c).
	_ok("H is hide, a plain key", int(km.binding_of("builder.hide").get("key", 0)) == KEY_H
		and not bool(km.binding_of("builder.hide").get("ctrl", false)))
	_ok("H no longer subtracts", not km.tapped(_press(KEY_H), "build_mode.csg_subtract"))
	_ok("I no longer intersects (free for inset)", not km.tapped(_press(KEY_I), "build_mode.csg_intersect"))
	_ok("J convex-merges", km.tapped(_press(KEY_J), "build_mode.csg_merge"))
	km.pop_context("build_mode")
	_ok("none of them fire outside build mode",
		not km.tapped(_press(KEY_KP_SUBTRACT, true), "build_mode.csg_subtract"))

	print("\n-- THE EDITOR TOOLS ARE BUILD-MODE ONLY (key audit 2026-10-01) --")
	# `builder` used to be ADDITIVE over play on the level map, so K B C G E N F V L
	# Z [ ] - = were live while walking -- L fired lizard-debug AND entity links, and
	# any future gameplay letter would have collided. It now requires build_mode.
	km.push_context("player")
	km.push_context("builder")          # the level map pushes it while loaded
	_ok("K does not knife the level while playing", not km.tapped(_press(KEY_K), "builder.knife"))
	_ok("L does not select linked while playing", not km.tapped(_press(KEY_L), "builder.select_linked_pick"))
	_ok("- does not change the grid while playing", not km.tapped(_press(KEY_MINUS), "builder.grid_finer"))
	km.push_context("build_mode")
	_ok("in build mode K is the knife", km.tapped(_press(KEY_K), "builder.knife"))
	_ok("in build mode - is the grid", km.tapped(_press(KEY_MINUS), "builder.grid_finer"))   # - = finer, = = coarser
	km.pop_context("build_mode")

	print("\n-- THE CARVER'S KEYS, WHILE PLAYING --")
	_ok("1 picks the box brush", km.tapped(_press(KEY_1), "player.csg_box"))
	_ok("Q toggles remove / add", km.tapped(_press(KEY_Q), "player.carve_mode"))
	_ok("- makes the brush smaller", km.tapped(_press(KEY_MINUS), "player.carve_smaller"))
	_ok("= makes it bigger", km.tapped(_press(KEY_EQUAL), "player.carve_bigger"))
	_ok("Shift is no longer part of any carve binding",
		not String(km.describe("player.fire")).contains("Shift"))
	km.push_context("build_mode")
	_ok("in build mode 1 is select-vertex, not the box brush",
		km.tapped(_press(KEY_1), "build_mode.sel_vertex") and not km.tapped(_press(KEY_1), "player.csg_box"))
	_ok("and - is the grid, not the brush size",
		not km.tapped(_press(KEY_MINUS), "player.carve_smaller"))
	km.pop_context("build_mode")

	print("\n-- ONE PRESS, ONE ACTION: every declared binding, in play AND in build mode --")
	# The four double-fires the audit found were all ACROSS contexts that are live at
	# the same moment (global + player + builder), which the per-context conflict
	# check cannot see by construction. So press every declared binding on the real
	# stack of each mode and count what fires.
	var states := {"play (level map)": ["player", "builder"], "build (level map)": ["player", "builder", "build_mode"]}
	for state: String in states:
		for c: String in ["player", "builder", "build_mode"]:
			km.pop_context(c)
		for c: String in states[state]:
			km.push_context(c)
		var doubles: Array[String] = []
		for row: Dictionary in km.bindings():
			var a := String(row["action"])
			var b: Dictionary = km.binding_of(a)
			if b.is_empty():
				continue                      # project.godot actions: no key to synthesise here
			var fired := _fired_by(km, b)
			if fired.size() > 1:
				doubles.append("%s fires %s" % [km.describe(a), ", ".join(fired)])
		for d: String in doubles:
			print("      %s" % d)
		_eqi("%s: presses that fire more than one action" % state, doubles.size(), 0)
	for c: String in ["builder", "build_mode"]:
		km.pop_context(c)

	print("\n-- NO KEY IS READ OUTSIDE THE KEYMAP (rule 3), except where allowed --")
	# The double-fires were all hardcoded `keycode ==` checks no table could see.
	# Allowed: modal operators (their keys only exist while the tool is up), the two
	# dev maps with their own key walls, Escape handlers, and third-party addons.
	var allowed := ["res://scenes/test/", "res://scenes/maps/operators/",
			"res://scenes/maps/map_geiss.gd",   # map_dc_terrain migrated 2026-10-01: it SHIPS
			"res://addons/nubis_clouds/", "res://addons/SunshineClouds2/",
			"res://addons/editor_screenshot/", "res://autoloads/keymap.gd"]
	var re := RegEx.create_from_string("\\.(physical_)?keycode\\s*(==|!=)\\s*KEY_(?!ESCAPE)|match\\s+[^\\n]*keycode|is_key_pressed\\(KEY_(?!ALT|SHIFT|CTRL|META)")
	var raw: Array[String] = []
	for root: String in ["res://scenes", "res://autoloads", "res://addons"]:
		_scan(root, allowed, re, raw)
	for r: String in raw:
		print("      %s" % r)
	_eqi("raw key reads outside the keymap", raw.size(), 0)

	print("\n%s" % ("FAILED" if fails else "PASS"))
	get_tree().quit(1 if fails else 0)


func _scan(dir: String, allowed: Array, re: RegEx, out: Array[String]) -> void:
	for a: String in allowed:
		if (dir + "/").begins_with(a):
			return
	var d := DirAccess.open(dir)
	if d == null:
		return
	for sub: String in d.get_directories():
		_scan(dir.path_join(sub), allowed, re, out)
	for f: String in d.get_files():
		if not f.ends_with(".gd"):
			continue
		var path := dir.path_join(f)
		if path in allowed:
			continue
		var lines := FileAccess.get_file_as_string(path).split("\n")
		for i in lines.size():
			var l := lines[i].strip_edges()
			if l.begins_with("#"):
				continue
			if re.search(lines[i]) != null:
				out.append("%s:%d  %s" % [path, i + 1, l.left(80)])