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