OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / game.gd

1328 lines · gd ref:
extends Node3D

const PlayerScene := preload("res://scenes/player/player.tscn")
const PlayerProceduralScene := preload("res://scenes/player/player_procedural.tscn")

## Toggle between classic and procedural (Overgrowth-style) player controller.
## Set via editor or at runtime before spawning.
@export var use_procedural_player: bool = true

@onready var maps_node: Node3D = $Maps
@onready var players_node: Node3D = $Players
@onready var player_spawner: MultiplayerSpawner = $PlayerSpawner
@onready var peer_label: Label = $HUD/PeerLabel
@onready var fps_label: Label = $HUD/FPSLabel
@onready var menu: Control = $MenuUI/MenuMain
@onready var lobby_camera: Camera3D = $LobbyCamera

var sky3d: Sky3DExtended = null
var current_map: Node3D = null
## Path of the currently-loaded map. Persisted to world:settings:current_map so a
## rejoining peer restores the SAME world (level) — each map is a contained world
## (maps = pages, teleporters = links). Defaults to the scene's hardcoded map.
const DEFAULT_MAP_PATH := "res://scenes/maps/map_test.tscn"

## Where a headless peer starts when nothing is declared. Separate from
## DEFAULT_MAP_PATH because the two answer different questions: one is the world
## a player opens into, the other is the cheapest scene a peer can host from.
const DEFAULT_HEADLESS_MAP_PATH := "res://scenes/maps/map_server.tscn"

## HYPER_TPS_MAP — start on a given map instead of map_test, for looking at one.
##
## ⚠ THIS EXISTS BECAUSE LOADING A MAP SCENE DIRECTLY DOES NOT WORK, and the
## failure is silent and total: the map is GREY. Sky3DExtended -- the
## WorldEnvironment, the SunLight, the MoonLight, the SkyDome -- lives in
## game.tscn, NOT in the map. Map scenes carry no environment BY DESIGN
## (map_dc_erosion.tscn is four nodes: terrain, spawn, teleporter). So
##
##     dev-run.sh res://scenes/maps/map_dc_erosion.tscn      <- no sky, no sun
##
## renders an unlit world against a default clear colour and looks broken when
## nothing is. Going through the shell is the only way to see a map as it is:
##
##     HYPER_TPS_MAP=res://scenes/maps/map_dc_erosion.tscn dev-run.sh
##
## It only seeds the STARTING map; teleporters and the persisted
## world:settings:current_map behave exactly as before.
static func _initial_map_path() -> String:
	var override := OS.get_environment("HYPER_TPS_MAP")
	if override != "":
		if not ResourceLoader.exists(override):
			push_error("[Game] HYPER_TPS_MAP=%s does not exist -- starting on %s"
					% [override, DEFAULT_MAP_PATH])
			return DEFAULT_MAP_PATH
		print("[Game] HYPER_TPS_MAP -> %s" % override)
		return override
	return DEFAULT_MAP_PATH


## The map a HEADLESS peer starts in.
##
## Same declaration as the graphical path -- HYPER_TPS_MAP -- deliberately, and
## not a second variable: two ways to say the same thing is how the headless and
## graphical branches drifted apart in the first place. Undeclared, this is the
## lightweight server map, so an existing headless peer behaves exactly as it did.
##
## A declared map that does not exist falls back rather than loading nothing: a
## peer-bot with no map is a peer that cannot host the bots it was started for,
## and the typo should not be discovered as an empty world.
static func _headless_map_path() -> String:
	var declared := OS.get_environment("HYPER_TPS_MAP")
	if declared != "":
		if ResourceLoader.exists(declared):
			return declared
		push_error("[Game] HYPER_TPS_MAP=%s does not exist -- headless starting on %s"
				% [declared, DEFAULT_HEADLESS_MAP_PATH])
	return DEFAULT_HEADLESS_MAP_PATH


var current_map_path: String = _initial_map_path()
var spawn_points: Array[Node3D] = []


## The spawn points that exist NOW, read from the group at the moment of use. Spawn
## points are objects since 2026-10-05 (scene_objects.gd base objects): deleting one
## detaches it from the tree and so from the group, and placing one adds it — the
## `spawn_points` array above is a snapshot from game start / the last map change and
## would keep spawning players at a deleted point.
func _live_spawn_points() -> Array[Node3D]:
	var out: Array[Node3D] = []
	if not is_inside_tree():
		return out
	for n in get_tree().get_nodes_in_group("spawn_points"):
		if n is Node3D and (n as Node3D).is_inside_tree():
			out.append(n as Node3D)
	return out
var _spawn_index: int = 0
var _game_active := false
var build_version: String = "dev"
var _net_overlay: RichTextLabel = null
var _net_timer: Timer = null
var _log_file: FileAccess = null
var _peer_keys: Dictionary = {}  # peer_id → PackedByteArray (public key)
## dhtKey hex → true, for peers already handed to swarm_join_peer. Log bookkeeping
## only: the GDExtension is idempotent, this just keeps one line per peer instead
## of one per presence change.
var _armed_peer_keys: Dictionary = {}
## The daemon's most recent error, quoted verbatim if room state times out.
var _last_state_error: String = ""
var _sky_debug_panel: PanelContainer = null
var _screenshot_label: Label = null

## Benchmark stats — per-peer RTT samples and position error measurements.
var benchmark_active: bool = false
var _benchmark_start_time: float = 0.0
var _benchmark_stats: Dictionary = {}  # peer_id → {rtt_samples: Array, pos_errors: Array, sync_count: int}

## Interest management — tiered update rates based on distance
## Each tier: [max_distance, replication_interval]
var interest_tiers: Array = [
	[500.0, 0.05],    # Near: full rate (20Hz)
	[1500.0, 0.2],    # Medium: 5Hz
	[3000.0, 0.5],    # Far: 2Hz
]
var interest_max_range: float = 3000.0  # Beyond this = hidden
var interest_enabled: bool = true


func _log(msg: String) -> void:
	print(msg)
	if not _log_file:
		if FileAccess.file_exists("/tmp/hypertps-game.log"):
			_log_file = FileAccess.open("/tmp/hypertps-game.log", FileAccess.READ_WRITE)
			if _log_file:
				_log_file.seek_end()
		else:
			_log_file = FileAccess.open("/tmp/hypertps-game.log", FileAccess.WRITE)
	if _log_file:
		_log_file.seek_end()
		_log_file.store_line("%s %s" % [Time.get_datetime_string_from_system(), msg])
		_log_file.flush()


