// Fork-local entry policy: saved callsigns/entered flags are hints, never proof of a role binding. import { net, identity } from '../net.js'; import { store } from '../store.js'; import { BindingError, createBindingApi, ensureBindingSession, preferredRole } from './api.js'; export function selectedBoundRole(state) { if (!state?.bound) return null; let selected = ''; try { selected = sessionStorage.getItem('sp.binding.role.' + state.playerId) || ''; } catch { /* optional preference */ } return preferredRole(state.roles, state.selectedRoleKey || selected); } /** Losing the authenticated game transport must never leave an anonymous socket inside the game shell. */ export function acceptAccountWelcome(message) { if (typeof message.playerId === 'string' && message.playerId.startsWith('a_')) return true; identity.setEntered(false); store.patch('session', { entered: false }); return false; } /** A title lookup restores the login only. Changing roles releases an old, roomless game transport. */ export async function syncAccountSession(state) { const current = store.get().me.playerId; if (current?.startsWith('a_') && current !== state?.gamePlayerId) await resetAccountSession(); } export async function resetAccountSession() { identity.clearToken(); identity.setEntered(false); store.patch('session', { entered: false }); net.reconnectNow('Doctor'); await ensureBindingSession({ net, identity, name: 'Doctor' }); } export async function accountRecovery({ signal } = {}) { await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' }); const state = await createBindingApi({ token: () => identity.getToken(), signal })('recovery'); if (!state.ok) throw new BindingError('response'); return state.recovery; } /** Explicit entry/recovery: ask for the selected role's token, then verify the WebSocket actually attached it. */ export async function enterBoundAccount(normalizeName, { recoveryId, automatic = false } = {}) { identity.setEntered(false); store.patch('session', { entered: false }); await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' }); const api = createBindingApi({ token: () => identity.getToken() }); const state = await api('status'); const name = normalizeName(selectedBoundRole(state)?.nickName || ''); if (!name || !state.gamePlayerId) throw new BindingError('session'); if (automatic) { // A copied/stale local token must not silently take the role back from the device that now owns it. if (net.playerId !== state.gamePlayerId) return false; } else { const entry = await api(recoveryId ? 'resume' : 'enter', recoveryId ? { id: recoveryId } : {}); if (!entry.ok || entry.gamePlayerId !== state.gamePlayerId || typeof entry.token !== 'string') throw new BindingError('response'); net.close(); if (!identity.stageToken(entry.token)) throw new BindingError('response'); try { net.reconnectNow(name); await ensureBindingSession({ net, identity, name }); if (net.playerId !== state.gamePlayerId) throw new BindingError('session'); } catch (error) { net.close(); identity.clearToken(); throw error; } } identity.saveName(name); store.patch('me', { name }); net.setName(name); identity.setEntered(true); store.patch('session', { entered: true }); return true; } /** Boot/Start keep the title visible when the server cannot verify the selected role. */ export async function restoreBoundEntry(normalizeName, options) { try { return await enterBoundAccount(normalizeName, options); } catch { return false; } }