OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / player_position.gd

457 lines · gd ref:
extends Node
## PlayerPosition — durable per-peer last-known position (memberId-keyed).
##
## player:pos:* is REALTIME (ephemeral live movement). This autoload adds a
## DURABLE snapshot of each peer's position so that on rejoin a player is
## restored to where they left off instead of the initial spawn point. We only
## ever write our OWN entry (keyed on the stable Keet memberId); remote peers'
## last positions arrive via autobase replication of their own writes.
##
## Cadence is EVENT-DRIVEN, not a fixed timer (spec game-state.md D4):
## a durable write happens only when the local player has moved more than
## SAVE_DISTANCE since the last save AND at least SAVE_MIN_INTERVAL has elapsed,
## plus a final flush on quit. Idle players generate no autobase churn.
##
## Restore is one-shot per session: when our saved entry is known (at spawn or
## when it later replicates in) we teleport the local player to it once, as long
## as it hasn't already wandered away from its spawn.
##
## Route is registered by game.gd: world:players:* -> DURABLE, "peer".

const PREFIX := "world:players:"
const SAVE_DISTANCE := 5.0          # metres moved before a new durable write
const SAVE_MIN_INTERVAL := 3.0      # seconds between durable writes (throttle)
const NO_SAVE_GRACE := 10.0         # if no saved pos has arrived this long after
                                    # spawn, assume a fresh world (nothing to
                                    # restore) and stop blocking writes.
const RESTORE_MAX_DRIFT := 30.0     # only SKIP restore if the player has clearly
                                    # run far (XZ) from spawn = actively playing.
                                    # 5m (SAVE_DISTANCE) was too tight: spawn-fall
                                    # + a few steps before the ~3s state.list load
                                    # tripped it. 30m = genuinely relocated.
const RESTORE_GRACE := 90.0         # generous BACKSTOP cap only. The real
                                    # "safe to restore" gate is horizontal (XZ)
                                    # distance from spawn (see _try_restore):
                                    # gravity only moves Y, so XZ distance is a
                                    # reliable "player hasn't actively moved"
                                    # signal. The old 8s window permanently gave
                                    # up before the daemon's state.list/identity
                                    # arrived (room-open latency) → never restored.

# Durable mirror: member_id -> { memberId, pos:[x,y,z], ts, grounded }
var _positions: Dictionary = {}

var _my_member_id: String = ""

var _local_player: Node3D = null
var _attach_time: float = 0.0
var _spawn_position: Vector3 = Vector3.ZERO  # where the player spawned this session
var _last_saved_pos: Vector3 = Vector3.ZERO
var _last_save_time: float = 0.0
var _restored_this_session: bool = false
var _restore_waiting_logged: bool = false


func _ready() -> void:
	call_deferred("_wire_up")


func _wire_up() -> void:
	var gs := get_node_or_null("/root/GameState")
	if gs != null:
		gs.state_changed.connect(_on_state_changed)
		gs.map_changing.connect(_on_map_changing)
		# Seed from any state already loaded.
		for key in gs.keys_with_prefix(PREFIX):
			var member_id: String = key.substr(PREFIX.length())
			var value: Variant = gs.get_state(key)
			if value != null:
				_positions[member_id] = value
	var ks := get_node_or_null("/root/KeetService")
	if ks != null and ks.identity != null:
		ks.identity.identity_loaded.connect(_on_identity_loaded)
		ks.identity.get_identity()


## Enter-map lifecycle: on a TELEPORT (player already exists), change_map has
## already placed us at the link destination — don't restore/yank to a saved
## position. On cold-join no local player exists yet, so restore still runs at
## spawn. We keep saving the new map's position as we move either way.
func _on_map_changing(_map_id: String) -> void:
	if _local_player != null:
		_restored_this_session = true
		_restore_waiting_logged = false


func _on_identity_loaded(identity: Dictionary) -> void:
	_my_member_id = str(identity.get("memberId", ""))
	# Identity may arrive after spawn — try restoring now that we know our key.
	_try_restore()


# ---- Local player lifecycle (called by game.gd) ----

## Attach the freshly-spawned local player. Records its spawn position so we can
## tell "hasn't moved yet" (safe to restore) from "already playing".
func attach_local_player(player: Node3D) -> void:
	_local_player = player
	_attach_time = Time.get_unix_time_from_system()
	_spawn_position = player.global_position
	_last_saved_pos = player.global_position
	_last_save_time = 0.0
	_restored_this_session = false
	_try_restore()


## The local player, or null before one is spawned.
##
## Public because the handle is already maintained here and every other reader was
## reaching for `_local_player` through get(), which returns null silently for a
## name with a leading underscore -- game.gd says so where it does it. A bot brain
## that has to find the body it drives should not be re-deriving from the scene
## tree what this autoload is told directly by _spawn_player_for.
func get_local_player() -> Node3D:
	if _local_player != null and is_instance_valid(_local_player):
		return _local_player
	return null


