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..2379184
--- /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();
+ 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');
+ 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.
- Guest identities and PvP demo balances are temporary — they reset when the server restarts or you reconnect.
+ Your guest identity persists across page reloads. Demo balances reset on server restart. Use "New Identity" below to start fresh.