func _ready() -> void:
	# Set default offline peer so MultiplayerSpawner/Synchronizer don't error
	# before a real peer is assigned via Host/Join/Home
	multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()

	# Says when edits are NOT being saved (daemon gone, or a write refused) instead
	# of losing them silently on quit. docs/home-world-design.md §4.
	$HUD.add_child(preload("res://scenes/game/save_banner.gd").new())

	# Read build version. HYPER_TPS_BUILD wins, because a dev run has no
	# version.txt and reported a bare "dev" -- which cannot answer the only
	# question anyone asks of it: am I looking at the code I just changed?
	# scripts/dev-run.sh sets it to the git HEAD plus a -dirty marker, and it also
	# reaches the Network HUD, so a screenshot names the commit it was taken on.
	var vf := FileAccess.open("res://version.txt", FileAccess.READ)
	if vf:
		build_version = vf.get_as_text().strip_edges()
	if OS.has_environment("HYPER_TPS_BUILD"):
		build_version = OS.get_environment("HYPER_TPS_BUILD")
	print("[HyperTPS] Build: %s   HTPS_MODELLOG=%s" % [build_version,
			"ON" if OS.has_environment("HTPS_MODELLOG") else "off"])

	# Long frames, timed by phase, to user://hitches.log (scenes/game/hitch_log.gd).
	# On by default: it writes only when a frame is a hitch, plus a 10 s summary.
	if OS.get_environment("HTPS_HITCHLOG") != "0" and get_tree().root.get_node_or_null(^"HitchLog") == null:
		get_tree().root.add_child.call_deferred(preload("res://scenes/game/hitch_log.gd").new())

	# Headless: load the DECLARED map, defaulting to the lightweight server map.
	#
	# This used to hardcode map_server, which spec/bot-testbed.md (e1) names as an
	# obligation of the containment work: a peer-bot's map must be a declaration
	# rather than a branch, or the process that runs every owned bot cannot say
	# which world it is in, and reaches map_flat only if a map change happens to
	# replicate to it.
	if DisplayServer.get_name() == "headless":
		var headless_path := _headless_map_path()
		for child in maps_node.get_children():
			child.queue_free()
		var headless_map := load(headless_path)
		if headless_map:
			current_map = headless_map.instantiate()
			maps_node.add_child(current_map)
			# ⚠ THE NAMESPACE FOLLOWS THE LOADED MAP. This assignment was missing
			# and its absence was silent: current_map_path still held
			# _initial_map_path() while map_server sat in the tree, and the
			# GameState.current_map_id seeded a dozen lines below is derived from
			# it. Every map-scoped key on a headless peer -- world:bots: and
			# world:creatures: among them -- was namespaced to a map that was not
			# loaded. Bot containment is map-scoped, so this landed on exactly the
			# peer the bots run on.
			current_map_path = headless_path
			print("[Game] Headless mode — loaded %s (no rendering)" % headless_path)
		else:
			push_error("[Game] headless map %s failed to load" % headless_path)

	# Track current map and collect spawn points
	if not current_map and maps_node.get_child_count() > 0:
		current_map = maps_node.get_child(0)
	# Seed the per-map state namespace with the default/hardcoded map.
	GameState.current_map_id = GameState.map_id_from_path(current_map_path)
	spawn_points.assign(get_tree().get_nodes_in_group("spawn_points"))

	# Connect multiplayer signals
	multiplayer.peer_connected.connect(_on_peer_connected)
	multiplayer.peer_disconnected.connect(_on_peer_disconnected)

	# Durable membership drives dialing. Presence replicates, so an entry can land
	# for a member this peer has never connected to -- which is the whole point:
	# a restarted peer has met nobody and must still be able to dial out.
	if Presence:
		Presence.presence_changed.connect(_on_presence_changed)

	# A peer announcing a map change has to reach us as an event: world:settings:
	# is read once at game start and nothing subscribes to it, which is why a live
	# peer never followed a map change in the first place.
	GameState.state_changed.connect(_on_player_map_changed)

	# Keep the daemon's last complaint, so a timeout can quote it instead of
	# guessing. One string: this is for a diagnosis, not a history.
	if KeetService != null and KeetService.game_state != null:
		KeetService.game_state.error_occurred.connect(
				func(msg: String) -> void: _last_state_error = msg)

	# Define world state routes — Keet room roles gate durable writes, and
	# author-scoped content adds a per-peer ownership gate (GameState.can_write).
	#
	# ONE LINE, because the table is the declaration now. This used to be fifteen
	# route() calls whose channels and roles had to agree with MAP_SCOPED_PREFIXES
	# and AUTHOR_SCOPED_PREFIXES in game_state.gd, with nothing checking that they
	# did — and both ways of disagreeing are silent. Adding world:sky: meant
	# remembering all three; world:level: was wrong in two of them at once. The
	# prose that lived here now sits beside the row it explains, in
	# GameState.STATE_ROUTES, and state_routes_gate asserts the rows are complete.
	GameState.install_routes()

	# Admin-controllable subsystem wiring. Each subsystem opts in via a
	# small registration block here rather than touching addon source.
	# Format: pick the node, add to "admin_controllable" group, set
	# admin_name (short label) and admin_exposed_methods (opt-in methods).
	# @export properties are auto-exposed; everything else requires
	# explicit set_meta("admin_exposed:<name>", true).
	var sky_node: Node = get_node_or_null("Sky3DExtended")
	if sky_node != null:
		sky_node.add_to_group("admin_controllable")
		sky_node.set_meta("admin_name", "sky")
		# Sky3D bug workaround: TimeOfDay.current_time's setter doesn't
		# call _update_celestial_coords() — the sun/moon position only
		# recomputes via _process() when game_time_enabled is true, OR
		# when day/month/year setters fire. Our scene has both
		# game_time_enabled=false and editor_time_enabled=false, so a
		# direct current_time = X via AdminFacade emits time_changed
		# but doesn't visually move the sun. Hook time_changed here to
		# force the recompute on every set.
		var tod: Node = sky_node.get_node_or_null("TimeOfDay")
		if tod != null and tod.has_signal("time_changed") and tod.has_method("_update_celestial_coords"):
			if not tod.time_changed.is_connected(_on_sky_time_changed.bind(tod)):
				tod.time_changed.connect(_on_sky_time_changed.bind(tod))
		print("[game] registered sky node for admin control")

	# Creatures are bots (#114 phase 5): BotRegistry builds, ticks and draws their bodies,
	# map-bound, so nothing per-map is attached here any more.

	# Terrain edit manager — routes edits through GameState for persistence + sync
	var terrain_mgr := TerrainEditManager.new()
	terrain_mgr.name = "TerrainEditManager"
	add_child(terrain_mgr)

	# HUD starts showing placeholder
	peer_label.text = "Peer: ---"

	# Load chat overlay
	var ChatScene := load("res://packs/keet_chat/chat_overlay.tscn") as PackedScene
	if ChatScene:
		var chat_overlay := ChatScene.instantiate()
		$HUD.add_child(chat_overlay)
		print("[Game] Loaded Keet Chat overlay")

	# Network debug overlay (top-right, always visible)
	_create_net_overlay()

	# Interest management timer (evaluate every 2 seconds)
	interest_enabled = Settings.interest_management
	var interest_timer := Timer.new()
	interest_timer.wait_time = 2.0
	interest_timer.timeout.connect(_update_interest)
	add_child(interest_timer)
	interest_timer.start()

	# Set up Sky3DExtended (day/night cycle, clouds, stars)
	_setup_sky()

	# Apply saved graphics settings
	Settings.apply_settings()