## Detach (player removed / room left). Flush a final snapshot first.
func detach_local_player() -> void:
	_flush()
	_local_player = null


# ---- Saving (event-driven: movement + throttle) ----

func _process(_delta: float) -> void:
	if not _player_ready():
		return
	if not _restored_this_session:
		_try_restore()
		return  # don't SAVE until restore is decided — else the spawn/fall
		        # position overwrites (clobbers) our real saved position.
	var pos: Vector3 = _local_player.global_position
	if pos.distance_to(_last_saved_pos) < SAVE_DISTANCE:
		return
	var now: float = Time.get_unix_time_from_system()
	if now - _last_save_time < SAVE_MIN_INTERVAL:
		return
	_write(pos, now)


## Force a final write (quit / detach), bypassing the distance gate.
func _flush() -> void:
	if not _player_ready():
		return
	_write(_local_player.global_position, Time.get_unix_time_from_system())


## True only when the local player is alive AND in the tree — global_position
## errors with "!is_inside_tree()" and returns identity (0,0,0) once the node
## leaves the tree (quit/teardown), which would otherwise persist a bogus origin.
func _player_ready() -> bool:
	return _local_player != null and is_instance_valid(_local_player) and _local_player.is_inside_tree()


func _write(pos: Vector3, now: float) -> void:
	if _my_member_id == "":
		return
	if not (is_finite(pos.x) and is_finite(pos.y) and is_finite(pos.z)):
		return
	# Defensive: never persist exact origin — it's the identity transform returned
	# when the node is out-of-tree, not a real position.
	if pos == Vector3.ZERO:
		return
	var gs := get_node_or_null("/root/GameState")
	if gs == null:
		return
	# WAS THE PLAYER STANDING ON SOMETHING? This is the ONLY thing that
	# distinguishes a save the terrain later stranded from a save made in mid-air
	# on purpose -- a grapple, a ski jump, a fall. Both look identical at restore
	# time: a point with air under it. Without this flag the restore either drops
	# flying players out of the sky or leaves stranded ones to fall 10 km, and it
	# cannot tell which it is doing.
	var grounded: bool = false
	if _local_player.has_method("is_on_floor"):
		grounded = bool(_local_player.is_on_floor())
	gs.set_state(PREFIX + _my_member_id, {
		"memberId": _my_member_id,
		"pos": [pos.x, pos.y, pos.z],
		"ts": now,
		"grounded": grounded,
	})
	_last_saved_pos = pos
	_last_save_time = now


# ---- Restore (one-shot per session) ----

func _try_restore() -> void:
	if _restored_this_session:
		return
	if not _player_ready():
		return
	if _my_member_id == "":
		return
	var saved: Variant = _saved_position_for(_my_member_id)
	if saved == null:
		# Fresh world (or saved entry never arrives): after a short grace, stop
		# blocking writes so this session's position starts persisting.
		if Time.get_unix_time_from_system() - _attach_time > NO_SAVE_GRACE:
			_restored_this_session = true
			_kgs_log("restore: give up — no saved pos after %.0fs grace" % NO_SAVE_GRACE)
		elif not _restore_waiting_logged:
			_restore_waiting_logged = true
			_kgs_log("restore: waiting — saved==null (positions_known=%d my_id=%s)" % [_positions.size(), _my_member_id.substr(0, 8)])
		return  # not loaded yet — retry when identity/state replicates in
	# Safe-to-restore gate: don't yank a player who has actively moved AWAY from
	# spawn. Gravity only affects Y, so use HORIZONTAL (XZ) distance from the
	# spawn point — moving in XZ means real player input. This (not a short time
	# window) is the primary gate, so a late state.list still restores us.
	var cur: Vector3 = _local_player.global_position
	var moved_xz: float = Vector2(cur.x - _spawn_position.x, cur.z - _spawn_position.z).length()
	var too_late: bool = Time.get_unix_time_from_system() - _attach_time > RESTORE_GRACE
	if moved_xz > RESTORE_MAX_DRIFT or too_late:
		_restored_this_session = true  # genuinely relocated / backstop hit — don't yank
		_kgs_log("restore: SKIP — moved_xz=%.1f too_late=%s (saved=%s)" % [moved_xz, str(too_late), str(saved)])
		return
	# THE WORLD CAN MOVE UNDER A SAVED POSITION. Saved positions are absolute
	# metres, but a map's terrain is generated, and any change to its parameters
	# relocates the surface. When map_dc_erosion's vertical scale changed, its
	# surface moved from ~-900..4800 m to ~5500..15100 m while a saved position of
	# y=5200 stayed put -- so the restore buried the player 4 km underground.
	#
	# The symptom is NOT "I am underground", because there is nothing to see down
	# there: every chunk around the camera is fully solid, so the mesher emits no
	# geometry, and the surface is kilometres overhead at coarse LOD. It reads as
	# "the terrain is gone", with a completely clean log. That cost most of a
	# debugging session.
	#
	# So: if the saved point is inside solid ground, lift it to the surface rather
	# than honouring a position the terrain no longer agrees with.
	var was_grounded: Variant = _saved_grounded_for(_my_member_id)
	saved = _reconcile_with_terrain(saved, was_grounded)

	# ARRIVE (player_procedural.arrive): held until the ground there is real, so a
	# restore onto terrain whose collision is not built yet does not fall through.
	# Snapped down only when the save was STANDING — an airborne save stays airborne;
	# a buried one is lifted either way.
	if _local_player.has_method("arrive"):
		_local_player.call("arrive", saved, was_grounded == true)
	else:
		_local_player.global_position = saved
		if "velocity" in _local_player:
			_local_player.velocity = Vector3.ZERO
	_last_saved_pos = saved
	_spawn_position = saved  # restored point is the new "haven't moved" origin
	_restored_this_session = true
	_kgs_log("restore: OK -> %s (moved_xz=%.1f)" % [str(saved), moved_xz])
	print("[player-pos] restored local player to %s (moved_xz=%.1f)" % [str(saved), moved_xz])


