NEO-22: fix stream rubber-banding (coalesce, idle gate, soft snap)

Queue only latest move-stream target per POST; clear pending while busy.
Skip stream submits when idle; skip tiny authoritative snaps after boot.
pull/42/head
VinPropane 2026-04-17 19:27:10 -04:00
parent a2c6c9f93e
commit 878b1f4aee
3 changed files with 36 additions and 15 deletions

View File

@ -53,7 +53,7 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen
## Authoritative movement (NEO-7, NEO-11, NEO-22)
With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with a batch of **absolute** world positions (NEO-22 **option C**). The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**; the client **snaps** to that position. **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player.
With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with the **latest** body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). **`PositionAuthorityClient`** coalesces to **one target per request** so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**; the client **snaps** when the correction exceeds a small threshold (boot snap always applies once). **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player.
**Boot** `sync_from_server()` **snaps** once so spawn matches the server.

View File

@ -23,9 +23,18 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0
## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec).
const STREAM_PHYSICS_INTERVAL: int = 6
## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps.
const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004
## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise.
const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08
const AUTH_SNAP_MIN_VERTICAL: float = 0.04
## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0
var _stream_physics_counter: int = 0
## After the first `apply_as_snap` authority update, micro-snaps may be skipped (boot always applies).
var _allow_authority_soft_snap_skip: bool = false
## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**):
## Restored when the obstacle is shown again.
@ -75,11 +84,16 @@ func _physics_process(_delta: float) -> void:
return
var wish: Vector3 = _locomotion_wish_world_xz_from_camera()
_player.call("set_locomotion_wish_world_xz", wish)
_stream_physics_counter += 1
if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL:
var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ
if moving:
_stream_physics_counter += 1
if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL:
_stream_physics_counter = 0
var batch: Array = [_player.global_position]
_authority.call("submit_stream_targets", batch)
else:
_stream_physics_counter = 0
var batch: Array = [_player.global_position]
_authority.call("submit_stream_targets", batch)
var p: Vector3 = _player.global_position
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z]
@ -111,6 +125,13 @@ func _on_target_chosen(world: Vector3) -> void:
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
if apply_as_snap:
if _allow_authority_soft_snap_skip:
var p: Vector3 = _player.global_position
var dh: float = Vector2(world.x - p.x, world.z - p.z).length()
var dy: float = absf(world.y - p.y)
if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL:
return
_allow_authority_soft_snap_skip = true
_player.snap_to_server(world)
else:
_player.set_authoritative_nav_goal(world)

View File

@ -58,8 +58,13 @@ func submit_move_target(world: Vector3) -> void:
_start_move_post(world)
## NEO-22: append samples; sends up to [constant MOVE_STREAM_MAX_PER_REQUEST] per POST when idle.
## NEO-22: append samples; each POST sends **only the latest** queued position so slow HTTP cannot
## apply a stale multi-step chain after the capsule has already moved (rubber-band / bounce).
func submit_stream_targets(targets: Array) -> void:
if _busy:
# In-flight POST already encodes an older chain; drop queued crumbs so we do not replay
# past intent after this response lands.
_stream_queue.clear()
for t: Variant in targets:
if t is Vector3:
_stream_queue.append(t as Vector3)
@ -72,11 +77,9 @@ func _try_flush_pending() -> void:
if _busy:
return
if not _stream_queue.is_empty():
var n: int = mini(_stream_queue.size(), MOVE_STREAM_MAX_PER_REQUEST)
var batch: Array = []
for i in range(n):
batch.append(_stream_queue[i])
_start_stream_post(batch)
var latest: Vector3 = _stream_queue[_stream_queue.size() - 1]
_stream_queue.clear()
_start_stream_post([latest])
return
if _pending_move_target is Vector3:
var w: Vector3 = _pending_move_target as Vector3
@ -200,10 +203,7 @@ func _on_request_completed(
_busy = false
_try_flush_pending()
return
# Drop the batch we sent (validated + applied as a unit on the server).
var drop: int = mini(_stream_in_flight_count, _stream_queue.size())
if drop > 0:
_stream_queue = _stream_queue.slice(drop)
# Queue was cleared when the POST started; nothing to drop.
_stream_in_flight_count = 0
_busy = false
if response_code == 200: