From a155b3fbcc1383e2fb8f24a5f9bcab5840c3a48b Mon Sep 17 00:00:00 2001 From: "protostatis.dev" Date: Mon, 27 Jul 2026 21:01:43 -0500 Subject: [PATCH 1/2] feat(game): persist PvP guest identity across reconnects Server-side: - New SessionStore module: in-memory opaque 256-bit bearer token store (bounded to 10K entries, FIFO eviction). Used by index.js to restore guest identity + credit balance across page reloads. - identifyGuest now accepts optional sessionToken; short-circuits on already-identified sockets to prevent token-to-player corruption. - Removes identification-time ensurePlayable (V2WagerRoom handles pre-match top-ups with correct ante threshold). Client-side: - New storage.js: localStorage wrapper for bc2_pvp_session key with field-level validation (rejects empty/non-string fields). - v2PvpTransport: reads persisted sessionToken before connect, sends in IDENTIFY_GUEST handshake; saves IDENTITY_OK response on success. - Session, userId, and name cached in localStorage for immediate UI. UI: - Lobby disclaimer updated (identity persists across reloads, balance resets on restart). - New Identity button in lobby (clears session, closes transport, triggers fresh connection; guarded by window.confirm and disabled during active challenges). - CSS for .btn-new-identity. Tests: - 28 storage tests: save/load/clear round-trips, saveSession validation (rejects null/undefined/empty/non-string/incomplete), corruption resilience, localStorage-unavailable handling. - 12 SessionStore tests: token format (43-char base64url, 32 bytes), getOrCreate semantics, identity restoration, FIFO eviction. Includes prior game fixes from the branch: - rematch revision tracking + credit display - mobile audio unlock via Web Audio API --- .../game/server/SessionStore.js | 67 ++++++ .../game/server/SessionStore.test.js | 202 +++++++++++++++++ dashboard-frontend/game/server/V2WagerRoom.js | 9 +- .../game/server/V2WagerRoom.test.js | 94 ++++++++ dashboard-frontend/game/server/index.js | 59 ++++- dashboard-frontend/game/src/App.css | 17 ++ dashboard-frontend/game/src/App.jsx | 22 +- .../game/src/components/V2PvpLobby.jsx | 17 +- .../game/src/components/V2PvpScreen.jsx | 28 ++- .../game/src/components/V2PvpScreen.test.jsx | 16 ++ dashboard-frontend/game/src/game/sound.js | 211 +++++++++++++++++- .../game/src/game/sound.test.js | 150 +++++++++++++ .../game/src/game/usePvpWagerController.js | 23 +- .../game/src/transports/v2PvpTransport.js | 27 ++- dashboard-frontend/game/src/utils/storage.js | 73 ++++++ .../game/src/utils/storage.test.js | 209 +++++++++++++++++ 16 files changed, 1187 insertions(+), 37 deletions(-) create mode 100644 dashboard-frontend/game/server/SessionStore.js create mode 100644 dashboard-frontend/game/server/SessionStore.test.js create mode 100644 dashboard-frontend/game/src/components/V2PvpScreen.test.jsx create mode 100644 dashboard-frontend/game/src/game/sound.test.js create mode 100644 dashboard-frontend/game/src/utils/storage.js create mode 100644 dashboard-frontend/game/src/utils/storage.test.js diff --git a/dashboard-frontend/game/server/SessionStore.js b/dashboard-frontend/game/server/SessionStore.js new file mode 100644 index 0000000..f8ffd60 --- /dev/null +++ b/dashboard-frontend/game/server/SessionStore.js @@ -0,0 +1,67 @@ +/** + * SessionStore.js — opaque bearer-token session store for PvP guest identity + * persistence. + * + * Tokens are 32-byte base64url (256-bit). Stored in client localStorage and + * sent with IDENTIFY_GUEST on reconnect to restore the same guest identity. + * Bounded to MAX_SESSIONS — evicts oldest entries when full. + * + * In-memory only (Phase 1). All sessions are lost on server restart. + */ + +import { randomBytes } from 'crypto'; + +const SESSION_TOKEN_BYTES = 32; + +function generateSessionToken() { + return randomBytes(SESSION_TOKEN_BYTES).toString('base64url'); +} + +export class SessionStore { + /** + * @param {object} [opts] + * @param {number} [opts.maxSessions=10000] + * @param {() => { id: string, name: string }} [opts.createPlayer] + */ + constructor(opts = {}) { + this._maxSessions = opts.maxSessions || 10_000; + this._createPlayer = opts.createPlayer || (() => ({ + id: `guest_${randomBytes(12).toString('base64url')}`, + name: `Player_${randomBytes(4).toString('hex')}`, + })); + /** @type {Map} */ + this._sessions = new Map(); + } + + /** Current number of stored sessions. */ + get size() { + return this._sessions.size; + } + + /** Look up an existing session or create a new one. + * Returns { token: string, userId: string, name: string }. */ + getOrCreate(sessionToken) { + if (sessionToken && this._sessions.has(sessionToken)) { + return this._sessions.get(sessionToken); + } + + if (this._sessions.size >= this._maxSessions) { + // Evict the oldest session (Map insertion order = oldest first) + const oldest = this._sessions.keys().next().value; + if (oldest) this._sessions.delete(oldest); + } + + const token = generateSessionToken(); + const player = this._createPlayer(); + const entry = { token, userId: player.id, name: player.name }; + this._sessions.set(token, entry); + return entry; + } + + /** Check whether a token exists (for testing). */ + has(token) { + return this._sessions.has(token); + } +} + +export default SessionStore; diff --git a/dashboard-frontend/game/server/SessionStore.test.js b/dashboard-frontend/game/server/SessionStore.test.js new file mode 100644 index 0000000..c21098a --- /dev/null +++ b/dashboard-frontend/game/server/SessionStore.test.js @@ -0,0 +1,202 @@ +/** + * SessionStore.test.js — unit tests for opaque session token store. + * + * Run: node --test server/SessionStore.test.js + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SessionStore } from './SessionStore.js'; + +/* ------------------------------------------------------------------ */ +/* Token format */ +/* ------------------------------------------------------------------ */ + +const TOKEN_REGEX = /^[A-Za-z0-9\-_]{43}$/; // 32 bytes → 43 base64url chars (no padding) + +function decodeBase64url(s) { + return Buffer.from(s, 'base64url'); +} + +describe('session token format', () => { + test('generated tokens are 43-character base64url strings', () => { + const store = new SessionStore(); + for (let i = 0; i < 20; i++) { + const { token } = store.getOrCreate(); + assert.match(token, TOKEN_REGEX, `token ${token} should match base64url pattern`); + } + }); + + test('generated tokens decode to exactly 32 bytes', () => { + const store = new SessionStore(); + for (let i = 0; i < 20; i++) { + const { token } = store.getOrCreate(); + const decoded = decodeBase64url(token); + assert.equal(decoded.length, 32, `token ${token} should decode to 32 bytes, got ${decoded.length}`); + } + }); + + test('successive tokens are unique', () => { + const store = new SessionStore(); + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + const { token } = store.getOrCreate(); + tokens.add(token); + } + assert.equal(tokens.size, 100); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +let counter = 0; +function createPlayer() { + counter++; + return { id: `test_user_${counter}`, name: `TestPlayer${counter}` }; +} + +function freshStore(maxSessions = 100) { + counter = 0; + return new SessionStore({ createPlayer, maxSessions }); +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe('SessionStore.getOrCreate', () => { + test('creates a new session when no token is given', () => { + const store = freshStore(); + const s1 = store.getOrCreate(); + + assert.ok(s1.token, 'should generate a token'); + assert.equal(typeof s1.token, 'string'); + assert.ok(s1.token.length > 0); + assert.equal(s1.userId, 'test_user_1'); + assert.equal(s1.name, 'TestPlayer1'); + assert.equal(store.size, 1); + }); + + test('returns the same session for a known token', () => { + const store = freshStore(); + const s1 = store.getOrCreate(); + + const s2 = store.getOrCreate(s1.token); + assert.equal(s2.token, s1.token, 'token should match'); + assert.equal(s2.userId, s1.userId, 'userId should match'); + assert.equal(s2.name, s1.name, 'name should match'); + assert.equal(store.size, 1, 'should not create a new entry'); + }); + + test('creates a fresh session for an unknown token', () => { + const store = freshStore(); + const s1 = store.getOrCreate(); + const s2 = store.getOrCreate('nonexistent-token'); + + assert.notEqual(s2.userId, s1.userId, 'should be different user'); + assert.notEqual(s2.name, s1.name, 'should be different name'); + assert.equal(store.size, 2); + }); + + test('handles empty string token as unknown', () => { + const store = freshStore(); + const s1 = store.getOrCreate(''); + + const s2 = store.getOrCreate(''); + assert.notEqual(s2.token, s1.token, 'empty string should create fresh each time'); + assert.equal(store.size, 2); + }); + + test('multiple getOrCreate calls without token create unique entries', () => { + const store = freshStore(); + const s1 = store.getOrCreate(); + const s2 = store.getOrCreate(); + const s3 = store.getOrCreate(); + + assert.equal(store.size, 3); + assert.notEqual(s1.userId, s2.userId); + assert.notEqual(s2.userId, s3.userId); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Eviction */ +/* ------------------------------------------------------------------ */ + +describe('SessionStore eviction', () => { + test('evicts oldest session when at capacity', () => { + const store = freshStore(3); // max 3 sessions + + const s1 = store.getOrCreate(); + const s2 = store.getOrCreate(); + const s3 = store.getOrCreate(); + assert.equal(store.size, 3); + + // Fourth creation should evict s1 + const s4 = store.getOrCreate(); + assert.equal(store.size, 3); + assert.equal(store.has(s1.token), false, 'oldest should be evicted'); + assert.equal(store.has(s2.token), true); + assert.equal(store.has(s3.token), true); + assert.equal(store.has(s4.token), true); + }); + + test('evicted token is no longer restorable', () => { + const store = freshStore(2); + + const s1 = store.getOrCreate(); + const s2 = store.getOrCreate(); + store.getOrCreate(); // evicts s1 + + const restored = store.getOrCreate(s1.token); + assert.notEqual(restored.userId, s1.userId, 'evicted token should not restore old identity'); + assert.equal(store.size, 2); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Identity restoration (same token → same identity) */ +/* ------------------------------------------------------------------ */ + +describe('SessionStore identity restoration', () => { + test('same token always returns same userId regardless of order', () => { + const store = freshStore(); + const s1 = store.getOrCreate(); + + // Interleave other sessions + store.getOrCreate(); + store.getOrCreate(); + + const restored = store.getOrCreate(s1.token); + assert.equal(restored.userId, s1.userId); + assert.equal(restored.name, s1.name); + }); + + test('survives until eviction threshold', () => { + const store = freshStore(5); + const ids = new Set(); + + // Fill 3 sessions + for (let i = 0; i < 3; i++) { + const s = store.getOrCreate(); + ids.add(s.userId); + } + + // Restore each one + for (const id of ids) { + // Find the token by scanning — in practice the client sends the token + let found = false; + for (const [token, entry] of store._sessions) { + if (entry.userId === id) { + const restored = store.getOrCreate(token); + assert.equal(restored.userId, id); + found = true; + break; + } + } + assert.ok(found, `user ${id} should be restorable`); + } + }); +}); diff --git a/dashboard-frontend/game/server/V2WagerRoom.js b/dashboard-frontend/game/server/V2WagerRoom.js index e4b86c9..7338834 100644 --- a/dashboard-frontend/game/server/V2WagerRoom.js +++ b/dashboard-frontend/game/server/V2WagerRoom.js @@ -89,6 +89,12 @@ export class V2WagerRoom { return false; } + /** Total demo-credit balance, including credits reserved in this match. */ + _totalCredits(playerId) { + return this.ledger.getBalance(playerId) + + this.ledger.getEscrowedBalance(playerId, this._escrowMatchId); + } + /* ------------------------------------------------------------------ */ /* Dead-board fix: ensure playable after cascades */ /* ------------------------------------------------------------------ */ @@ -434,10 +440,11 @@ export class V2WagerRoom { id: s.id, name: s.name, connected: this._isConnected(s.id), + totalCredits: this._totalCredits(s.id), })), self: { id: playerId, - credits: this.ledger.getBalance(playerId) + this.ledger.getEscrowedBalance(playerId, this._escrowMatchId), + credits: this._totalCredits(playerId), seat: seatIdx !== -1 ? seatIdx : null, }, activeSeatId: null, diff --git a/dashboard-frontend/game/server/V2WagerRoom.test.js b/dashboard-frontend/game/server/V2WagerRoom.test.js index 924bee0..98d8e21 100644 --- a/dashboard-frontend/game/server/V2WagerRoom.test.js +++ b/dashboard-frontend/game/server/V2WagerRoom.test.js @@ -351,7 +351,10 @@ describe('V2WagerRoom — snapshot + broadcast', () => { assert.ok(snap.wager); assert.ok(Array.isArray(snap.players)); assert.equal(snap.players.length, 2); + assert.equal(snap.players[0].totalCredits, 100); + assert.equal(snap.players[1].totalCredits, 100); assert.equal(snap.self.id, 'alice'); + assert.equal(snap.self.credits, 100); assert.equal(snap.self.seat, 0); assert.ok(snap.activeSeatId); }); @@ -669,6 +672,97 @@ describe('V2WagerRoom — rematch consent', () => { }); }); +describe('V2WagerRoom — rematch credit display', () => { + test('tops up only the broke wallet while preserving equal table stacks', () => { + const ledger = new DemoCreditLedger(); + ledger.reserve('alice', 100, 'previous'); + ledger.reserve('bob', 100, 'previous'); + ledger.settle('previous', { alice: 200, bob: 0 }); + + const room = newRoom(ledger); + room.join({ id: 'alice', name: 'Alice' }); + room.join({ id: 'bob', name: 'Bob' }); + + const snap = room.snapshotFor('alice'); + const totals = Object.fromEntries( + snap.players.map((player) => [player.id, player.totalCredits]) + ); + + assert.deepEqual(totals, { alice: 200, bob: 100 }); + assert.equal(snap.self.credits, 200); + assert.equal(room.buyIn, 100); + assert.deepEqual( + room.wager.seats.map((seat) => seat.coins), + [90, 90], + 'Both table stacks should remain equal after the ante' + ); + + // Betting changes table/pot allocation, not each player's displayed + // escrow-inclusive total. + for (const seat of room._seats) { + const [i, j] = findValidMove(room); + assert.equal(room.tryMove(seat.id, i, j, room.revision).ok, true); + } + const actor = currentActor(room.wager); + assert.equal(room.tryBet(actor.id, BET_ACTION.RAISE, room.revision).ok, true); + assert.deepEqual( + Object.fromEntries( + room.snapshotFor('alice').players.map((player) => [player.id, player.totalCredits]) + ), + { alice: 200, bob: 100 } + ); + + room.forfeit('bob', 'test forfeit'); + const completed = room.snapshotFor('alice'); + for (const player of completed.players) { + assert.equal(player.totalCredits, ledger.getBalance(player.id)); + } + }); +}); + +describe('V2WagerRoom — rematch revision gap', () => { + test('new rematch room starts with revision 1, below the settled room\'s final revision', () => { + const ledger = new DemoCreditLedger(); + + // Play a single street (move+bet for each player) to build revision history + const oldRoom = newRoom(ledger); + oldRoom.join({ id: 'alice', name: 'Alice' }); + oldRoom.join({ id: 'bob', name: 'Bob' }); + + // Revision is 1 after activation. Play one full street. + const seats = [...oldRoom._seats]; + for (const s of seats) { + const [i, j] = findValidMove(oldRoom); + const r = oldRoom.tryMove(s.id, i, j, oldRoom.revision); + assert.equal(r.ok, true, `move should succeed for ${s.id}`); + } + assert.equal(oldRoom.wager.phase, WAGER_PHASE.BETTING, 'should enter betting'); + for (const s of seats) { + const r = oldRoom.tryBet(s.id, BET_ACTION.CHECK, oldRoom.revision); + assert.equal(r.ok, true, `check should succeed for ${s.id}`); + } + + const oldFinalRevision = oldRoom.revision; + assert.ok(oldFinalRevision >= 5, + `Expected room revision >= 5 after one street, got ${oldFinalRevision}`); + + // Simulate startV2Rematch: close old room (refunds escrow) and create a new one + oldRoom.close(); + + const rematchRoom = new V2WagerRoom({ code: 'REMATCH01', ledger }); + testRooms.add(rematchRoom); + rematchRoom.join({ id: 'alice', name: 'Alice' }); + rematchRoom.join({ id: 'bob', name: 'Bob' }); + + // After auto-activation, revision is 1 (constructor 0 + _tryActivate++) + assert.equal(rematchRoom.lifecycle, 'active'); + assert.ok(rematchRoom.revision < oldFinalRevision, + `New room revision (${rematchRoom.revision}) should be < old room revision (${oldFinalRevision})`); + assert.equal(rematchRoom.revision, 1, + 'New room should start at revision 1 after activation'); + }); +}); + describe('V2WagerRoom — dead-board handling', () => { test('_ensurePlayable returns true after reshuffle on dead board', () => { const room = newRoom(); diff --git a/dashboard-frontend/game/server/index.js b/dashboard-frontend/game/server/index.js index e2392d0..80cca48 100644 --- a/dashboard-frontend/game/server/index.js +++ b/dashboard-frontend/game/server/index.js @@ -25,6 +25,7 @@ import { randomBytes } from 'crypto'; import { DemoCreditLedger } from './DemoCreditLedger.js'; import { MatchLobby } from './MatchLobby.js'; import { V2WagerRoom } from './V2WagerRoom.js'; +import { SessionStore } from './SessionStore.js'; // ---- Configuration ---- @@ -61,6 +62,12 @@ const clients = new Map(); /** @type {Map} IP -> connection count */ const ipCounts = new Map(); +/** In-memory session store for PvP guest identity persistence. + * Resets on server restart (Phase 1). */ +const sessions = new SessionStore({ + createPlayer: () => newGuestPlayer(), +}); + const GUEST_ADJECTIVES = [ 'Amber', 'Brisk', 'Cobalt', 'Copper', 'Daring', 'Ember', 'Fable', 'Golden', 'Harbor', 'Indigo', 'Jolly', 'Kindle', 'Lucky', 'Mellow', 'Nimble', 'Opal', @@ -106,7 +113,7 @@ function newGuestPlayer() { }; } -function identityMessage(player) { +function identityMessage(player, sessionToken) { return { type: 'IDENTITY_OK', self: { @@ -114,6 +121,7 @@ function identityMessage(player) { name: player.name, authType: player.authType, }, + sessionToken, }; } @@ -259,16 +267,41 @@ const matchLobby = new MatchLobby({ // ---- Identity ---- -function identifyGuest(ws) { +/** Identify (or re-identify) a WebSocket client. + * + * New socket: looks up `sessionToken` in the sessions map. If found, + * restores that identity (userId, name) and DemoCreditLedger automatically + * serves the existing credit balance (keyed by userId). If not found, + * creates a fresh session + identity. NEVER calls ensurePlayable here — + * V2WagerRoom._tryActivate handles the pre-match top-up with the correct + * ante threshold. + * + * Already-identified socket: ignores any incoming sessionToken and resends + * the existing identity with the stored token. This prevents a second + * IDENTIFY_GUEST from corrupting the token-to-player mapping. */ +function identifyGuest(ws, sessionToken) { const meta = clients.get(ws); if (!meta) return null; - if (!meta.player) { - meta.player = newGuestPlayer(); - send(ws, identityMessage(meta.player)); - matchLobby.attach(ws, meta.player); - } else { - send(ws, identityMessage(meta.player)); + + // Already identified — resend existing identity with its stored session + // token. Do NOT call sessions.getOrCreate again; a mismatched or empty + // token on a second IDENTIFY_GUEST would create a spurious session entry. + if (meta.player && meta.sessionToken) { + send(ws, identityMessage(meta.player, meta.sessionToken)); + return meta; } + + const session = sessions.getOrCreate(sessionToken); + + meta.player = { + id: session.userId, + name: session.name, + authType: 'guest', + }; + meta.sessionToken = session.token; + + send(ws, identityMessage(meta.player, session.token)); + matchLobby.attach(ws, meta.player); return meta; } @@ -406,6 +439,7 @@ wss.on('connection', (ws, request) => { const meta = { player: null, + sessionToken: null, v2Room: null, ip, tokenWindow: { tokens: BURST_LIMIT, resetAt: Date.now() + WINDOW_MS }, @@ -465,7 +499,12 @@ wss.on('connection', (ws, request) => { }); return; } - identifyGuest(ws); + // Optional session token for identity persistence across page loads. + // Must be a string if provided; bounded to prevent abuse. + const sessionToken = typeof msg.sessionToken === 'string' && msg.sessionToken.length <= 128 + ? msg.sessionToken + : undefined; + identifyGuest(ws, sessionToken); return; } @@ -604,5 +643,5 @@ httpServer.listen(PORT, () => { console.log(`[server] PanicRadar BlockCoined V2 PvP server listening on port ${PORT}`); console.log(`[server] Mode: ${process.env.NODE_ENV || 'development'}`); console.log(`[server] Allowed origins: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(', ') : '(fail-closed)'}`); - console.log(`[server] Demo credits reset on restart. Guest identities are temporary.`); + console.log(`[server] Guest identities persist via session tokens (lost on server restart). Demo credits are in-memory.`); }); diff --git a/dashboard-frontend/game/src/App.css b/dashboard-frontend/game/src/App.css index 2056e8d..6d9324d 100644 --- a/dashboard-frontend/game/src/App.css +++ b/dashboard-frontend/game/src/App.css @@ -151,6 +151,7 @@ body { max-width: 100%; } .player-coins { font-size: 0.95rem; font-weight: 700; color: #43e97b; } +.player-total-credits { font-size: 0.66rem; color: #79c0ff; text-align: center; } .player-pts { font-size: 0.74rem; color: #8b949e; } .player-committed { font-size: 0.64rem; color: #f093fb; } @@ -1196,6 +1197,22 @@ body { margin-top: 1px; } +.btn-new-identity { + font-size: 0.72rem; + padding: 4px 10px; + border: 1px solid #484f58; + border-radius: 8px; + background: transparent; + color: #8b949e; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.15s, color 0.15s; +} +.btn-new-identity:hover { + border-color: #f093fb; + color: #f093fb; +} + .challenge-card { width: 100%; display: flex; diff --git a/dashboard-frontend/game/src/App.jsx b/dashboard-frontend/game/src/App.jsx index c82ea42..9c5b02d 100644 --- a/dashboard-frontend/game/src/App.jsx +++ b/dashboard-frontend/game/src/App.jsx @@ -5,6 +5,7 @@ import V2PvpLobby from './components/V2PvpLobby'; import V2PvpScreen from './components/V2PvpScreen'; import TutorialScreen from './components/TutorialScreen'; import { V2PvpTransport } from './transports/v2PvpTransport'; +import { clearSession } from './utils/storage'; import { trackGameEvent, trackGamePageView } from './utils/analytics'; export default function App() { @@ -94,6 +95,22 @@ export default function App() { setV2PvpConnectionError(''); }; + const handleNewIdentity = () => { + // Tear down the existing connection + const v2Transport = v2PvpSession?.transport || v2PvpMatch?.transport; + if (v2Transport) { + v2Transport.leaveMatch(); + v2Transport.close(); + } + // Clear the persisted session so the next connect creates a fresh identity + clearSession(); + // Reset all PvP state and trigger a new connection + setV2PvpSession(null); + setV2PvpMatch(null); + setV2PvpConnectionError(''); + setV2PvpConnectAttempt((attempt) => attempt + 1); + }; + // ---- PvP screen ---- if (showPvP) { return ( @@ -117,6 +134,7 @@ export default function App() { setV2PvpConnectionError(''); setV2PvpConnectAttempt((attempt) => attempt + 1); }} + onNewIdentity={handleNewIdentity} /> @@ -190,10 +208,10 @@ export default function App() {

Challenge another player in a live match over demo credits. Both players connect to the lobby, challenge, and play by the same V2 - wagering rules. Guest identities and credit balances are temporary. + wagering rules. Your guest identity persists across page reloads.

- Guest identities and demo balances reset on server restart. + Demo balances reset on server restart. Demo credits only — no real money.

+ )} {incoming && ( diff --git a/dashboard-frontend/game/src/components/V2PvpScreen.jsx b/dashboard-frontend/game/src/components/V2PvpScreen.jsx index f676cc5..0b380b0 100644 --- a/dashboard-frontend/game/src/components/V2PvpScreen.jsx +++ b/dashboard-frontend/game/src/components/V2PvpScreen.jsx @@ -26,6 +26,20 @@ function phaseLabel(phase, moveKind) { return ''; } +/** Distinguish the in-match stack from the escrow-inclusive demo total. */ +export function PlayerCreditSummary({ tableCredits = 0, totalCredits = null }) { + return ( + <> + Table {tableCredits} cr + {Number.isFinite(totalCredits) && ( + + Total {totalCredits} cr · includes table + + )} + + ); +} + /** Small local game-over overlay — avoids the data-shape mismatch of GameOverOverlay. */ function WinnerOverlay({ session, @@ -171,9 +185,10 @@ export default function V2PvpScreen({ transport, myId, onLobby }) { {c.mySeat ? c.mySeat.name : 'You'} - - {c.mySeat ? c.mySeat.coins : 0} cr - + {c.mySeat ? c.mySeat.score || 0 : 0} pts @@ -202,9 +217,10 @@ export default function V2PvpScreen({ transport, myId, onLobby }) { {c.opponentSeat ? c.opponentSeat.name : 'Opponent'} - - {c.opponentSeat ? c.opponentSeat.coins : 0} cr - + {c.opponentSeat ? c.opponentSeat.score || 0 : 0} pts diff --git a/dashboard-frontend/game/src/components/V2PvpScreen.test.jsx b/dashboard-frontend/game/src/components/V2PvpScreen.test.jsx new file mode 100644 index 0000000..2a2debf --- /dev/null +++ b/dashboard-frontend/game/src/components/V2PvpScreen.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { PlayerCreditSummary } from './V2PvpScreen'; + +describe('PlayerCreditSummary', () => { + it('distinguishes table credits from the escrow-inclusive total', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('Table 90 cr'); + expect(markup).toContain('Total 200 cr'); + expect(markup).toContain('includes table'); + }); +}); diff --git a/dashboard-frontend/game/src/game/sound.js b/dashboard-frontend/game/src/game/sound.js index ba72cbe..fcf55e2 100644 --- a/dashboard-frontend/game/src/game/sound.js +++ b/dashboard-frontend/game/src/game/sound.js @@ -1,29 +1,218 @@ /** - * Sound effects. Uses the native HTMLAudioElement (no extra dependency) with - * the bundled "cha-ching" cue from the original BlockCoined build. A new - * Audio() is created per play so overlapping scores don't cut each other off. + * Sound effects. Uses Web Audio so server-driven PvP score events can play + * after the browser's audio context has been unlocked by a user gesture. + * + * Mobile WebKit is stricter than desktop browsers: create/resume the context + * from touchend, click, or keydown, and synchronously start a silent source. + * Keep retrying on later gestures until the context is actually running. */ import chaching from '../sound/Cha-ching-sound.mp3'; +const UNLOCK_EVENTS = ['touchend', 'click', 'keydown']; + let enabled = true; +/** @type {AudioContext|null} */ +let ctx = null; +/** @type {AudioBuffer|null} */ +let buffer = null; +/** @type {Promise|null} */ +let loadPromise = null; +let contextGeneration = 0; +let listenersArmed = false; +let pendingPlay = false; +/** @type {AudioContext|null} */ +let silentStartedFor = null; + +function audioContextConstructor() { + if (typeof window === 'undefined') return null; + return window.AudioContext || window.webkitAudioContext || null; +} + +function onContextStateChange(context) { + if (context !== ctx) return; + if (context.state === 'running') { + disarmUnlockListeners(); + preload(context); + } else { + armUnlockListeners(); + } +} + +function ensureContext() { + if (ctx && ctx.state !== 'closed') return ctx; + + const Ctor = audioContextConstructor(); + if (!Ctor) return null; + + try { + ctx = new Ctor(); + } catch { + ctx = null; + return null; + } + + contextGeneration += 1; + buffer = null; + loadPromise = null; + silentStartedFor = null; + + const context = ctx; + if (typeof context.addEventListener === 'function') { + context.addEventListener('statechange', () => onContextStateChange(context)); + } else { + context.onstatechange = () => onContextStateChange(context); + } + return context; +} + +function startSilentUnlock(context) { + if (silentStartedFor === context) return; + try { + const source = context.createBufferSource(); + source.buffer = context.createBuffer(1, 1, 22_050); + source.connect(context.destination); + source.onended = () => { + try { source.disconnect(); } catch { /* ignore */ } + }; + source.start(0); + silentStartedFor = context; + } catch { + // A later supported gesture can retry resume even if this source fails. + } +} + +function completeUnlock(context) { + if (context !== ctx) return; + if (context.state !== 'running') { + armUnlockListeners(); + return; + } + disarmUnlockListeners(); + preload(context); +} + +function attemptUnlock() { + const context = ensureContext(); + if (!context) return; + + let resumePromise = null; + if (context.state !== 'running') { + try { + resumePromise = context.resume(); + } catch { + armUnlockListeners(); + } + } + + // Do not await resume: source.start() must remain in the trusted gesture + // call stack for iOS Safari. + startSilentUnlock(context); + + if (context.state === 'running') completeUnlock(context); + if (resumePromise && typeof resumePromise.then === 'function') { + resumePromise + .then(() => completeUnlock(context)) + .catch(() => armUnlockListeners()); + } +} + +function armUnlockListeners() { + if (listenersArmed || typeof document === 'undefined') return; + for (const eventName of UNLOCK_EVENTS) { + document.addEventListener(eventName, attemptUnlock, true); + } + listenersArmed = true; +} + +function disarmUnlockListeners() { + if (!listenersArmed || typeof document === 'undefined') return; + for (const eventName of UNLOCK_EVENTS) { + document.removeEventListener(eventName, attemptUnlock, true); + } + listenersArmed = false; +} + +async function preload(context) { + if (context !== ctx || context.state !== 'running' || buffer) return; + if (loadPromise) return loadPromise; + + const generation = contextGeneration; + const pending = (async () => { + try { + const response = await fetch(chaching); + if (!response.ok) throw new Error(`Audio fetch failed: ${response.status}`); + const raw = await response.arrayBuffer(); + const decoded = await context.decodeAudioData(raw); + if (context !== ctx || generation !== contextGeneration) return; + + buffer = decoded; + if (pendingPlay && enabled) { + pendingPlay = false; + playBuffer(context, decoded); + } + } catch { + if (context === ctx && generation === contextGeneration) { + pendingPlay = false; + } + } + })(); + + loadPromise = pending; + try { + await pending; + } finally { + if (context === ctx && generation === contextGeneration && loadPromise === pending) { + loadPromise = null; + } + } +} + +function playBuffer(context, audioBuffer) { + if (!enabled || context !== ctx || context.state !== 'running') { + armUnlockListeners(); + return; + } + + try { + const source = context.createBufferSource(); + const gain = context.createGain(); + source.buffer = audioBuffer; + gain.gain.value = 0.6; + source.connect(gain); + gain.connect(context.destination); + source.onended = () => { + try { source.disconnect(); } catch { /* ignore */ } + try { gain.disconnect(); } catch { /* ignore */ } + }; + source.start(0); + } catch { + /* audio not available — ignore */ + } +} export function setSoundEnabled(on) { enabled = !!on; + if (!enabled) pendingPlay = false; } export function isSoundEnabled() { return enabled; } -/** Play the score "cha-ching". Safe to call repeatedly; ignores failures. */ +/** Play the score "cha-ching" once for each 3+ coin cascade. */ export function playScoreSound() { if (!enabled) return; - try { - const a = new Audio(chaching); - a.volume = 0.6; - const p = a.play(); - if (p && typeof p.catch === 'function') p.catch(() => {}); - } catch { - /* audio not available — ignore */ + if (!ctx || ctx.state !== 'running') { + pendingPlay = true; + armUnlockListeners(); + return; + } + if (!buffer) { + pendingPlay = true; + preload(ctx); + return; } + playBuffer(ctx, buffer); } + +armUnlockListeners(); diff --git a/dashboard-frontend/game/src/game/sound.test.js b/dashboard-frontend/game/src/game/sound.test.js new file mode 100644 index 0000000..f450f09 --- /dev/null +++ b/dashboard-frontend/game/src/game/sound.test.js @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +class FakeAudioContext extends EventTarget { + static instances = []; + static resumeResults = []; + static decodePromise = null; + + constructor() { + super(); + this.state = 'suspended'; + this.destination = {}; + this.resumeCalls = 0; + this.sources = []; + FakeAudioContext.instances.push(this); + } + + resume() { + this.resumeCalls += 1; + const result = FakeAudioContext.resumeResults.shift() || 'resolve'; + if (result === 'reject') return Promise.reject(new Error('blocked')); + this.state = 'running'; + this.dispatchEvent(new Event('statechange')); + return Promise.resolve(); + } + + createBuffer() { + return { kind: 'silent' }; + } + + createBufferSource() { + const source = { + buffer: null, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + onended: null, + }; + this.sources.push(source); + return source; + } + + createGain() { + return { + gain: { value: 1 }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + } + + decodeAudioData() { + return FakeAudioContext.decodePromise || Promise.resolve({ kind: 'score' }); + } +} + +async function flushPromises() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('mobile score audio unlock', () => { + let documentTarget; + + beforeEach(() => { + vi.resetModules(); + FakeAudioContext.instances = []; + FakeAudioContext.resumeResults = []; + FakeAudioContext.decodePromise = null; + documentTarget = new EventTarget(); + vi.stubGlobal('document', documentTarget); + vi.stubGlobal('window', { AudioContext: FakeAudioContext }); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + arrayBuffer: async () => new ArrayBuffer(8), + }))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('creates and unlocks the context from touchend, not pointerdown', async () => { + await import('./sound'); + + documentTarget.dispatchEvent(new Event('pointerdown')); + expect(FakeAudioContext.instances).toHaveLength(0); + + documentTarget.dispatchEvent(new Event('touchend')); + await flushPromises(); + + const context = FakeAudioContext.instances[0]; + expect(context.state).toBe('running'); + expect(context.resumeCalls).toBe(1); + expect(context.sources[0].buffer).toEqual({ kind: 'silent' }); + expect(context.sources[0].start).toHaveBeenCalledWith(0); + }); + + it('keeps gesture listeners armed after a failed resume and retries', async () => { + FakeAudioContext.resumeResults = ['reject', 'resolve']; + await import('./sound'); + + documentTarget.dispatchEvent(new Event('touchend')); + await flushPromises(); + const context = FakeAudioContext.instances[0]; + expect(context.state).toBe('suspended'); + + documentTarget.dispatchEvent(new Event('click')); + await flushPromises(); + expect(context.resumeCalls).toBe(2); + expect(context.state).toBe('running'); + }); + + it('plays the decoded score buffer from a later non-gesture callback', async () => { + const sound = await import('./sound'); + documentTarget.dispatchEvent(new Event('click')); + await flushPromises(); + + const context = FakeAudioContext.instances[0]; + const sourcesBefore = context.sources.length; + sound.playScoreSound(); + + expect(context.sources).toHaveLength(sourcesBefore + 1); + expect(context.sources.at(-1).buffer).toEqual({ kind: 'score' }); + expect(context.sources.at(-1).start).toHaveBeenCalledWith(0); + }); + + it('plays one pending score after the buffer finishes decoding', async () => { + let finishDecode; + FakeAudioContext.decodePromise = new Promise((resolve) => { + finishDecode = resolve; + }); + const sound = await import('./sound'); + documentTarget.dispatchEvent(new Event('touchend')); + await flushPromises(); + + const context = FakeAudioContext.instances[0]; + sound.playScoreSound(); + sound.playScoreSound(); + expect(context.sources).toHaveLength(1); // silent unlock source only + + finishDecode({ kind: 'score' }); + await flushPromises(); + + expect(context.sources).toHaveLength(2); + expect(context.sources.at(-1).buffer).toEqual({ kind: 'score' }); + expect(context.sources.at(-1).start).toHaveBeenCalledWith(0); + }); +}); diff --git a/dashboard-frontend/game/src/game/usePvpWagerController.js b/dashboard-frontend/game/src/game/usePvpWagerController.js index f22ed5c..8780530 100644 --- a/dashboard-frontend/game/src/game/usePvpWagerController.js +++ b/dashboard-frontend/game/src/game/usePvpWagerController.js @@ -149,6 +149,14 @@ export function usePvpWagerController({ transport, myId }) { const opponentSeat = seats.find( (s, i) => i !== mySeatIndex && !s.folded ) || seats.find((s, i) => i !== mySeatIndex) || null; + const myPlayer = players.find((player) => player.id === viewerId) || null; + const opponentPlayer = players.find((player) => player.id === opponentSeat?.id) || null; + const myTotalCredits = Number.isFinite(myPlayer?.totalCredits) + ? myPlayer.totalCredits + : (Number.isFinite(self?.credits) ? self.credits : null); + const opponentTotalCredits = Number.isFinite(opponentPlayer?.totalCredits) + ? opponentPlayer.totalCredits + : null; // ---- Turn detection ---- const isMyMove = @@ -308,7 +316,18 @@ export function usePvpWagerController({ transport, myId }) { if (transport.lastSnapshot) applySnapshot(transport.lastSnapshot); const off = transport.onMessage((msg) => { - if (msg.type === 'V2_SNAPSHOT') { + if (msg.type === 'REMATCH_STARTED') { + // New room starts with revision 1; reset so we don't reject its + // first snapshot below the old room's last revision. + genRef.current += 1; + animatingRef.current = false; + setIsAnimating(false); + setMatchedIndices([]); + setPhase('idle'); + setSelected(null); + authoritativeRevisionRef.current = -1; + displayRevisionRef.current = -1; + } else if (msg.type === 'V2_SNAPSHOT') { applySnapshot(msg); } else if (msg.type === 'ERROR') { setMessage(msg.message || 'An error occurred'); @@ -385,6 +404,8 @@ export function usePvpWagerController({ transport, myId }) { // Derived mySeat, opponentSeat, + myTotalCredits, + opponentTotalCredits, seats, players, self, diff --git a/dashboard-frontend/game/src/transports/v2PvpTransport.js b/dashboard-frontend/game/src/transports/v2PvpTransport.js index 49eebc6..146e8f3 100644 --- a/dashboard-frontend/game/src/transports/v2PvpTransport.js +++ b/dashboard-frontend/game/src/transports/v2PvpTransport.js @@ -5,9 +5,12 @@ * - Derives ws/wss from location.host with exact /game/ws. * - Permits VITE_PVP_URL override only for local development. * - Versioned protocol handshake (protocolVersion 1 in IDENTIFY_GUEST). + * - Session token in localStorage persists guest identity across page loads. * - Bounded reconnect/backoff for lobby disconnections only. * - No Google auth import, config, module, or CSP references. */ +import { loadSession, saveSession } from '../utils/storage'; + const PROTOCOL_VERSION = 1; function devOverrideUrl() { @@ -70,6 +73,10 @@ export class V2PvpTransport { let settled = false; let handshakeTimer = null; + // Read persisted session token for identity restoration. + const persistedSession = loadSession(); + const sessionToken = persistedSession?.sessionToken; + const resolveConnection = (identity) => { if (settled) return; settled = true; @@ -90,7 +97,9 @@ export class V2PvpTransport { }, 10_000); ws.onopen = () => { - this._send({ type: 'IDENTIFY_GUEST', protocolVersion: PROTOCOL_VERSION }); + const handshake = { type: 'IDENTIFY_GUEST', protocolVersion: PROTOCOL_VERSION }; + if (sessionToken) handshake.sessionToken = sessionToken; + this._send(handshake); }; ws.onmessage = (ev) => { @@ -106,6 +115,13 @@ export class V2PvpTransport { this._connectedOnce = true; this._reconnectAttempts = 0; resolveConnection(msg.self); + // Persist the session token + identity so reloads restore them. + // saveSession validates all fields internally before writing. + saveSession({ + sessionToken: msg.sessionToken, + userId: msg.self?.userId, + name: msg.self?.name, + }); } if (msg.type === 'LOBBY_SNAPSHOT') this.lastLobbySnapshot = msg; if (msg.type === 'V2_SNAPSHOT') { @@ -113,7 +129,14 @@ export class V2PvpTransport { this.expectedRevision = msg.revision; if (msg.lifecycle === 'active') this._inMatch = true; } - if (msg.type === 'MATCH_STARTED' || msg.type === 'REMATCH_STARTED') this._inMatch = true; + if (msg.type === 'MATCH_STARTED' || msg.type === 'REMATCH_STARTED') { + this._inMatch = true; + if (msg.type === 'REMATCH_STARTED') { + // New room has its own revision sequence; reset stale state. + this.lastSnapshot = null; + this.expectedRevision = -1; + } + } if (msg.type === 'MATCH_ENDED') { this._inMatch = false; this.lastSnapshot = null; diff --git a/dashboard-frontend/game/src/utils/storage.js b/dashboard-frontend/game/src/utils/storage.js new file mode 100644 index 0000000..df1ba43 --- /dev/null +++ b/dashboard-frontend/game/src/utils/storage.js @@ -0,0 +1,73 @@ +/** + * storage.js — client-side persistence for PvP guest session data. + * + * Stores the opaque session token (issued by the game server) in localStorage + * so that page reloads / tab closures restore the same guest identity and + * credit balance (within the server's lifetime). + * + * The server never trusts client-stored balances — only the session token is + * meaningful. The userId and name are cached alongside for immediate UI use + * without waiting for a round-trip. + */ + +const STORAGE_KEY = 'bc2_pvp_session'; + +/** + * @typedef {{ sessionToken: string, userId: string, name: string }} PvpSession + */ + +/** Read the persisted PvP session, or null if absent / corrupt. */ +export function loadSession() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if ( + typeof parsed.sessionToken === 'string' && + parsed.sessionToken.length > 0 && + typeof parsed.userId === 'string' && + typeof parsed.name === 'string' + ) { + return parsed; + } + } catch { + // Corrupt or unavailable — treat as no session + } + return null; +} + +/** Persist the PvP session (token + identity) to localStorage. + * Validates that all required fields are non-empty strings before writing. + * Returns true if the session was persisted, false otherwise. */ +export function saveSession(session) { + if ( + !session || + typeof session.sessionToken !== 'string' || + session.sessionToken.length === 0 || + typeof session.userId !== 'string' || + session.userId.length === 0 || + typeof session.name !== 'string' || + session.name.length === 0 + ) { + return false; + } + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + sessionToken: session.sessionToken, + userId: session.userId, + name: session.name, + })); + return true; + } catch { + return false; + } +} + +/** Remove the persisted session (explicit "new identity" action). */ +export function clearSession() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } +} diff --git a/dashboard-frontend/game/src/utils/storage.test.js b/dashboard-frontend/game/src/utils/storage.test.js new file mode 100644 index 0000000..db1a668 --- /dev/null +++ b/dashboard-frontend/game/src/utils/storage.test.js @@ -0,0 +1,209 @@ +/** + * storage.test.js — unit tests for PvP guest session persistence. + * + * Runs under vitest's node environment; localStorage is mocked via + * globalThis stubs since jsdom is not a project dependency. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { loadSession, saveSession, clearSession } from './storage'; + +/** Minimal in-memory localStorage replacement. */ +function createMockStorage() { + const store = {}; + return { + getItem(key) { return store[key] ?? null; }, + setItem(key, value) { store[key] = String(value); }, + removeItem(key) { delete store[key]; }, + clear() { Object.keys(store).forEach((k) => delete store[k]); }, + get length() { return Object.keys(store).length; }, + key(i) { return Object.keys(store)[i] ?? null; }, + }; +} + +let mockStorage; + +beforeEach(() => { + mockStorage = createMockStorage(); + globalThis.localStorage = mockStorage; +}); + +afterEach(() => { + delete globalThis.localStorage; +}); + +/* ------------------------------------------------------------------ */ +/* Round-trip tests */ +/* ------------------------------------------------------------------ */ + +describe('saveSession / loadSession', () => { + it('persists a valid session and reads it back', () => { + const session = { sessionToken: 'abc123', userId: 'guest_xyz', name: 'Amber Badger' }; + saveSession(session); + expect(loadSession()).toEqual(session); + }); + + it('overwrites an existing session on subsequent save', () => { + const first = { sessionToken: 'tok1', userId: 'uid1', name: 'First' }; + const second = { sessionToken: 'tok2', userId: 'uid2', name: 'Second' }; + saveSession(first); + saveSession(second); + expect(loadSession()).toEqual(second); + }); + + it('returns null when no session has been saved', () => { + expect(loadSession()).toBeNull(); + }); + + it('handles special characters in name', () => { + const session = { sessionToken: 'tok', userId: 'uid', name: "Cöbra O'Malley—Viper" }; + saveSession(session); + expect(loadSession()).toEqual(session); + }); + + it('returns true on successful save', () => { + const result = saveSession({ sessionToken: 'tok', userId: 'uid', name: 'Name' }); + expect(result).toBe(true); + }); +}); + +/* ------------------------------------------------------------------ */ +/* saveSession validation */ +/* ------------------------------------------------------------------ */ + +describe('saveSession validation', () => { + it('rejects null', () => { + expect(saveSession(null)).toBe(false); + expect(loadSession()).toBeNull(); + }); + + it('rejects undefined', () => { + expect(saveSession(undefined)).toBe(false); + expect(loadSession()).toBeNull(); + }); + + it('rejects empty sessionToken', () => { + expect(saveSession({ sessionToken: '', userId: 'uid', name: 'Name' })).toBe(false); + expect(loadSession()).toBeNull(); + }); + + it('rejects non-string sessionToken', () => { + expect(saveSession({ sessionToken: 123, userId: 'uid', name: 'Name' })).toBe(false); + expect(loadSession()).toBeNull(); + }); + + it('rejects missing userId', () => { + expect(saveSession({ sessionToken: 'tok', name: 'Name' })).toBe(false); + }); + + it('rejects empty userId', () => { + expect(saveSession({ sessionToken: 'tok', userId: '', name: 'Name' })).toBe(false); + }); + + it('rejects missing name', () => { + expect(saveSession({ sessionToken: 'tok', userId: 'uid' })).toBe(false); + }); + + it('rejects empty name', () => { + expect(saveSession({ sessionToken: 'tok', userId: 'uid', name: '' })).toBe(false); + }); + + it('rejects extra unknown properties but valid fields still saves', () => { + const result = saveSession({ sessionToken: 'tok', userId: 'uid', name: 'Name', extra: 'ignored' }); + expect(result).toBe(true); + const loaded = loadSession(); + expect(loaded.sessionToken).toBe('tok'); + expect(loaded.extra).toBeUndefined(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Corruption / edge cases */ +/* ------------------------------------------------------------------ */ + +describe('loadSession — corruption resilience', () => { + it('returns null for missing key', () => { + expect(loadSession()).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + mockStorage.setItem('bc2_pvp_session', '{bad json'); + expect(loadSession()).toBeNull(); + }); + + it('returns null for non-object JSON', () => { + mockStorage.setItem('bc2_pvp_session', '"just a string"'); + expect(loadSession()).toBeNull(); + }); + + it('returns null for empty object', () => { + mockStorage.setItem('bc2_pvp_session', '{}'); + expect(loadSession()).toBeNull(); + }); + + it('returns null when sessionToken is missing', () => { + mockStorage.setItem('bc2_pvp_session', JSON.stringify({ userId: 'uid', name: 'Name' })); + expect(loadSession()).toBeNull(); + }); + + it('returns null when sessionToken is empty string', () => { + mockStorage.setItem('bc2_pvp_session', JSON.stringify({ sessionToken: '', userId: 'uid', name: 'Name' })); + expect(loadSession()).toBeNull(); + }); + + it('returns null when userId is missing', () => { + mockStorage.setItem('bc2_pvp_session', JSON.stringify({ sessionToken: 'tok', name: 'Name' })); + expect(loadSession()).toBeNull(); + }); + + it('returns null when name is missing', () => { + mockStorage.setItem('bc2_pvp_session', JSON.stringify({ sessionToken: 'tok', userId: 'uid' })); + expect(loadSession()).toBeNull(); + }); + + it('returns null when name is not a string', () => { + mockStorage.setItem('bc2_pvp_session', JSON.stringify({ sessionToken: 'tok', userId: 'uid', name: 42 })); + expect(loadSession()).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* clearSession */ +/* ------------------------------------------------------------------ */ + +describe('clearSession', () => { + it('removes the stored session', () => { + saveSession({ sessionToken: 'tok', userId: 'uid', name: 'Name' }); + clearSession(); + expect(loadSession()).toBeNull(); + }); + + it('is a no-op when no session is stored', () => { + clearSession(); + expect(loadSession()).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* localStorage unavailable */ +/* ------------------------------------------------------------------ */ + +describe('when localStorage is unavailable', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + get() { throw new Error('localStorage denied'); }, + configurable: true, + }); + }); + + it('saveSession does not throw', () => { + expect(() => saveSession({ sessionToken: 't', userId: 'u', name: 'n' })).not.toThrow(); + }); + + it('loadSession returns null without throwing', () => { + expect(loadSession()).toBeNull(); + }); + + it('clearSession does not throw', () => { + expect(() => clearSession()).not.toThrow(); + }); +}); From e6a8f68bed01b831754e33dca47b689b69d49371 Mon Sep 17 00:00:00 2001 From: "protostatis.dev" Date: Mon, 27 Jul 2026 21:06:14 -0500 Subject: [PATCH 2/2] fix: remove unused s2 variable in SessionStore test (lint) --- dashboard-frontend/game/server/SessionStore.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard-frontend/game/server/SessionStore.test.js b/dashboard-frontend/game/server/SessionStore.test.js index c21098a..2379184 100644 --- a/dashboard-frontend/game/server/SessionStore.test.js +++ b/dashboard-frontend/game/server/SessionStore.test.js @@ -147,8 +147,8 @@ describe('SessionStore eviction', () => { const store = freshStore(2); const s1 = store.getOrCreate(); - const s2 = store.getOrCreate(); - store.getOrCreate(); // evicts s1 + store.getOrCreate(); // fills slot 2 + store.getOrCreate(); // evicts s1 (slot 1) const restored = store.getOrCreate(s1.token); assert.notEqual(restored.userId, s1.userId, 'evicted token should not restore old identity');