## Re-arm the one-shot restore after a cold-join world restore. on_game_started
## now spawns the local player BEFORE restoring the saved map (so the joiner is
## never frozen on the lobby camera). That map switch fires map_changing, which
## _on_map_changing treats as a teleport and suppresses restore. Once the correct
## map is active (and its world:players entries have re-loaded), game.gd calls this
## so a rejoining peer still lands at its saved position. Harmless for fresh
## joiners: no saved entry -> _try_restore gives up after the grace window.
func restore_after_cold_join() -> void:
	if _local_player == null:
		return
	_restored_this_session = false
	_restore_waiting_logged = false
	_attach_time = Time.get_unix_time_from_system()  # fresh grace window for the new map
	_spawn_position = _local_player.global_position   # current = the new map's spawn point
	_try_restore()


func _kgs_log(msg: String) -> void:
	if not OS.has_environment("HTPS_VERBOSE"):
		return  # gated — set HTPS_VERBOSE=1 to re-enable diagnostic logging
	var line := "[%s] [player-pos] %s" % [Time.get_datetime_string_from_system(), msg]
	printerr("HTPSDIAG " + line)
	var f := FileAccess.open("/tmp/hyper-tps-diag.log", FileAccess.READ_WRITE)
	if f == null:
		f = FileAccess.open("/tmp/hyper-tps-diag.log", FileAccess.WRITE)
	if f != null:
		f.seek_end()
		f.store_line(line)
		f.close()


## Saved Vector3 for THIS peer (for spawn-at-saved in game.gd), or null if
## unknown (identity not loaded yet or no saved entry). Read after room state load.
func get_saved_local_position() -> Variant:
	if _my_member_id == "":
		return null
	return _saved_position_for(_my_member_id)


## Saved Vector3 for a member, or null if unknown/invalid.
func _saved_position_for(member_id: String) -> Variant:
	var entry: Variant = _positions.get(member_id)
	if entry is Dictionary and entry.has("pos"):
		var p: Variant = entry["pos"]
		if p is Array and p.size() == 3:
			return Vector3(p[0], p[1], p[2])
	return null


## True/false if the record says whether the player was standing when it was
## written; null for records written before the field existed -- which is NOT
## the same as false, and is treated as "unknown, do not touch".
func _saved_grounded_for(member_id: String) -> Variant:
	var entry: Variant = _positions.get(member_id)
	if entry is Dictionary and entry.has("grounded"):
		return bool(entry["grounded"])
	return null


# ---- Durable mirror ----

func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
	if not key.begins_with(PREFIX):
		return
	var member_id: String = key.substr(PREFIX.length())
	if value == null:
		_positions.erase(member_id)
		return
	_positions[member_id] = value
	if member_id == _my_member_id:
		_try_restore()


func _notification(what: int) -> void:
	if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_EXIT_TREE:
		_flush()


## Find the active DCTerrain, if this map has one. Returns null otherwise, which
## is the normal case for maps built from static geometry.
func _find_terrain() -> Node:
	var root := get_tree().get_root()
	var stack: Array[Node] = [root]
	var visited := 0
	while not stack.is_empty() and visited < 4000:
		var n: Node = stack.pop_back()
		visited += 1
		if n.has_method("sample_density") and n.has_method("get_chunk_count"):
			return n
		for c in n.get_children():
			stack.push_back(c)
	return null