func _setup_sky() -> void:
	# Reuse the Sky3DExtended that's been placed in the scene via the editor.
	# game.gd does NOT create one at runtime — that path caused duplicate
	# SunLights when an editor-added Sky3DExtended also existed.
	var existing := get_node_or_null("Sky3DExtended")
	if existing is Sky3DExtended:
		sky3d = existing
	else:
		push_warning("[Game] No Sky3DExtended in scene — sky/lighting disabled. Add a Sky3DExtended node to game.tscn.")
		return

	# In headless --dedicated mode, Sky3DExtended's shader children allocate
	# Vulkan RIDs against a null GPU backend, producing endless
	# "Attempted to free invalid ID" double-free errors that crash the
	# engine (and the parent launcher with it). KEEP the parent node so
	# AdminFacade can index it and /world set sky.current_time still
	# routes state through state-base — but kill the heavy children
	# (Atmosphere/SkyDome/Clouds/SunLight/MoonLight) that own the RIDs.
	# TimeOfDay (a logical timer) stays so current_time has somewhere to live.
	if DisplayServer.get_name() == "headless":
		var doomed_children: Array[String] = [
			"Atmosphere", "SkyDome", "Sky", "Clouds", "CloudVolume",
			"SunLight", "MoonLight", "Sun", "Moon", "Environment",
			"WorldEnvironment", "DirectionalLight3D"
		]
		for cname in doomed_children:
			var c := existing.get_node_or_null(cname)
			if c != null:
				c.queue_free()
		# Also strip the env on the Sky3DExtended itself so apply_environment
		# won't recreate sky shaders.
		if "environment" in existing:
			existing.environment = null
		push_warning("[Game] Headless mode — Sky3DExtended kept (TimeOfDay only); rendering children freed to prevent RID leak")
		return

	# All Sky3D properties (current_time, minutes_per_day, update_interval,
	# environment.sky.process_mode) are taken from the editor-configured
	# Sky3DExtended node. game.gd does not override them.
	print("[Game] Sky3DExtended found (editor-configured)")
	# The dock is built and the sky resolved — register the atmosphere panel now
	# rather than on first entry to build mode, so the section exists the moment
	# you get there instead of appearing a frame late.
	_register_editor_panels()


func _unhandled_input(event: InputEvent) -> void:
	if Keymap.tapped(event, "global.screenshot"):
		_take_screenshot()


## Put the atmosphere controls in the editor dock, once, the first time build mode
## is entered.
##
## WAS a raw `KEY_F2` match in _unhandled_input, which had three problems and only
## the first was visible: F2 is `global.mode_play` in the keymap, so opening the
## sky panel ALSO dropped you out of build mode — undeclared, so the boot conflict
## check never saw it. It anchored itself top-left over the viewport. And it flipped
## Input.mouse_mode by hand, which is now GameMode's job.
##
## As a dock section it needs no key at all: it is there while building, and its
## header collapses it. That hands F2 back to mode_play, whole.
func _register_editor_panels() -> void:
	if BuildDock.has_panel("atmosphere"):
		return
	# LOUD, not silent. Every step here can fail in a way that leaves the dock
	# simply missing a section, which is indistinguishable from a dock that never
	# rendered — and that cost two round trips of "I don't see the panel".
	if sky3d == null:
		push_warning("[Game] no Sky3DExtended — no Atmosphere panel. _setup_sky() " +
				"returns early in headless AND when the node is absent from game.tscn.")
		return
	var SkyDebugPanel := load("res://scenes/game/sky_debug_panel.gd")
	if SkyDebugPanel == null:
		push_error("[Game] sky_debug_panel.gd failed to LOAD — no Atmosphere panel.")
		return
	_sky_debug_panel = PanelContainer.new()
	_sky_debug_panel.set_script(SkyDebugPanel)
	# Build the UI BEFORE it enters the tree — OptionButton popups emit signals on
	# entry and error if their handlers are not wired yet.
	_sky_debug_panel.setup(sky3d)
	# OPEN by default. It is a parametric panel — the thing you came to the editor
	# to turn — and a collapsed one reads as absent. The dock caps the column
	# against the window and scrolls the overflow, so "tall" is no longer a reason
	# to hide it. Reference material (Hotkeys) is what starts collapsed.
	BuildDock.register("atmosphere", "Atmosphere", _sky_debug_panel, true)


func _take_screenshot() -> void:
	var img := get_viewport().get_texture().get_image()
	var dir_path := "user://screenshots"
	DirAccess.make_dir_recursive_absolute(dir_path)
	var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
	var path := dir_path + "/screenshot_" + timestamp + ".jpg"
	img.save_jpg(path, 0.9)
	var global_path := ProjectSettings.globalize_path(path)
	print("[Screenshot] Saved: %s" % global_path)
	_show_screenshot_notification(global_path)


func _show_screenshot_notification(path: String) -> void:
	if _screenshot_label:
		_screenshot_label.queue_free()
	_screenshot_label = Label.new()
	_screenshot_label.text = "Screenshot saved"
	_screenshot_label.add_theme_font_size_override("font_size", 18)
	_screenshot_label.add_theme_color_override("font_color", Color(1, 1, 1))
	_screenshot_label.anchor_left = 0.5
	_screenshot_label.anchor_right = 0.5
	_screenshot_label.anchor_top = 0.85
	_screenshot_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
	_screenshot_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	$HUD.add_child(_screenshot_label)
	var tween := create_tween()
	tween.tween_interval(1.5)
	tween.tween_property(_screenshot_label, "modulate:a", 0.0, 0.5)
	tween.tween_callback(_screenshot_label.queue_free)


func _process(_delta: float) -> void:
	fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
	# In build mode the peer / FPS lines sit in the 3D view's corner, not over the editor's
	# areas (hyper-games #124; BuildDock.view_rect() is the whole window in play mode).
	# Only these two move: the HUD layer also holds the crosshair, which stays centred.
	var corner: Vector2 = BuildDock.view_rect().position if BuildDock != null else Vector2.ZERO
	peer_label.position = corner + Vector2(10, 10)
	fps_label.position = corner + Vector2(10, 30)

	if benchmark_active:
		_collect_benchmark_stats()


## Called by menu_main.gd when the player starts the game (Resume, Host, or Join).
func on_game_started() -> void:
	if _game_active:
		return
	_game_active = true

	var my_id := multiplayer.get_unique_id()
	peer_label.text = "Peer: %d" % my_id
	_log("[Game] on_game_started id=%d" % my_id)

	# Load persistent world state from Autobase (terrain edits, creature registry,
	# world settings, saved positions). Listeners process state_changed as it arrives.
	GameState.reset_loaded()
	GameState.load_room_state()

	# Arm the swarm against everyone the room knows about, before any spawn logic
	# and before the read-only return below — a spectator still has to mesh.
	_rearm_known_peers()

	# Say which map we are in, and take stock of who else is where. Before the
	# read-only return: a spectator still needs to not see bodies from another map.
	_publish_my_map()
	_update_map_visibility()

	# Read-only users are spectators — no player spawn, keep lobby camera.
	var role: String = KeetService.current_role if KeetService else "peer"
	if role == "read-only":
		_log("[Game] read-only mode — spectating (no player spawn)")
		return

	# Spawn the LOCAL player IMMEDIATELY so a joiner is never frozen on the lobby
	# camera while durable room state loads. A cold daemon's first state.list can
	# take ~25s, and blocking the spawn behind it made the joining peer look stuck
	# (player never appears, lobby camera frozen). The player lands on the current
	# (default) map; the world restore below moves it to the saved map + position
	# once state arrives — change_map relocates existing players, so this is safe.
	# Mesh mode: each peer spawns only its own player (no centralized authority).
	_spawn_player_for(my_id)
	lobby_camera.current = false

	# Announce to any already-connected peers so they create our player.
	for id in multiplayer.get_peers():
		_announce_player.rpc_id(id, my_id)

	# Restore the WORLD asynchronously (does NOT block spawn): wait (bounded) for
	# durable state, switch to the saved map, then re-arm a one-shot position
	# restore. The map switch fires map_changing, which PlayerPosition would
	# otherwise treat as a teleport and suppress — so a rejoining peer still lands
	# at its saved position once the correct map is active.
	await _await_room_state()
	await _restore_saved_map()
	PlayerPosition.restore_after_cold_join()
	# A SLOW room (#131: 16.4 s to open): the world is restored once its state arrives.
	if not bool(GameState.room_state_loaded):
		_restore_when_loaded()


