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])