## If `pos` is inside solid terrain, return a point just above the surface in the
## same column. Otherwise return `pos` unchanged.
##
## Density sign is measured, not assumed: sample_density is POSITIVE below the
## surface and NEGATIVE in air (dc_terrain.cpp::get_distance returns its
## negation, and a probe at map_dc_erosion's origin reads +9320 at y=0 against
## -380 at y=9700). A comment in dc_terrain_generator_procnodes.cpp claims the
## opposite convention; the measurement wins.
## Reconcile a saved position with the terrain as it stands NOW.
##
## Two directions, and they are NOT symmetric on purpose.
##
## BURIED is unambiguous: there is solid ground where the player was, so the
## terrain has moved up under them. Always lift.
##
## STRANDED IN AIR is ambiguous, and only the saved `grounded` flag resolves it.
## A player who was STANDING when the position was written and now has kilometres
## of air beneath them was stranded by the terrain -- put them back on the
## surface. A player who was AIRBORNE was legitimately in the air, and snapping
## them down would destroy a real position. A record with no flag predates it and
## cannot be told apart, so it is left ALONE: this only lifts positions the
## terrain provably stranded.
func _reconcile_with_terrain(pos: Vector3, was_grounded: Variant) -> Vector3:
	var terrain := _find_terrain()
	if terrain == null:
		return pos
	if terrain.sample_density(pos) > 0.0:
		return _lift_out_of_terrain(pos)
	if was_grounded != true:
		return pos
	return _settle_onto_terrain(pos, terrain)


## The player was standing here and now is not. Find the surface BELOW and put
## them back on it. Mirrors _lift_out_of_terrain: coarse walk to bracket, then
## bisect. Returns pos unchanged if the gap is within tolerance (ordinary settling
## and step height, not a moved world) or if no surface is found at all.
func _settle_onto_terrain(pos: Vector3, terrain: Node) -> Vector3:
	const STEP := 250.0
	const MAX_DROP := 40000.0
	## A grounded save legitimately sits a little above the surface -- the capsule
	## radius, a step, one frame of settling. Below this, nothing moved.
	const SNAP_TOLERANCE := 5.0
	var above := pos.y
	var y := pos.y - STEP
	var below := 0.0
	var bracketed := false
	while pos.y - y <= MAX_DROP:
		if terrain.sample_density(Vector3(pos.x, y, pos.z)) > 0.0:
			below = y
			bracketed = true
			break
		above = y
		y -= STEP
	if not bracketed:
		push_warning("[player-pos] saved position %s was standing on ground that " % str(pos) +
				"no longer exists, and no surface was found within %.0f m below " % MAX_DROP +
				"it — leaving it alone.")
		return pos
	for _i in range(24):
		var m: float = (below + above) * 0.5
		if terrain.sample_density(Vector3(pos.x, m, pos.z)) > 0.0:
			below = m
		else:
			above = m
	if pos.y - above <= SNAP_TOLERANCE:
		return pos
	var settled := Vector3(pos.x, above + 0.5, pos.z)
	print("[player-pos] saved position %s was standing %.0f m ABOVE the surface " % [str(pos), pos.y - above] +
			"(the map's surface moved since it was saved) — settled to %s" % str(settled))
	return settled


func _lift_out_of_terrain(pos: Vector3) -> Vector3:
	var terrain := _find_terrain()
	if terrain == null:
		return pos
	var d: float = terrain.sample_density(pos)
	if d <= 0.0:
		return pos  # already in air

	# Walk up in coarse steps to bracket the surface, then bisect. The step has
	# to clear whatever the terrain moved by, so it is generous; this runs once.
	const STEP := 250.0
	const MAX_RISE := 40000.0
	var below := pos.y
	var y := pos.y + STEP
	var above := 0.0
	var bracketed := false
	while y - pos.y <= MAX_RISE:
		if terrain.sample_density(Vector3(pos.x, y, pos.z)) <= 0.0:
			above = y
			bracketed = true
			break
		below = y
		y += STEP
	if not bracketed:
		push_warning("[player-pos] saved position %s is inside terrain and no " % str(pos) +
				"surface was found within %.0f m above it — leaving it alone." % MAX_RISE)
		return pos
	for _i in range(24):
		var m: float = (below + above) * 0.5
		if terrain.sample_density(Vector3(pos.x, m, pos.z)) > 0.0:
			below = m
		else:
			above = m
	var lifted := Vector3(pos.x, above + 2.0, pos.z)
	print("[player-pos] saved position %s was %.0f m INSIDE terrain " % [str(pos), above - pos.y] +
			"(the map's surface moved since it was saved) — lifted to %s" % str(lifted))
	return lifted