## The late half of the world restore: wait (bounded, 120 s) for the room's state, then
## switch to its saved map and position — or seed a default only now that the setting is
## KNOWN to be absent (_may_seed_world).
func _restore_when_loaded() -> void:
	var deadline := Time.get_unix_time_from_system() + 120.0
	while Time.get_unix_time_from_system() < deadline:
		if not is_inside_tree():
			return
		if bool(GameState.room_state_loaded):
			_log("[Game] room state arrived late — restoring the world now")
			await _restore_saved_map()
			PlayerPosition.restore_after_cold_join()
			return
		await get_tree().process_frame
	_log("[Game] room state never loaded (120 s) — the world is NOT restored and NOTHING was seeded")


## Wait (bounded) for the first durable room-state load so saved map + position
## are available before spawn. Proceeds on timeout (fresh world / daemon offline).
func _await_room_state() -> void:
	var gs := get_node_or_null("/root/GameState")
	if gs == null:
		return
	# MEASURED, not guessed. The previous budget was 25 s, justified by a comment
	# claiming a cold room open takes 15-20 s. Timed against a real daemon and a
	# 1014-key room:
	#
	#   cold daemon boot -> UDS socket     1.40 s
	#   first state-list (opens the room)  1.18 s
	#   subsequent state-list (warm)       0.05 s
	#
	# So the operation is ~2.6 s cold, and 8 s is already 3x headroom. The old
	# figure was not merely generous, it was harmful: a load that is genuinely
	# BROKEN sat for 25 s looking like a load that was merely slow, and the only
	# signal was a line saying it had given up.
	#
	# Report how long it actually took, so drift shows up as a number rather than
	# as a feeling that startup got worse.
	# Only errors from THIS wait count: a stale one (a set_value refused before any room
	# was selected) was reported as the fault (#131).
	_last_state_error = ""
	var t0 := Time.get_unix_time_from_system()
	var deadline := t0 + 8.0
	while Time.get_unix_time_from_system() < deadline:
		if gs.room_state_loaded:
			var dt := Time.get_unix_time_from_system() - t0
			if dt > 3.0:
				_log("[Game] room state loaded in %.1fs — slower than the ~2.6s cold path" % dt)
			return
		await get_tree().process_frame
	_log("[Game] room-state load TIMED OUT after 8s — proceeding with defaults. " +
			"The cold path measures ~2.6s, so this is a fault, not slowness. " +
			_observe_room_state_fault())


## What the daemon looks like right now, in one line.
##
## Gathers the observations and hands them to the pure describer below, so the
## rule that turns them into a diagnosis can be gated without standing up a
## daemon — that rule is the only part of this that can be wrong in a way that
## costs anything.
func _observe_room_state_fault() -> String:
	var ks := get_node_or_null("/root/KeetService")
	if ks == null:
		return "KeetService autoload absent — nothing could have loaded room state."
	var uds: bool = ks.client != null and bool(ks.client.is_connected)
	var room: String = String(ks.get_current_room()) if ks.has_method("get_current_room") else ""
	var rooms: int = (ks.get_rooms() as Dictionary).size() if ks.has_method("get_rooms") else -1
	var polls: int = 0
	if ks.game_state != null:
		polls = int(ks.game_state.get("_consecutive_disconnect_polls"))
	return _describe_room_state_fault(uds, room, rooms, polls, _last_state_error)


## Turn those observations into a specific diagnosis.
##
## ⛔ THE MESSAGE USED TO NAME TWO CANDIDATES AND DISTINGUISH NEITHER: "(daemon
## down, room not opening)". It fired once on stortron after a pgh app update and
## four controlled attempts failed to reproduce it — three cold restarts and one
## under heavy tree-read I/O all loaded in under 3s — so the single occurrence had
## to carry its own evidence, and it did not.
##
## The empty-room case is listed first because it is the cheapest thing to be
## wrong and the old message did not mention it at all: KeetGameState._poll_state
## returns immediately when current_room is empty, so state_loaded can never fire
## and the wait is guaranteed to burn its whole budget.
static func _describe_room_state_fault(uds_connected: bool, room_id: String,
		room_count: int, disconnect_polls: int, last_error: String) -> String:
	if not uds_connected:
		return ("FAULT: the keet-daemon UDS is not connected%s — the daemon is down "
				+ "or never finished booting.") % (
				"" if disconnect_polls <= 0 else " (%d polls skipped while disconnected)" % disconnect_polls)
	if room_id == "":
		return ("FAULT: no room is selected, so _poll_state returns before it ever "
				+ "calls state.list — room state could not load, and would not have "
				+ "loaded given any amount of time.")
	if room_count == 0:
		return ("FAULT: the daemon answered but lists 0 rooms, so room '%s' is not "
				+ "open on it.") % room_id.left(16)
	if last_error != "":
		return "FAULT: the daemon reported an error: %s" % last_error
	return ("FAULT: UDS up, room '%s' selected, %d rooms listed and NO ERROR "
			+ "reported — state.list was issued and never answered. Suspect the "
			+ "room's autobase not opening.") % [room_id.left(16), room_count]


## MAY THE DEFAULT WORLD BE SEEDED? (hyper-games #131) Only when the room's state has
## LOADED and so the setting is known to be absent — or with no room at all (offline:
## local state, nothing shared to overwrite). A timed-out load is UNKNOWN, not absent:
## seeding then overwrote a slow room's saved map, as admin, for everyone.
static func _may_seed_world(room_selected: bool, state_loaded: bool) -> bool:
	return not room_selected or state_loaded


## Switch to the saved map (world:settings:current_map) before spawning, so a
## rejoining peer lands in the same world (level) with its terrain edits.
func _restore_saved_map() -> void:
	var saved: Variant = GameState.get_state("world:settings:current_map")
	if not (saved is Dictionary and saved.has("path")):
		# Fresh room (no saved map yet): SEED the default world ONCE so the room is
		# self-describing from its first session — a later joiner, a headless
		# --daemon-only peer, or an agent probing over UDS reads
		# world:settings:current_map without a prior map change/teleport. This is
		# the world-state INIT and it happens exactly once: admin-gated (only the
		# creator can write world:settings:*), and every joiner already has a saved
		# map synced from the shared StateBase so they never reach this branch.
		# We do NOT reload (we're already on current_map_path) — only persist the
		# setting. Idempotent by construction: runs solely while the setting is
		# ABSENT, so accumulated changes are never re-initialized (joins sync the
		# persistent state; they do not re-seed a default).
		var ks := get_node_or_null("/root/KeetService")
		var room_selected: bool = ks != null and ks.has_method("get_current_room") and String(ks.get_current_room()) != ""
		if not _may_seed_world(room_selected, bool(GameState.room_state_loaded)):
			_log("[Game] NOT seeding the default world: the room's state has not loaded, so its saved map is unknown, not absent (#131)")
			return
		if GameState.can_write("world:settings:current_map"):
			GameState.set_state("world:settings:current_map", {"path": current_map_path, "ts": Time.get_unix_time_from_system()})
			_log("[Game] seeded default world (current_map=%s)" % current_map_path)
		return
	var path: String = str(saved["path"])
	if path == "" or path == current_map_path:
		return
	# ⚠ HYPER_TPS_MAP WINS OVER THE SAVED MAP. Without this the override seeds
	# current_map_path and is then immediately overridden by the room's persisted
	# world:settings:current_map -- measured: the game logged
	# "HYPER_TPS_MAP -> map_dc_erosion" and then loaded map_test anyway, which is
	# a flag that looks like it worked and did not. It is a dev affordance for
	# LOOKING at a map, so it outranks the restore for this session only; nothing
	# is written back, so the room's saved map is untouched.
	if OS.get_environment("HYPER_TPS_MAP") != "":
		_log("[Game] HYPER_TPS_MAP set -- not restoring saved map %s" % path)
		return
	_log("[Game] restoring saved map: %s" % path)
	# ZERO = land on that map's spawn point (see change_map), not its origin —
	# (0,2,0) is inside the terrain on map_mountain. restore_after_cold_join then
	# moves us to our saved position if we have one.
	await change_map(path, Vector3.ZERO)


