OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_input_sync_root.gd

89 lines · gd ref:
extends SceneTree
## Gate: every MultiplayerSynchronizer's replication paths resolve from its root.
##
## THE BUG THIS EXISTS FOR. player_procedural.tscn's InputSynchronizer carried no
## root_path, so it defaulted to ".." -- the InputSync node it hangs under. Its
## config paths are "InputSync:input_direction" and friends, which then resolve
## RELATIVE TO InputSync, i.e. it looked for a child named InputSync inside
## InputSync. Never found, every frame, for as long as a peer was connected:
##
##     ERROR: Node 'InputSync:wants_jump' not found.  (_get_prop_target)
##     ERROR: Unable to retrieve sync state.          (_send_sync)
##
## player.tscn had root_path = NodePath("../..") and was fine, which is why this
## only ever appeared with use_procedural_player -- i.e. always, in practice.
##
## Checked STRUCTURALLY rather than by instancing: a synchronizer out of the tree
## cannot resolve its own root_path, and instancing the procedural player pulls in
## a skeleton, an animation tree and a camera to verify one NodePath.

var ok := true
var _ran := false

## scene -> [synchronizer node name, expected root_path]
const CASES := [
	["res://scenes/player/player.tscn", "InputSynchronizer", "../.."],
	["res://scenes/player/player_procedural.tscn", "InputSynchronizer", "../.."],
]


func _p(c: bool, m: String) -> void:
	print(("ok   " if c else "FAIL: ") + m)
	if not c:
		ok = false


func _process(_delta: float) -> bool:
	if _ran:
		return true
	_ran = true
	_run()
	print("GATE input_sync_root: %s" % ("PASS" if ok else "FAIL"))
	quit(0 if ok else 1)      # returning true alone exits 0 — a FAIL never reached CI
	return true


func _run() -> void:
	# SELF-CHECK FIRST: the same rule applied to the defect must FAIL. Without this
	# the gate could pass because its parser never finds anything, which is exactly
	# how a structural check rots into a no-op.
	var broken: String = '[node name="InputSynchronizer" type="MultiplayerSynchronizer" parent="InputSync"]\nreplication_config = SubResource("x")\n'
	_p(not broken.contains("root_path"),
		"self-check: a block with no root_path is detected as missing")

	for case: Array in CASES:
		var path: String = case[0]
		var node_name: String = case[1]
		var want: String = case[2]
		var src: String = FileAccess.get_file_as_string(path)
		if src == "":
			_p(false, "%s readable" % path.get_file())
			continue
		# Find the node block and read the root_path line that follows it. A
		# MISSING line is the defect, so absence must fail rather than be skipped.
		var idx: int = src.find('[node name="%s"' % node_name)
		if idx < 0:
			_p(false, "%s has a %s" % [path.get_file(), node_name])
			continue
		var block: String = src.substr(idx)
		var end: int = block.find("\n[node ")
		if end > 0:
			block = block.substr(0, end)
		var has_root := block.contains("root_path")
		_p(has_root, "%s/%s declares a root_path at all" % [path.get_file(), node_name])
		if has_root:
			_p(block.contains('root_path = NodePath("%s")' % want),
				"%s/%s root_path is \"%s\"" % [path.get_file(), node_name, want])

		# And the properties it replicates must be addressed from THAT root: every
		# path here starts with the InputSync node, so the root has to be the
		# player, one level above it.
		_p(block.contains("replication_config"), "%s/%s has a replication_config" % [path.get_file(), node_name])

	# The config itself: paths are relative to root_path, so a config naming
	# "InputSync:*" is only correct when root_path points at the player root.
	for case: Array in CASES:
		var src: String = FileAccess.get_file_as_string(case[0])
		var names: int = src.count('NodePath("InputSync:')
		_p(names >= 3, "%s replicates InputSync:* properties (%d)" % [String(case[0]).get_file(), names])