Move nickname/position state server-side per account

This commit is contained in:
Jage9
2026-02-24 23:37:10 -05:00
parent e8b8cac27d
commit 45db5762a1
11 changed files with 153 additions and 80 deletions

View File

@@ -19,7 +19,6 @@
<label for="authPassword">Password</label> <label for="authPassword">Password</label>
<input id="authPassword" type="password" maxlength="64" autocomplete="current-password" /> <input id="authPassword" type="password" maxlength="64" autocomplete="current-password" />
</div> </div>
<button id="showRegisterButton" type="button">Register</button>
</section> </section>
<section id="registerView" class="auth-panel hidden"> <section id="registerView" class="auth-panel hidden">
<h2>Register</h2> <h2>Register</h2>
@@ -43,11 +42,12 @@
<button id="showLoginButton" type="button">Login</button> <button id="showLoginButton" type="button">Login</button>
</section> </section>
<section id="authSessionView" class="auth-panel hidden"> <section id="authSessionView" class="auth-panel hidden">
<h2>Session</h2> <h2>Logged In</h2>
<p id="authSessionText" class="auth-hint"></p> <p id="authSessionText" class="auth-hint"></p>
</section> </section>
<div class="controls" id="button-container"> <div class="controls" id="button-container">
<button id="connectButton">Connect</button> <button id="connectButton">Connect</button>
<button id="showRegisterButton" type="button">Register</button>
<button id="logoutButton" class="hidden">Log out</button> <button id="logoutButton" class="hidden">Log out</button>
<button id="settingsButton">Audio setup</button> <button id="settingsButton">Audio setup</button>
<button id="disconnectButton" class="hidden">Disconnect</button> <button id="disconnectButton" class="hidden">Disconnect</button>

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -1130,39 +1130,6 @@ function formatCoordinate(value: number): string {
return value.toFixed(2).replace(/\.?0+$/, ''); 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. */ /** Picks one random footstep sample URL. */
function randomFootstepUrl(): string { function randomFootstepUrl(): string {
return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)]; 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 }); signaling.send({ type: 'teleport_complete', x: activeTeleport.targetX, y: activeTeleport.targetY });
activeTeleport = null; activeTeleport = null;
stopTeleportLoopAudio(); stopTeleportLoopAudio();
persistPlayerPosition();
void refreshAudioSubscriptions(true); void refreshAudioSubscriptions(true);
void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN);
updateStatus(completionStatus); updateStatus(completionStatus);
@@ -1305,7 +1271,6 @@ function handleMovement(): void {
state.player.x = nextX; state.player.x = nextX;
state.player.y = nextY; state.player.y = nextY;
lastWallCollisionDirection = null; lastWallCollisionDirection = null;
persistPlayerPosition();
state.player.lastMoveTime = now; state.player.lastMoveTime = now;
void refreshAudioSubscriptions(true); void refreshAudioSubscriptions(true);
void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, movementTickMs); void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, movementTickMs);
@@ -1557,7 +1522,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
pushChatMessage(message); pushChatMessage(message);
}, },
updateConnectAvailability, updateConnectAvailability,
settingsSaveNickname: (value) => settings.saveNickname(value),
mediaIsConnecting: () => mediaSession.isConnecting(), mediaIsConnecting: () => mediaSession.isConnecting(),
mediaSetConnecting: (value) => mediaSession.setConnecting(value), mediaSetConnecting: (value) => mediaSession.setConnecting(value),
mediaCheckMicPermission: () => checkMicPermission(), mediaCheckMicPermission: () => checkMicPermission(),
@@ -1570,7 +1534,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
signalingSendAuth: () => sendAuthRequest(), signalingSendAuth: () => sendAuthRequest(),
signalingDisconnect: () => signaling.disconnect(), signalingDisconnect: () => signaling.disconnect(),
onMessage: (message) => onSignalingMessage(message as IncomingMessage), onMessage: (message) => onSignalingMessage(message as IncomingMessage),
persistPlayerPosition,
peerManagerCleanupAll: () => peerManager.cleanupAll(), peerManagerCleanupAll: () => peerManager.cleanupAll(),
radioCleanupAll: () => radioRuntime.cleanupAll(), radioCleanupAll: () => radioRuntime.cleanupAll(),
emitCleanupAll: () => itemEmitRuntime.cleanupAll(), emitCleanupAll: () => itemEmitRuntime.cleanupAll(),
@@ -1615,7 +1578,6 @@ const onAppMessage = createOnMessageHandler({
mediaSession.setConnecting(value); mediaSession.setConnecting(value);
updateConnectAvailability(); updateConnectAvailability();
}, },
getPersistedPlayerPosition,
rendererSetGridSize: (size) => renderer.setGridSize(size), rendererSetGridSize: (size) => renderer.setGridSize(size),
applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters<typeof applyServerItemUiDefinitions>[0]), applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters<typeof applyServerItemUiDefinitions>[0]),
state, state,
@@ -2670,10 +2632,6 @@ function setupUiHandlers(): void {
}, },
updateDeviceSummary, updateDeviceSummary,
setOutputDevice: (id) => peerManager.setOutputDevice(id), setOutputDevice: (id) => peerManager.setOutputDevice(id),
persistOnUnload: () => {
if (!state.running) return;
persistPlayerPosition();
},
}); });
dom.showRegisterButton.addEventListener('click', () => { dom.showRegisterButton.addEventListener('click', () => {
setAuthMode('register'); setAuthMode('register');

View File

@@ -9,7 +9,6 @@ type MessageHandlerDeps = {
setWorldGridSize: (size: number) => void; setWorldGridSize: (size: number) => void;
setMovementTickMs: (value: number) => void; setMovementTickMs: (value: number) => void;
setConnecting: (value: boolean) => void; setConnecting: (value: boolean) => void;
getPersistedPlayerPosition: () => { x: number; y: number } | null;
rendererSetGridSize: (size: number) => void; rendererSetGridSize: (size: number) => void;
applyServerItemUiDefinitions: (defs: unknown) => boolean; applyServerItemUiDefinitions: (defs: unknown) => boolean;
state: { state: {
@@ -105,11 +104,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.state.player.id = message.id; deps.state.player.id = message.id;
deps.state.running = true; deps.state.running = true;
deps.setConnecting(false); deps.setConnecting(false);
const persistedPosition = deps.getPersistedPlayerPosition(); deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.x));
const targetX = persistedPosition?.x ?? message.player.x; deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.y));
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.dom.connectButton.classList.add('hidden'); deps.dom.connectButton.classList.add('hidden');
deps.dom.disconnectButton.classList.remove('hidden'); deps.dom.disconnectButton.classList.remove('hidden');
deps.dom.focusGridButton.classList.remove('hidden'); deps.dom.focusGridButton.classList.remove('hidden');

View File

@@ -17,7 +17,6 @@ export type ConnectFlowDeps = {
sanitizeName: (value: string) => string; sanitizeName: (value: string) => string;
updateStatus: (message: string) => void; updateStatus: (message: string) => void;
updateConnectAvailability: () => void; updateConnectAvailability: () => void;
settingsSaveNickname: (value: string) => void;
mediaIsConnecting: () => boolean; mediaIsConnecting: () => boolean;
mediaSetConnecting: (value: boolean) => void; mediaSetConnecting: (value: boolean) => void;
mediaCheckMicPermission: () => Promise<boolean>; mediaCheckMicPermission: () => Promise<boolean>;
@@ -30,7 +29,6 @@ export type ConnectFlowDeps = {
signalingSendAuth: () => void; signalingSendAuth: () => void;
signalingDisconnect: () => void; signalingDisconnect: () => void;
onMessage: (message: unknown) => Promise<void>; onMessage: (message: unknown) => Promise<void>;
persistPlayerPosition: () => void;
peerManagerCleanupAll: () => void; peerManagerCleanupAll: () => void;
radioCleanupAll: () => void; radioCleanupAll: () => void;
emitCleanupAll: () => void; emitCleanupAll: () => void;
@@ -46,9 +44,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
} }
const nickname = deps.sanitizeName(deps.state.player.nickname); const nickname = deps.sanitizeName(deps.state.player.nickname);
deps.state.player.nickname = nickname || deps.state.player.nickname; deps.state.player.nickname = nickname || deps.state.player.nickname;
if (nickname) {
deps.settingsSaveNickname(nickname);
}
deps.mediaSetConnecting(true); deps.mediaSetConnecting(true);
deps.updateConnectAvailability(); deps.updateConnectAvailability();
@@ -105,9 +100,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
*/ */
export function runDisconnectFlow(deps: ConnectFlowDeps): void { export function runDisconnectFlow(deps: ConnectFlowDeps): void {
const wasRunning = deps.state.running; const wasRunning = deps.state.running;
if (deps.state.running) {
deps.persistPlayerPosition();
}
deps.signalingDisconnect(); deps.signalingDisconnect();
deps.mediaStopLocalMedia(); deps.mediaStopLocalMedia();

View File

@@ -30,16 +30,12 @@ type UiBindingsDeps = {
setPreferredOutput: (id: string, name: string) => void; setPreferredOutput: (id: string, name: string) => void;
updateDeviceSummary: () => void; updateDeviceSummary: () => void;
setOutputDevice: (id: string) => Promise<void>; setOutputDevice: (id: string) => Promise<void>;
persistOnUnload: () => void;
}; };
/** /**
* Attaches UI listeners (connect/settings/device changes) and focus traps. * Attaches UI listeners (connect/settings/device changes) and focus traps.
*/ */
export function setupUiHandlers(deps: UiBindingsDeps): void { export function setupUiHandlers(deps: UiBindingsDeps): void {
window.addEventListener('pagehide', deps.persistOnUnload);
window.addEventListener('beforeunload', deps.persistOnUnload);
deps.dom.connectButton.addEventListener('click', () => { deps.dom.connectButton.addEventListener('click', () => {
void deps.connect(); void deps.connect();
}); });

View File

@@ -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 all action validation and normalization.
- Server is authoritative for movement acceptance (bounds + rate/delta checks). - 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. - Client validates incoming packet shapes and applies runtime behavior.
- Sound/media field normalization uses shared server policy helpers: - Sound/media field normalization uses shared server policy helpers:
- `none/off` normalize to empty values - `none/off` normalize to empty values

View File

@@ -14,7 +14,7 @@
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity - 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 - records `welcome.serverInfo` (`instanceId`, `version`) for restart detection
- if `welcome.serverInfo.version` differs from running client version, auto-reloads the page - if `welcome.serverInfo.version` differs from running client version, auto-reloads the page
- applies `welcome.uiDefinitions` for item menus/properties/options - applies `welcome.uiDefinitions` for item menus/properties/options

View File

@@ -31,6 +31,8 @@ class AuthUser:
status: str status: str
email: str | None email: str | None
last_nickname: str | None last_nickname: str | None
last_x: int | None
last_y: int | None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -115,8 +117,8 @@ class AuthService:
self._conn.execute( self._conn.execute(
""" """
INSERT INTO users ( INSERT INTO users (
username, password_hash, email, role, status, last_nickname, created_at_ms, updated_at_ms, last_login_at_ms username, password_hash, email, role, status, created_at_ms, updated_at_ms, last_login_at_ms
) VALUES (?, ?, ?, ?, 'active', NULL, ?, ?, ?) ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
""", """,
(normalized_username, password_hash, normalized_email, role, now_ms, now_ms, now_ms), (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) user = self._get_user_by_username(normalized_username)
if user is None: if user is None:
raise AuthError("Failed to load newly created user.") 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) return self._create_session(user)
def login(self, username: str, password: str) -> AuthSession: def login(self, username: str, password: str) -> AuthSession:
@@ -139,9 +159,19 @@ class AuthService:
normalized_username = self._normalize_username(username) normalized_username = self._normalize_username(username)
user_row = self._conn.execute( user_row = self._conn.execute(
""" """
SELECT id, username, password_hash, email, role, status, last_nickname SELECT
FROM users u.id,
WHERE username = ? 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,), (normalized_username,),
).fetchone() ).fetchone()
@@ -161,6 +191,8 @@ class AuthService:
status=user.status, status=user.status,
email=user.email, email=user.email,
last_nickname=user.username, last_nickname=user.username,
last_x=user.last_x,
last_y=user.last_y,
) )
self._conn.execute( self._conn.execute(
"UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?", "UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?",
@@ -179,9 +211,10 @@ class AuthService:
row = self._conn.execute( row = self._conn.execute(
""" """
SELECT s.id AS session_id, s.user_id, s.expires_at_ms, s.revoked_at_ms, 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 FROM sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
LEFT JOIN user_state us ON us.user_id = u.id
WHERE s.token_hash = ? WHERE s.token_hash = ?
""", """,
(token_hash,), (token_hash,),
@@ -210,6 +243,8 @@ class AuthService:
status=row["status"], status=row["status"],
email=row["email"], email=row["email"],
last_nickname=row["last_nickname"], 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: if not user.last_nickname:
self.set_last_nickname(user.id, user.username) self.set_last_nickname(user.id, user.username)
@@ -220,6 +255,8 @@ class AuthService:
status=user.status, status=user.status,
email=user.email, email=user.email,
last_nickname=user.username, 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) return AuthSession(session_id=row["session_id"], token=cleaned, user=user)
@@ -242,11 +279,47 @@ class AuthService:
cleaned = nickname.strip() cleaned = nickname.strip()
if not cleaned: if not cleaned:
return return
try:
user_id_value = int(user_id)
except (TypeError, ValueError):
return
try:
self._conn.execute( self._conn.execute(
"UPDATE users SET last_nickname = ?, updated_at_ms = ? WHERE id = ?", """
(cleaned, self.now_ms(), user_id), 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() 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 @staticmethod
def now_ms() -> int: def now_ms() -> int:
@@ -267,7 +340,6 @@ class AuthService:
email TEXT UNIQUE, email TEXT UNIQUE,
role TEXT NOT NULL CHECK(role IN ('user', 'admin')) DEFAULT 'user', role TEXT NOT NULL CHECK(role IN ('user', 'admin')) DEFAULT 'user',
status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active', status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active',
last_nickname TEXT,
created_at_ms INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL,
last_login_at_ms INTEGER 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_username ON users(username)")
self._conn.execute( self._conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL" "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_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_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_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() self._conn.commit()
def _create_session(self, user: AuthUser) -> AuthSession: def _create_session(self, user: AuthUser) -> AuthSession:
@@ -321,7 +406,20 @@ class AuthService:
"""Fetch one user by normalized username.""" """Fetch one user by normalized username."""
row = self._conn.execute( 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,), (username,),
).fetchone() ).fetchone()
if row is None: if row is None:
@@ -338,7 +436,9 @@ class AuthService:
role=row["role"], role=row["role"],
status=row["status"], status=row["status"],
email=row["email"], 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 @staticmethod

View File

@@ -19,6 +19,8 @@ class ClientConnection:
role: str = "user" role: str = "user"
session_token: str | None = None session_token: str | None = None
nickname: str = "user..." nickname: str = "user..."
saved_x: int | None = None
saved_y: int | None = None
x: int = 20 x: int = 20
y: int = 20 y: int = 20
last_position_update_ms: int = 0 last_position_update_ms: int = 0

View File

@@ -88,6 +88,7 @@ PIANO_RECORDING_MAX_MS = 30_000
PIANO_RECORDING_MAX_EVENTS = 4096 PIANO_RECORDING_MAX_EVENTS = 4096
MOVEMENT_TICK_MS = 200 MOVEMENT_TICK_MS = 200
MOVEMENT_MAX_STEPS_PER_TICK = 1 MOVEMENT_MAX_STEPS_PER_TICK = 1
POSITION_PERSIST_DEBOUNCE_MS = 5_000
class SignalingServer: 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.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_handle: asyncio.TimerHandle | None = None
self._pending_state_save_started_at: float | None = None self._pending_state_save_started_at: float | None = None
self._last_position_persist_ms_by_user: dict[str, int] = {}
@staticmethod @staticmethod
def _resolve_server_version() -> str: def _resolve_server_version() -> str:
@@ -177,6 +179,19 @@ class SignalingServer:
return nickname.casefold() 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]: def _auth_policy(self) -> dict[str, int]:
"""Return server-auth policy limits advertised to clients.""" """Return server-auth policy limits advertised to clients."""
@@ -770,6 +785,9 @@ class SignalingServer:
if websocket in self.clients: if websocket in self.clients:
disconnected = self.clients.pop(websocket) disconnected = self.clients.pop(websocket)
self.active_piano_keys_by_client.pop(disconnected.id, None) 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()): for item_id, session in list(self.piano_recording_state_by_item.items()):
if session.get("ownerClientId") != disconnected.id: if session.get("ownerClientId") != disconnected.id:
continue continue
@@ -829,6 +847,12 @@ class SignalingServer:
if client.websocket in self.clients: if client.websocket in self.clients:
return return
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.x = random.randrange(self.grid_size)
client.y = random.randrange(self.grid_size) client.y = random.randrange(self.grid_size)
now_ms = self.item_service.now_ms() now_ms = self.item_service.now_ms()
@@ -910,6 +934,8 @@ class SignalingServer:
client.role = session.user.role client.role = session.user.role
client.session_token = session.token client.session_token = session.token
client.nickname = session.user.last_nickname or client.nickname 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( await self._send(
client.websocket, client.websocket,
AuthResultPacket( AuthResultPacket(
@@ -1036,6 +1062,7 @@ class SignalingServer:
client.x = packet.x client.x = packet.x
client.y = packet.y client.y = packet.y
client.last_position_update_ms = now_ms client.last_position_update_ms = now_ms
self._persist_client_position(client)
await self._send( await self._send(
client.websocket, client.websocket,
BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),
@@ -1070,6 +1097,7 @@ class SignalingServer:
client.x = packet.x client.x = packet.x
client.y = packet.y client.y = packet.y
client.last_position_update_ms = self.item_service.now_ms() client.last_position_update_ms = self.item_service.now_ms()
self._persist_client_position(client, force=True)
await self._send( await self._send(
client.websocket, client.websocket,
BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),