diff --git a/client/index.html b/client/index.html index d20c926..821d31a 100644 --- a/client/index.html +++ b/client/index.html @@ -19,7 +19,6 @@ -
+ diff --git a/client/public/version.js b/client/public/version.js index 548862f..2b1b283 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.25 R250"; +window.CHGRID_WEB_VERSION = "2026.02.25 R251"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index a108cb2..327e88c 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1130,39 +1130,6 @@ function formatCoordinate(value: number): string { return value.toFixed(2).replace(/\.?0+$/, ''); } -/** Persists current local player coordinates for reconnect/refresh restore. */ -function persistPlayerPosition(): void { - try { - const positionKey = getPlayerPositionStorageKey(); - localStorage.setItem( - positionKey, - JSON.stringify({ x: state.player.x, y: state.player.y }), - ); - } catch { - // Ignore storage failures (private mode/quota/blocked storage). - } -} - -/** Loads previously persisted local player coordinates, when available and valid. */ -function getPersistedPlayerPosition(): { x: number; y: number } | null { - const raw = localStorage.getItem(getPlayerPositionStorageKey()); - if (!raw) return null; - try { - const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown }; - if (typeof parsed.x !== 'number' || typeof parsed.y !== 'number') return null; - if (!Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) return null; - return { x: Math.round(parsed.x), y: Math.round(parsed.y) }; - } catch { - return null; - } -} - -/** Resolves local storage key for per-account saved player position. */ -function getPlayerPositionStorageKey(): string { - const usernameKey = sanitizeAuthUsername(authUsername); - return usernameKey ? `spatialChatPosition:${usernameKey}` : 'spatialChatPosition'; -} - /** Picks one random footstep sample URL. */ function randomFootstepUrl(): string { return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)]; @@ -1248,7 +1215,6 @@ function updateTeleport(): void { signaling.send({ type: 'teleport_complete', x: activeTeleport.targetX, y: activeTeleport.targetY }); activeTeleport = null; stopTeleportLoopAudio(); - persistPlayerPosition(); void refreshAudioSubscriptions(true); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); updateStatus(completionStatus); @@ -1305,7 +1271,6 @@ function handleMovement(): void { state.player.x = nextX; state.player.y = nextY; lastWallCollisionDirection = null; - persistPlayerPosition(); state.player.lastMoveTime = now; void refreshAudioSubscriptions(true); void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, movementTickMs); @@ -1557,7 +1522,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps { pushChatMessage(message); }, updateConnectAvailability, - settingsSaveNickname: (value) => settings.saveNickname(value), mediaIsConnecting: () => mediaSession.isConnecting(), mediaSetConnecting: (value) => mediaSession.setConnecting(value), mediaCheckMicPermission: () => checkMicPermission(), @@ -1570,7 +1534,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps { signalingSendAuth: () => sendAuthRequest(), signalingDisconnect: () => signaling.disconnect(), onMessage: (message) => onSignalingMessage(message as IncomingMessage), - persistPlayerPosition, peerManagerCleanupAll: () => peerManager.cleanupAll(), radioCleanupAll: () => radioRuntime.cleanupAll(), emitCleanupAll: () => itemEmitRuntime.cleanupAll(), @@ -1615,7 +1578,6 @@ const onAppMessage = createOnMessageHandler({ mediaSession.setConnecting(value); updateConnectAvailability(); }, - getPersistedPlayerPosition, rendererSetGridSize: (size) => renderer.setGridSize(size), applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters[0]), state, @@ -2670,10 +2632,6 @@ function setupUiHandlers(): void { }, updateDeviceSummary, setOutputDevice: (id) => peerManager.setOutputDevice(id), - persistOnUnload: () => { - if (!state.running) return; - persistPlayerPosition(); - }, }); dom.showRegisterButton.addEventListener('click', () => { setAuthMode('register'); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index d315cad..efa7cb6 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -9,7 +9,6 @@ type MessageHandlerDeps = { setWorldGridSize: (size: number) => void; setMovementTickMs: (value: number) => void; setConnecting: (value: boolean) => void; - getPersistedPlayerPosition: () => { x: number; y: number } | null; rendererSetGridSize: (size: number) => void; applyServerItemUiDefinitions: (defs: unknown) => boolean; state: { @@ -105,11 +104,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco deps.state.player.id = message.id; deps.state.running = true; deps.setConnecting(false); - const persistedPosition = deps.getPersistedPlayerPosition(); - const targetX = persistedPosition?.x ?? message.player.x; - const targetY = persistedPosition?.y ?? message.player.y; - deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, targetX)); - deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, targetY)); + deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.x)); + deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.y)); deps.dom.connectButton.classList.add('hidden'); deps.dom.disconnectButton.classList.remove('hidden'); deps.dom.focusGridButton.classList.remove('hidden'); diff --git a/client/src/session/connectionFlow.ts b/client/src/session/connectionFlow.ts index cf6f49e..adf6964 100644 --- a/client/src/session/connectionFlow.ts +++ b/client/src/session/connectionFlow.ts @@ -17,7 +17,6 @@ export type ConnectFlowDeps = { sanitizeName: (value: string) => string; updateStatus: (message: string) => void; updateConnectAvailability: () => void; - settingsSaveNickname: (value: string) => void; mediaIsConnecting: () => boolean; mediaSetConnecting: (value: boolean) => void; mediaCheckMicPermission: () => Promise; @@ -30,7 +29,6 @@ export type ConnectFlowDeps = { signalingSendAuth: () => void; signalingDisconnect: () => void; onMessage: (message: unknown) => Promise; - persistPlayerPosition: () => void; peerManagerCleanupAll: () => void; radioCleanupAll: () => void; emitCleanupAll: () => void; @@ -46,9 +44,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise { } const nickname = deps.sanitizeName(deps.state.player.nickname); deps.state.player.nickname = nickname || deps.state.player.nickname; - if (nickname) { - deps.settingsSaveNickname(nickname); - } deps.mediaSetConnecting(true); deps.updateConnectAvailability(); @@ -105,9 +100,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise { */ export function runDisconnectFlow(deps: ConnectFlowDeps): void { const wasRunning = deps.state.running; - if (deps.state.running) { - deps.persistPlayerPosition(); - } deps.signalingDisconnect(); deps.mediaStopLocalMedia(); diff --git a/client/src/ui/domBindings.ts b/client/src/ui/domBindings.ts index 0c88f3b..fbee96d 100644 --- a/client/src/ui/domBindings.ts +++ b/client/src/ui/domBindings.ts @@ -30,16 +30,12 @@ type UiBindingsDeps = { setPreferredOutput: (id: string, name: string) => void; updateDeviceSummary: () => void; setOutputDevice: (id: string) => Promise; - persistOnUnload: () => void; }; /** * Attaches UI listeners (connect/settings/device changes) and focus traps. */ export function setupUiHandlers(deps: UiBindingsDeps): void { - window.addEventListener('pagehide', deps.persistOnUnload); - window.addEventListener('beforeunload', deps.persistOnUnload); - deps.dom.connectButton.addEventListener('click', () => { void deps.connect(); }); diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index d9e63fd..1b8f018 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -86,6 +86,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - Server is authoritative for all action validation and normalization. - Server is authoritative for movement acceptance (bounds + rate/delta checks). +- Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume. - Client validates incoming packet shapes and applies runtime behavior. - Sound/media field normalization uses shared server policy helpers: - `none/off` normalize to empty values diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 88c34f7..2ed2c12 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -14,7 +14,7 @@ - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance - applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity - - uses `welcome.player` as authoritative starting position + - uses `welcome.player` as authoritative starting position (restored from server-side account state when available) - records `welcome.serverInfo` (`instanceId`, `version`) for restart detection - if `welcome.serverInfo.version` differs from running client version, auto-reloads the page - applies `welcome.uiDefinitions` for item menus/properties/options diff --git a/server/app/auth_service.py b/server/app/auth_service.py index 82fec3b..7eb2be4 100644 --- a/server/app/auth_service.py +++ b/server/app/auth_service.py @@ -31,6 +31,8 @@ class AuthUser: status: str email: str | None last_nickname: str | None + last_x: int | None + last_y: int | None @dataclass(frozen=True) @@ -115,8 +117,8 @@ class AuthService: self._conn.execute( """ INSERT INTO users ( - username, password_hash, email, role, status, last_nickname, created_at_ms, updated_at_ms, last_login_at_ms - ) VALUES (?, ?, ?, ?, 'active', NULL, ?, ?, ?) + username, password_hash, email, role, status, created_at_ms, updated_at_ms, last_login_at_ms + ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) """, (normalized_username, password_hash, normalized_email, role, now_ms, now_ms, now_ms), ) @@ -131,6 +133,24 @@ class AuthService: user = self._get_user_by_username(normalized_username) if user is None: raise AuthError("Failed to load newly created user.") + self._conn.execute( + """ + INSERT OR IGNORE INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms) + VALUES (?, ?, NULL, NULL, ?) + """, + (int(user.id), user.username, now_ms), + ) + self._conn.commit() + user = AuthUser( + id=user.id, + username=user.username, + role=user.role, + status=user.status, + email=user.email, + last_nickname=user.username, + last_x=user.last_x, + last_y=user.last_y, + ) return self._create_session(user) def login(self, username: str, password: str) -> AuthSession: @@ -139,9 +159,19 @@ class AuthService: normalized_username = self._normalize_username(username) user_row = self._conn.execute( """ - SELECT id, username, password_hash, email, role, status, last_nickname - FROM users - WHERE username = ? + SELECT + u.id, + u.username, + u.password_hash, + u.email, + u.role, + u.status, + us.last_nickname, + us.last_x, + us.last_y + FROM users u + LEFT JOIN user_state us ON us.user_id = u.id + WHERE u.username = ? """, (normalized_username,), ).fetchone() @@ -161,6 +191,8 @@ class AuthService: status=user.status, email=user.email, last_nickname=user.username, + last_x=user.last_x, + last_y=user.last_y, ) self._conn.execute( "UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?", @@ -179,9 +211,10 @@ class AuthService: row = self._conn.execute( """ SELECT s.id AS session_id, s.user_id, s.expires_at_ms, s.revoked_at_ms, - u.username, u.role, u.status, u.email, u.last_nickname + u.username, u.role, u.status, u.email, us.last_nickname, us.last_x, us.last_y FROM sessions s JOIN users u ON u.id = s.user_id + LEFT JOIN user_state us ON us.user_id = u.id WHERE s.token_hash = ? """, (token_hash,), @@ -210,6 +243,8 @@ class AuthService: status=row["status"], email=row["email"], last_nickname=row["last_nickname"], + last_x=row["last_x"] if "last_x" in row.keys() else None, + last_y=row["last_y"] if "last_y" in row.keys() else None, ) if not user.last_nickname: self.set_last_nickname(user.id, user.username) @@ -220,6 +255,8 @@ class AuthService: status=user.status, email=user.email, last_nickname=user.username, + last_x=user.last_x, + last_y=user.last_y, ) return AuthSession(session_id=row["session_id"], token=cleaned, user=user) @@ -242,11 +279,47 @@ class AuthService: cleaned = nickname.strip() if not cleaned: return - self._conn.execute( - "UPDATE users SET last_nickname = ?, updated_at_ms = ? WHERE id = ?", - (cleaned, self.now_ms(), user_id), - ) - self._conn.commit() + try: + user_id_value = int(user_id) + except (TypeError, ValueError): + return + try: + self._conn.execute( + """ + INSERT INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms) + VALUES (?, ?, NULL, NULL, ?) + ON CONFLICT(user_id) DO UPDATE SET + last_nickname = excluded.last_nickname, + updated_at_ms = excluded.updated_at_ms + """, + (user_id_value, cleaned, self.now_ms()), + ) + self._conn.commit() + except sqlite3.IntegrityError: + self._conn.rollback() + + def set_last_position(self, user_id: str, x: int, y: int) -> None: + """Persist last known world position for one user.""" + + try: + user_id_value = int(user_id) + except (TypeError, ValueError): + return + try: + self._conn.execute( + """ + INSERT INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms) + VALUES (?, NULL, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + last_x = excluded.last_x, + last_y = excluded.last_y, + updated_at_ms = excluded.updated_at_ms + """, + (user_id_value, int(x), int(y), self.now_ms()), + ) + self._conn.commit() + except sqlite3.IntegrityError: + self._conn.rollback() @staticmethod def now_ms() -> int: @@ -267,7 +340,6 @@ class AuthService: email TEXT UNIQUE, role TEXT NOT NULL CHECK(role IN ('user', 'admin')) DEFAULT 'user', status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active', - last_nickname TEXT, created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, last_login_at_ms INTEGER @@ -290,6 +362,18 @@ class AuthService: ) """ ) + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS user_state ( + user_id INTEGER PRIMARY KEY, + last_nickname TEXT, + last_x INTEGER, + last_y INTEGER, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) self._conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)") self._conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL" @@ -297,6 +381,7 @@ class AuthService: self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)") self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at_ms)") self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash)") + self._conn.execute("CREATE INDEX IF NOT EXISTS idx_user_state_updated ON user_state(updated_at_ms)") self._conn.commit() def _create_session(self, user: AuthUser) -> AuthSession: @@ -321,7 +406,20 @@ class AuthService: """Fetch one user by normalized username.""" row = self._conn.execute( - "SELECT id, username, role, status, email, last_nickname FROM users WHERE username = ?", + """ + SELECT + u.id, + u.username, + u.role, + u.status, + u.email, + us.last_nickname, + us.last_x, + us.last_y + FROM users u + LEFT JOIN user_state us ON us.user_id = u.id + WHERE u.username = ? + """, (username,), ).fetchone() if row is None: @@ -338,7 +436,9 @@ class AuthService: role=row["role"], status=row["status"], email=row["email"], - last_nickname=row["last_nickname"], + last_nickname=row["last_nickname"] if "last_nickname" in row.keys() else None, + last_x=row["last_x"] if "last_x" in row.keys() else None, + last_y=row["last_y"] if "last_y" in row.keys() else None, ) @staticmethod diff --git a/server/app/client.py b/server/app/client.py index 8f3f897..c522f42 100644 --- a/server/app/client.py +++ b/server/app/client.py @@ -19,6 +19,8 @@ class ClientConnection: role: str = "user" session_token: str | None = None nickname: str = "user..." + saved_x: int | None = None + saved_y: int | None = None x: int = 20 y: int = 20 last_position_update_ms: int = 0 diff --git a/server/app/server.py b/server/app/server.py index 9273873..1f6c9e4 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -88,6 +88,7 @@ PIANO_RECORDING_MAX_MS = 30_000 PIANO_RECORDING_MAX_EVENTS = 4096 MOVEMENT_TICK_MS = 200 MOVEMENT_MAX_STEPS_PER_TICK = 1 +POSITION_PERSIST_DEBOUNCE_MS = 5_000 class SignalingServer: @@ -141,6 +142,7 @@ class SignalingServer: self.state_save_max_delay_ms = max(self.state_save_debounce_ms, int(state_save_max_delay_ms)) self._pending_state_save_handle: asyncio.TimerHandle | None = None self._pending_state_save_started_at: float | None = None + self._last_position_persist_ms_by_user: dict[str, int] = {} @staticmethod def _resolve_server_version() -> str: @@ -177,6 +179,19 @@ class SignalingServer: return nickname.casefold() + def _persist_client_position(self, client: ClientConnection, *, force: bool = False) -> None: + """Persist one authenticated client's last known position with debounce.""" + + if not client.user_id: + return + now_ms = self.item_service.now_ms() + if not force: + last_saved_ms = self._last_position_persist_ms_by_user.get(client.user_id, 0) + if now_ms - last_saved_ms < POSITION_PERSIST_DEBOUNCE_MS: + return + self.auth_service.set_last_position(client.user_id, client.x, client.y) + self._last_position_persist_ms_by_user[client.user_id] = now_ms + def _auth_policy(self) -> dict[str, int]: """Return server-auth policy limits advertised to clients.""" @@ -770,6 +785,9 @@ class SignalingServer: if websocket in self.clients: disconnected = self.clients.pop(websocket) self.active_piano_keys_by_client.pop(disconnected.id, None) + self._persist_client_position(disconnected, force=True) + if disconnected.user_id: + self._last_position_persist_ms_by_user.pop(disconnected.user_id, None) for item_id, session in list(self.piano_recording_state_by_item.items()): if session.get("ownerClientId") != disconnected.id: continue @@ -829,8 +847,14 @@ class SignalingServer: if client.websocket in self.clients: return - client.x = random.randrange(self.grid_size) - client.y = random.randrange(self.grid_size) + saved_x = getattr(client, "saved_x", None) + saved_y = getattr(client, "saved_y", None) + if isinstance(saved_x, int) and isinstance(saved_y, int) and self._is_in_bounds(saved_x, saved_y): + client.x = saved_x + client.y = saved_y + else: + client.x = random.randrange(self.grid_size) + client.y = random.randrange(self.grid_size) now_ms = self.item_service.now_ms() client.last_position_update_ms = now_ms client.movement_window_index = self._movement_window_index(now_ms) @@ -910,6 +934,8 @@ class SignalingServer: client.role = session.user.role client.session_token = session.token client.nickname = session.user.last_nickname or client.nickname + client.saved_x = session.user.last_x + client.saved_y = session.user.last_y await self._send( client.websocket, AuthResultPacket( @@ -1036,6 +1062,7 @@ class SignalingServer: client.x = packet.x client.y = packet.y client.last_position_update_ms = now_ms + self._persist_client_position(client) await self._send( client.websocket, BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), @@ -1070,6 +1097,7 @@ class SignalingServer: client.x = packet.x client.y = packet.y client.last_position_update_ms = self.item_service.now_ms() + self._persist_client_position(client, force=True) await self._send( client.websocket, BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),