## Reset game state so it can be restarted (e.g., when yielding host to another peer).
func reset_for_rejoin() -> void:
	PlayerPosition.detach_local_player()
	for child in players_node.get_children():
		child.queue_free()
	_game_active = false
	_spawn_index = 0
	# Safe to drop now. This clear used to be the third way a peer forgot how to
	# dial (alongside the in-memory table and process restart) because _peer_keys
	# was the ONLY record of anyone's key. Durable presence is that record now, and
	# on_game_started re-arms from it, so this is back to being what it reads as:
	# dropping a live peer_id mapping that no longer means anything.
	_peer_keys.clear()
	# Cleared as well so the re-arm logs honestly against whatever peer object the
	# next session builds — the explicit marks live in the GDExtension, and a new
	# peer instance has none of them.
	_armed_peer_keys.clear()
	lobby_camera.current = true
	peer_label.text = "Peer: ---"


## Sky3D addon bug workaround: TimeOfDay.current_time's setter doesn't
## call _update_celestial_coords() on its own (only via _process when
## game_time_enabled is true). Hook time_changed so any direct set —
## including from AdminFacade — visibly moves the sun.
func _on_sky_time_changed(_time: float, tod: Node) -> void:
	if tod != null and tod.has_method("_update_celestial_coords"):
		tod._update_celestial_coords()


func _on_peer_connected(id: int) -> void:
	_log("[Game] peer_connected id=%d game_active=%s peer_count=%d" % [id, _game_active, multiplayer.get_peers().size()])
	# Store peer's public key for event-driven reconnection.
	var mp = multiplayer.multiplayer_peer
	if mp and mp.has_method("get_peer_public_key"):
		var pk: PackedByteArray = mp.get_peer_public_key(id)
		if pk.size() == 32:
			_peer_keys[id] = pk
			_log("[Game] stored pk for peer %d: %s" % [id, pk.hex_encode().substr(0, 8)])
	if _game_active:
		# Announce ourselves so the new peer creates a player for us.
		_announce_player.rpc_id(id, multiplayer.get_unique_id())


## Any peer announces itself — all recipients create that player locally.
@rpc("any_peer", "reliable")
func _announce_player(peer_id: int) -> void:
	_log("[Game] _announce_player peer_id=%d" % peer_id)
	if _game_active:
		_spawn_player_for(peer_id)


## Where each peer is, so a body is only drawn in the map its owner is in.
const KEY_PLAYER_MAP := "player:map:"


## Announce which map WE are in. Called wherever current_map_path changes.
func _publish_my_map() -> void:
	if not _game_active:
		return
	GameState.set_state(KEY_PLAYER_MAP + str(multiplayer.get_unique_id()),
			GameState.map_id_from_path(current_map_path))


## Is this peer in the same map as us?
##
## ⚠ AN UNKNOWN MAP COUNTS AS SHARED. A peer running a build that does not
## publish player:map: would otherwise become invisible to everyone the day this
## ships — a worse failure than the one being fixed, and a silent one.
func _peer_shares_our_map(peer_id: int, my_map: String = "") -> bool:
	if my_map == "":
		my_map = GameState.map_id_from_path(current_map_path)
	var theirs: Variant = GameState.get_state(KEY_PLAYER_MAP + str(peer_id))
	if theirs == null or String(theirs) == "":
		return true
	return String(theirs) == my_map


## Hide the bodies whose owners are somewhere else.
##
## Separate from _update_interest, and deliberately: interest is a DISTANCE
## optimisation that can be switched off with interest_enabled, and "you are in
## another world" must not be switchable with it. Interest asks this before it
## shows anyone, so the two cannot contradict each other.
func _update_map_visibility() -> void:
	if players_node == null or not is_instance_valid(players_node):
		return
	var my_id := multiplayer.get_unique_id()
	var my_map := GameState.map_id_from_path(current_map_path)
	for child in players_node.get_children():
		var peer_id: int = child.name.to_int()
		if peer_id == my_id:
			continue
		var shares := _peer_shares_our_map(peer_id, my_map)
		if child is Node3D:
			(child as Node3D).visible = shares
		# A body you cannot see must not be one you can walk into.
		if child is CollisionObject3D:
			(child as CollisionObject3D).process_mode = \
				Node.PROCESS_MODE_INHERIT if shares else Node.PROCESS_MODE_DISABLED


func _on_player_map_changed(key: String, _value: Variant, _channel: int) -> void:
	if key.begins_with(KEY_PLAYER_MAP):
		_update_map_visibility()


## Presence changed — a member joined, left, or published a key. Re-arm.
func _on_presence_changed(_presence_map: Dictionary) -> void:
	_rearm_known_peers()


## Mark every known room member as an EXPLICIT swarm peer.
##
## ⛔ THIS IS NOT A RETRY LOOP, AND IT MUST NOT BECOME ONE. The retry already
## exists one layer down, in the GDExtension. hyperswarm-peer's
## SwarmManager::join_peer() sets is_explicit, and _select_timer() routes an
## explicit peer whose attempts exceed 3 to the long retry timer while a
## NON-explicit peer gets nullptr and is dropped. update_priority() then pins an
## explicit peer at attempts = 3 for ever, so it retries indefinitely. Calling
## swarm_join_peer once per peer is the correct idiom — it is JS hyperswarm's
## joinPeer, which is also called once.
##
## hyper-games #77 item 2 reads the single call in _on_peer_disconnected as the
## bug ("nothing retries"). It is not. What was missing was the KEY: public keys
## were learned only in _on_peer_connected, held in the in-memory _peer_keys, and
## cleared by reset_for_rejoin, so after a restart there was nothing to call
## swarm_join_peer WITH and the retry ladder was never armed for anybody.
##
## Idempotent by construction: join_peer skips a peer that is already connected
## or connecting, so this is safe to call on every presence change.
func _rearm_known_peers() -> void:
	if not Presence:
		return
	var mp := multiplayer.multiplayer_peer
	if mp == null or not mp.has_method("swarm_join_peer"):
		return
	var keys: Array[String] = Presence.get_peer_dht_keys()
	for pk_hex: String in keys:
		var pk: PackedByteArray = pk_hex.hex_decode()
		if pk.size() != 32:
			continue
		mp.swarm_join_peer(pk)
		# Log only the first time we arm a key, or a busy room repeats this line
		# on every presence change for every member.
		if not _armed_peer_keys.has(pk_hex):
			_armed_peer_keys[pk_hex] = true
			_log("[Game] armed swarm peer from presence: %s" % pk_hex.substr(0, 8))


func _on_peer_disconnected(id: int) -> void:
	_log("[Game] peer_disconnected id=%d" % id)
	_remove_player(id)
	# Event-driven reconnection: immediately tell swarm to reconnect to this peer.
	if _game_active and _peer_keys.has(id):
		var pk: PackedByteArray = _peer_keys[id]
		var mp = multiplayer.multiplayer_peer
		if mp and mp.has_method("swarm_join_peer"):
			_log("[Game] swarm_join_peer for reconnect: %s" % pk.hex_encode().substr(0, 8))
			mp.swarm_join_peer(pk)


## Spawn a player node directly (bypasses MultiplayerSpawner — mesh mode).
## Each peer is authority over its own player; MultiplayerSynchronizer handles sync.
func _spawn_player_for(id: int) -> void:
	if players_node.has_node(str(id)):
		return  # Already exists
	var scene: PackedScene = PlayerProceduralScene if use_procedural_player else PlayerScene
	var player: CharacterBody3D = scene.instantiate()
	player.name = str(id)
	player.set_multiplayer_authority(id)
	_log("[Game] _spawn_player_for id=%d local=%s procedural=%s" % [id, id == multiplayer.get_unique_id(), use_procedural_player])

	var is_local := id == multiplayer.get_unique_id()
	var saved_pos: Variant = PlayerPosition.get_saved_local_position() if is_local else null
	if saved_pos != null:
		player.position = saved_pos
		_log("[Game] spawning local player at SAVED pos %s" % str(saved_pos))
	elif not _live_spawn_points().is_empty():
		var live := _live_spawn_points()
		player.position = live[_spawn_index % live.size()].global_position
		_spawn_index += 1
	else:
		player.position = Vector3(0, 2, 0)

	players_node.add_child(player, true)

	if is_local:
		# ARRIVE, don't just appear: held until the ground under the point is real
		# (spawn points sit 17-121 m up or 12 m inside rock on the terrain maps, and
		# the terrain's collision lags its arrival). A SAVED position keeps its own
		# rules (PlayerPosition reconciles it), so it is not snapped here.
		if player.has_method("arrive"):
			player.call("arrive", player.position, saved_pos == null)
		lobby_camera.current = false
		# Hand the local player to PlayerPosition so it snapshots as we move and
		# can still restore if the saved pos arrives after spawn.
		PlayerPosition.attach_local_player(player)


func _add_player(id: int) -> void:
	_spawn_player_for(id)


## Ensure all connected players (including self) are spawned.
func ensure_all_spawned() -> void:
	if not _game_active:
		return
	var my_id := multiplayer.get_unique_id()
	_reap_orphan_players(my_id)
	if not players_node.has_node(str(my_id)):
		_spawn_player_for(my_id)
	for id in multiplayer.get_peers():
		if not players_node.has_node(str(id)):
			_spawn_player_for(id)


## Free player nodes that belong to no live peer.
##
## ⛔ THE OFFLINE PEER LEAVES ONE BEHIND EVERY SESSION. on_game_started spawns the
## local player immediately so a joiner is never frozen on the lobby camera, but at
## that moment multiplayer.multiplayer_peer is still the OfflineMultiplayerPeer and
## get_unique_id() returns 1 -- so the node is named "1". When the mesh comes up we
## are assigned a real id and ensure_all_spawned, seeing no node by that name,
## spawns a SECOND local player. Nothing ever removed the first.
##
## Measured from a real ashatron join:
##     [Game] on_game_started id=1
##     [Game] _spawn_player_for id=1 local=true procedural=true
##     [Menu] authority elected: peer_id=576519484
##     [Game] _spawn_player_for id=576519484 local=true procedural=true
##
## The orphan is not inert. It carries two MultiplayerSynchronizers, and it is
## still iterated by _update_interest, which reads child.name.to_int() -> 1 and
## calls set_visibility_for(1, ...) for a peer that does not exist -- the
## "Condition !peers_info.has(p_peer) is true" spam at _update_sync_visibility. It
## is also the most likely source of the "Node 'InputSync:wants_jump' not found"
## flood, which begins the instant a remote peer appears and there is finally
## someone to sync to.
##
## Reaped by IDENTITY, not by a special case for 1: any player node whose name is
## neither our id nor a connected peer's belongs to nobody. A peer mid-handshake is
## already in get_peers() before _add_player spawns for it, so this cannot race a
## legitimate arrival.
func _reap_orphan_players(my_id: int) -> void:
	var live: Dictionary = {my_id: true}
	for id in multiplayer.get_peers():
		live[id] = true
	for child in players_node.get_children():
		var id: int = child.name.to_int()
		if live.has(id):
			continue
		_log("[Game] reaping orphan player '%s' (no live peer owns it)" % child.name)
		# `_local_player`, with the underscore: the property is private by
		# convention and get("local_player") silently returns null, which would
		# leave PlayerPosition holding a freed node.
		if child == PlayerPosition.get("_local_player"):
			PlayerPosition.detach_local_player()
		child.queue_free()


func _remove_player(id: int) -> void:
	if id == multiplayer.get_unique_id():
		PlayerPosition.detach_local_player()
	var player_node := players_node.get_node_or_null(str(id))
	if player_node:
		player_node.queue_free()


func change_map(map_path: String, spawn_pos: Vector3) -> void:
	# Remove current map
	if current_map:
		current_map.queue_free()
		current_map = null

	# Load and add new map
	var scene := load(map_path) as PackedScene
	if not scene:
		push_error("[Game] Failed to load map: %s" % map_path)
		return

	# ENTER THE MAP BEFORE INSTANTIATING IT, for two reasons.
	#
	# 1. The per-map namespace has to be right while the map LOADS, not after.
	#    add_child runs the map's _ready, which defers an import that writes
	#    map-scoped state (the level source announcement among it). With this call
	#    below the instantiation, current_map_id was still the map we just LEFT for
	#    that whole window, so those writes were scoped to the wrong map.
	#
	# 2. It is what the sky was waiting for. Timestamped, the import and shell
	#    subtract take 1.5 s and 2.2 s; enter_map emits map_changing, which is what
	#    tells the panels to switch. Announcing the change after the load meant you
	#    watched the OUTGOING map's sky for ~3.7 s and it flipped at the end.
	#    Announced first, the level loads into a world already set to its own sky.
	current_map_path = map_path
	GameState.enter_map(GameState.map_id_from_path(map_path))

	current_map = scene.instantiate()
	maps_node.add_child(current_map)

	# Remove any WorldEnvironment embedded in the map scene — Sky3DExtended provides
	# the global environment and would conflict with a second WorldEnvironment node.
	_remove_map_world_environment(current_map)

	# Wait a frame for spawn points to register
	await get_tree().process_frame

	# Re-collect spawn points from new map
	spawn_points.assign(get_tree().get_nodes_in_group("spawn_points"))

	# NO DESTINATION = THE MAP'S OWN SPAWN POINT. A pad that hard-codes a point
	# inside ANOTHER scene holds a copy that goes stale when that map changes:
	# map_test's Mountain pad kept y=5 after map_mountain's ground plane was
	# removed and its spawns moved to y=246.5, and landed players 240 m inside the
	# mountain. Pads that leave `destination` unset (ZERO) follow the target map.
	# Pads that set one (far-out heightmap/diffusion, erosion's 5200 m) keep it.
	if spawn_pos == Vector3.ZERO and not _live_spawn_points().is_empty():
		spawn_pos = _live_spawn_points()[0].global_position

	# ⛔ MOVE OUR OWN PLAYER, NOT EVERY PLAYER. This used to teleport every body in
	# players_node to the destination, which was coherent only while the whole room
	# changed map together — and it never did: world:settings:current_map is read
	# once at game start and nothing subscribes to it, so a live peer stays where
	# it is. Dragging their body along made a peer in another map appear beside you
	# at your spawn, which is the visible half of the same wrong assumption.
	var my_player := players_node.get_node_or_null(str(multiplayer.get_unique_id()))
	if my_player != null:
		if my_player.has_method("arrive"):
			my_player.call("arrive", spawn_pos)   # held until the new map's ground is real
		else:
			my_player.global_position = spawn_pos
			my_player.velocity = Vector3.ZERO
		if "grapple_hooked" in my_player:
			my_player.grapple_hooked = false

	# Re-apply graphics settings for new WorldEnvironment
	Settings.apply_settings()
	# current_map_path / enter_map moved ABOVE the instantiation — see there.
	# Persist the current world (room-global) so a rejoining peer restores this
	# level (not the default). world:settings:* is admin-gated.
	GameState.set_state("world:settings:current_map", {"path": map_path, "ts": Time.get_unix_time_from_system()})
	# We have moved worlds: tell the room, and re-evaluate who we can see. Both
	# halves matter — without the publish nobody hides US, without the refresh we
	# keep drawing the peers we just left behind.
	_publish_my_map()
	_update_map_visibility()
	print("[Game] Loaded map: %s" % map_path)


## Coordinate atmosphere ownership between game.gd's Sky3DExtended and the loaded map.
##  - If the map has its own top-level WorldEnvironment (e.g. map_dc_terrain has its
##    Sky3DExtended + Sunshine driver wired to its own sun/moon paths), DETACH the
##    game.gd Sky3D from the tree so the map's atmosphere takes over without duplication.
##  - If the map has no WorldEnvironment, ensure game.gd's Sky3D is in the tree (re-add
##    it if previously detached) — the canonical fallback for plain maps like map_test.
##  - Standalone DirectionalLight3D nodes at map root are stripped only when game.gd
##    owns the atmosphere; if the map owns it, those lights are part of the map's
##    Sky3D wiring and stay put.
func _remove_map_world_environment(root: Node) -> void:
	var map_has_world_env := false
	for child in root.get_children():
		if child is WorldEnvironment:
			map_has_world_env = true
			break

	if map_has_world_env:
		# Map owns the atmosphere — detach game.gd's Sky3D from the tree.
		# (WorldEnvironment is a Node, not a Node3D, so it has no `visible` flag —
		# tree detachment is the standard way to suspend it. Reference is preserved
		# on `sky3d` so we can re-add it on the next map without one.)
		if sky3d and sky3d.is_inside_tree():
			remove_child(sky3d)
			print("[Game] Map has own WorldEnvironment — detached game.gd Sky3D")
	else:
		# game.gd owns the atmosphere — re-attach Sky3D if it was previously detached.
		if sky3d and not sky3d.is_inside_tree():
			add_child(sky3d)
			print("[Game] Re-attached game.gd Sky3D")
		for child in root.get_children():
			if child is DirectionalLight3D:
				print("[Game] Removing map DirectionalLight3D: %s" % child.name)
				child.queue_free()


## RPC to sync map changes across the mesh. Authority calls this to change
## the map on all peers. Only admin/mod roles can trigger map changes.
@rpc("any_peer", "call_local", "reliable")
func _sync_map_change(map_path: String, spawn_x: float, spawn_y: float, spawn_z: float) -> void:
	var sender_id := multiplayer.get_remote_sender_id()
	# Allow local calls (sender_id == 0) and authority
	if sender_id != 0 and sender_id != get_multiplayer_authority():
		push_warning("[Game] _sync_map_change rejected from non-authority peer %d" % sender_id)
		return
	change_map(map_path, Vector3(spawn_x, spawn_y, spawn_z))


func _spawn_player(data: Variant) -> Node:
	var id: int = data as int
	var scene: PackedScene = PlayerProceduralScene if use_procedural_player else PlayerScene
	var player: CharacterBody3D = scene.instantiate()
	player.name = str(id)
	player.set_multiplayer_authority(id)
	_log("[Game] _spawn_player id=%d local=%s procedural=%s" % [id, id == multiplayer.get_unique_id(), use_procedural_player])
	DebugLog.log("game", "SPAWNING PLAYER id=%d local=%s procedural=%s" % [id, id == multiplayer.get_unique_id(), use_procedural_player])

	if not _live_spawn_points().is_empty():
		var live2 := _live_spawn_points()
		player.position = live2[_spawn_index % live2.size()].global_position
		_spawn_index += 1
	else:
		player.position = Vector3(0, 2, 0)

	# When the local player is spawned, switch from lobby camera to player camera
	if id == multiplayer.get_unique_id():
		lobby_camera.current = false
		PlayerPosition.attach_local_player(player)

	return player


# ---- Benchmark Stats ----

func start_benchmark() -> void:
	benchmark_active = true
	_benchmark_start_time = Time.get_ticks_msec() / 1000.0
	_benchmark_stats.clear()


func _collect_benchmark_stats() -> void:
	var mp = multiplayer.multiplayer_peer
	if mp == null or mp is OfflineMultiplayerPeer:
		return
	var my_id: int = multiplayer.get_unique_id()
	var my_player: Node = players_node.get_node_or_null(str(my_id))
	if not my_player:
		return

	for child in players_node.get_children():
		if child == my_player:
			continue
		var pid: int = child.name.to_int()
		if not _benchmark_stats.has(pid):
			_benchmark_stats[pid] = {"rtt_samples": [], "pos_errors": [], "sync_count": 0}
		var entry: Dictionary = _benchmark_stats[pid]

		# RTT from GDExtension
		if mp.has_method("get_peer_rtt"):
			var rtt: Dictionary = mp.get_peer_rtt(pid)
			if not rtt.is_empty():
				var srtt: int = rtt.get("srtt", 0)
				if srtt > 0:
					entry["rtt_samples"].append(srtt)

		# Position error: interpolation lag between decoded sync position and lerped position.
		# Low values mean the remote is tracking the authority closely.
		var decoded_pos: Variant = child.get("_decoded_position")
		var actual_pos: Vector3 = child.global_position
		if decoded_pos is Vector3 and (decoded_pos as Vector3) != Vector3.ZERO:
			var error_m: float = (decoded_pos as Vector3).distance_to(actual_pos)
			entry["pos_errors"].append(error_m)
			entry["sync_count"] += 1


func get_benchmark_summary() -> Dictionary:
	var elapsed: float = (Time.get_ticks_msec() / 1000.0) - _benchmark_start_time
	var peers := {}
	for pid in _benchmark_stats:
		var entry: Dictionary = _benchmark_stats[pid]
		var rtt_arr: Array = entry["rtt_samples"]
		var pos_arr: Array = entry["pos_errors"]
		var peer_data := {}

		if not rtt_arr.is_empty():
			var rtt_sum: int = 0
			var rtt_min: int = rtt_arr[0]
			var rtt_max: int = rtt_arr[0]
			for v in rtt_arr:
				rtt_sum += v
				if v < rtt_min:
					rtt_min = v
				if v > rtt_max:
					rtt_max = v
			peer_data["rtt_avg_ms"] = rtt_sum / rtt_arr.size()
			peer_data["rtt_min_ms"] = rtt_min
			peer_data["rtt_max_ms"] = rtt_max
			peer_data["rtt_sample_count"] = rtt_arr.size()
			# Jitter: mean absolute deviation from average
			var avg: float = float(rtt_sum) / rtt_arr.size()
			var jitter_sum: float = 0.0
			for v in rtt_arr:
				jitter_sum += absf(float(v) - avg)
			peer_data["rtt_jitter_ms"] = roundi(jitter_sum / rtt_arr.size())

		if not pos_arr.is_empty():
			var pos_sum: float = 0.0
			var pos_max: float = 0.0
			for v in pos_arr:
				pos_sum += v
				if v > pos_max:
					pos_max = v
			peer_data["position_error_avg_m"] = snappedf(pos_sum / pos_arr.size(), 0.001)
			peer_data["position_error_max_m"] = snappedf(pos_max, 0.001)

		peer_data["sync_count"] = entry["sync_count"]
		peers[str(pid)] = peer_data

	return {
		"running": benchmark_active,
		"elapsed_s": snappedf(elapsed, 0.1),
		"peers": peers,
	}


# ---- Interest Management ----

## Tiered interest management: adjust what we send to each peer based on distance.
## On our own player's synchronizers, set_visibility_for() controls per-peer sending.
## replication_interval is set based on closest peer distance (global, not per-peer).
func _update_interest() -> void:
	if not _game_active or not interest_enabled:
		return
	var my_id := multiplayer.get_unique_id()
	var my_player := players_node.get_node_or_null(str(my_id))
	if not my_player:
		return
	var my_pos: Vector3 = my_player.global_position

	# Get our own synchronizers (we're authority for these)
	var my_player_sync := my_player.get_node_or_null("PlayerSync") as MultiplayerSynchronizer
	var my_input_sync := my_player.get_node_or_null("InputSync/InputSynchronizer") as MultiplayerSynchronizer

	var min_dist: float = INF

	for child in players_node.get_children():
		if child == my_player:
			continue
		var peer_id: int = child.name.to_int()
		var dist: float = my_pos.distance_to(child.global_position)
		if dist < min_dist:
			min_dist = dist

		# Another world outranks distance. Interest is an optimisation that
		# interest_enabled can switch off; "they are not here" must not be.
		if not _peer_shares_our_map(peer_id):
			if my_player_sync:
				my_player_sync.set_visibility_for(peer_id, false)
			if my_input_sync:
				my_input_sync.set_visibility_for(peer_id, false)
			child.visible = false
			continue

		if dist > interest_max_range:
			# Out of range — stop sending to this peer and hide them locally
			if my_player_sync:
				my_player_sync.set_visibility_for(peer_id, false)
			if my_input_sync:
				my_input_sync.set_visibility_for(peer_id, false)
			child.visible = false
		else:
			# In range — ensure we send to this peer and show them
			if my_player_sync:
				my_player_sync.set_visibility_for(peer_id, true)
			if my_input_sync:
				my_input_sync.set_visibility_for(peer_id, true)
			child.visible = true

	# Set our own replication interval based on closest peer distance
	if my_player_sync and min_dist < INF:
		for tier in interest_tiers:
			if min_dist < tier[0]:
				my_player_sync.replication_interval = tier[1]
				if my_input_sync:
					my_input_sync.replication_interval = tier[1]
				break


# ---- Network Debug Overlay ----

func _create_net_overlay() -> void:
	# Background panel
	var panel := PanelContainer.new()
	panel.name = "NetOverlay"
	panel.anchor_left = 1.0
	panel.anchor_right = 1.0
	panel.offset_left = -280.0
	panel.offset_top = 10.0
	panel.offset_right = -10.0
	panel.offset_bottom = 220.0
	panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
	var style := StyleBoxFlat.new()
	style.bg_color = Color(0, 0, 0, 0.5)
	style.corner_radius_top_left = 4
	style.corner_radius_top_right = 4
	style.corner_radius_bottom_left = 4
	style.corner_radius_bottom_right = 4
	style.content_margin_left = 8
	style.content_margin_right = 8
	style.content_margin_top = 6
	style.content_margin_bottom = 6
	panel.add_theme_stylebox_override("panel", style)

	_net_overlay = RichTextLabel.new()
	_net_overlay.bbcode_enabled = true
	_net_overlay.scroll_active = false
	_net_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_net_overlay.add_theme_font_size_override("normal_font_size", 11)
	_net_overlay.add_theme_color_override("default_color", Color(0.85, 0.85, 0.85))
	panel.add_child(_net_overlay)
	$HUD.add_child(panel)

	# Poll native GDExtension stats every 3 seconds
	_net_timer = Timer.new()
	_net_timer.wait_time = 3.0
	_net_timer.timeout.connect(_update_net_overlay)
	add_child(_net_timer)
	_net_timer.start()
	_update_net_overlay()


func _update_net_overlay() -> void:
	if not _net_overlay:
		return
	var lines := PackedStringArray()
	lines.append("[b][color=#66ccff]Network[/color][/b]  build: %s" % build_version)

	var mp = multiplayer.multiplayer_peer
	if mp and not mp is OfflineMultiplayerPeer and mp.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED:
		# DHT status from GDExtension
		if mp.has_method("get_dht_status"):
			var dht: Dictionary = mp.get_dht_status()
			var port: int = dht.get("bound_port", 0)
			lines.append("DHT: port %d  active: %s" % [port, "yes" if dht.get("active", false) else "no"])

		# Swarm status from GDExtension
		if mp.has_method("get_swarm_status"):
			var sw: Dictionary = mp.get_swarm_status()
			var conns: int = sw.get("peers_connected", 0)
			var known: int = sw.get("peers_known", 0)
			lines.append("Swarm: %d/%d peers (connected/known)" % [conns, known])

		# Game players
		var my_id := multiplayer.get_unique_id()
		var peer_ids := multiplayer.get_peers()
		var total := 1 + peer_ids.size()
		var role := "authority" if is_multiplayer_authority() else "peer"
		lines.append("[b]Players: %d[/b]  (you=%d %s)" % [total, my_id, role])
		for pid in peer_ids:
			var rtt_str := ""
			if mp.has_method("get_peer_rtt"):
				var rtt: Dictionary = mp.get_peer_rtt(pid)
				if not rtt.is_empty():
					rtt_str = "  rtt=%dms min=%dms" % [rtt.get("srtt", 0), rtt.get("min_rtt", 0)]
			lines.append("  peer %d [color=#66ff66]connected[/color]%s" % [pid, rtt_str])
		if peer_ids.is_empty():
			lines.append("  [color=#999999]no other players yet[/color]")
	else:
		lines.append("[b]Players:[/b] offline")

	# Keet daemon status
	if KeetService.client.is_connected:
		lines.append("Keet: [color=#66ff66]ok[/color]")
	else:
		lines.append("Keet: [color=#ff6666]offline[/color]")

	# Headless mode: log stats to stdout for remote monitoring
	if DisplayServer.get_name() == "headless":
		var plain := PackedStringArray()
		for line in lines:
			# Strip BBCode tags for plain-text output
			var stripped := line
			while stripped.find("[") != -1:
				var open := stripped.find("[")
				var close := stripped.find("]", open)
				if close == -1:
					break
				stripped = stripped.substr(0, open) + stripped.substr(close + 1)
			stripped = stripped.strip_edges()
			if not stripped.is_empty():
				plain.append(stripped)
		DebugLog.log("network", " | ".join(plain))

	_net_overlay.text = "\n".join(lines)