From 26b095883e5e1cef888c972b06099379705f4210 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sat, 28 Feb 2026 14:00:38 +0100 Subject: [PATCH 1/2] psersist disconnectedgamestte --- src/Lobby.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Lobby.ts b/src/Lobby.ts index eaea703..d17546c 100644 --- a/src/Lobby.ts +++ b/src/Lobby.ts @@ -1,5 +1,6 @@ import type Client from "./Client.js"; import GameModes from "./GameMode.js"; +import { InsaneInt } from "./InsaneInt.js"; import type { ActionLobbyInfo, ActionServerToClient, @@ -31,10 +32,24 @@ export const getEnemy = (client: Client): [Lobby | null, Client | null] => { /** How long to keep a disconnected player's slot reserved (ms) */ const RECONNECT_GRACE_PERIOD = 60000; +interface DisconnectedGameState { + lives: number; + score: InsaneInt; + handsLeft: number; + ante: number; + skips: number; + furthestBlind: number; + livesBlocker: boolean; + isReady: boolean; + firstReady: boolean; + location: string; +} + interface DisconnectedSlot { reconnectToken: string; role: 'host' | 'guest'; timer: ReturnType; + gameState: DisconnectedGameState; } class Lobby { @@ -132,6 +147,18 @@ class Lobby { this.disconnectedSlot = { reconnectToken: client.reconnectToken, role, + gameState: { + lives: client.lives, + score: new InsaneInt(client.score.startingECount, client.score.coefficient, client.score.exponent), + handsLeft: client.handsLeft, + ante: client.ante, + skips: client.skips, + furthestBlind: client.furthestBlind, + livesBlocker: client.livesBlocker, + isReady: client.isReady, + firstReady: client.firstReady, + location: client.location, + }, timer: setTimeout(() => { // Grace period expired, do a full leave console.log(`Reconnect grace period expired for lobby ${this.code}`) @@ -158,10 +185,22 @@ class Lobby { return false; } - const { role, timer } = this.disconnectedSlot; + const { role, timer, gameState } = this.disconnectedSlot; clearTimeout(timer); this.disconnectedSlot = null; + // Restore game state from the disconnected client + newClient.lives = gameState.lives; + newClient.score = new InsaneInt(gameState.score.startingECount, gameState.score.coefficient, gameState.score.exponent); + newClient.handsLeft = gameState.handsLeft; + newClient.ante = gameState.ante; + newClient.skips = gameState.skips; + newClient.furthestBlind = gameState.furthestBlind; + newClient.livesBlocker = gameState.livesBlocker; + newClient.isReady = gameState.isReady; + newClient.firstReady = gameState.firstReady; + newClient.location = gameState.location; + // Place the new client in the correct slot if (role === 'host') { this.host = newClient; @@ -184,6 +223,16 @@ class Lobby { const enemy = role === 'host' ? this.guest : this.host; enemy?.sendAction({ action: "enemyReconnected" }); + // Re-sync game state so both sides have correct values + newClient.sendAction({ action: "playerInfo", lives: newClient.lives }); + enemy?.sendAction({ + action: "enemyInfo", + handsLeft: newClient.handsLeft, + score: newClient.score.toString(), + skips: newClient.skips, + lives: newClient.lives, + }); + this.broadcastLobbyInfo(); return true; }; From 921cf4b8fbefe5d8f5bce4d37d01ec1920a8c557 Mon Sep 17 00:00:00 2001 From: Stephen Kirk Date: Sat, 28 Feb 2026 14:08:06 +0100 Subject: [PATCH 2/2] Fix lives resetting to default after reconnect On disconnect, preserve the old Client object (with all game state) in the reconnect slot. On rejoin, swap the new socket onto the old client via replaceConnection instead of creating a fresh client with default lives = 5. Re-sync both sides with playerInfo/enemyInfo after rejoin. --- src/Client.ts | 8 +++++ src/Lobby.ts | 84 +++++++++++++------------------------------ src/actionHandlers.ts | 7 ++-- src/main.ts | 8 +++-- 4 files changed, 43 insertions(+), 64 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 3936312..d3ad349 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -101,6 +101,14 @@ class Client { setSkips = (skips: number) => { this.skips = skips } + + /** Adopt the connection from another Client (used on reconnect) */ + replaceConnection = (other: Client) => { + this.address = other.address + this.sendAction = other.sendAction + this.closeConnection = other.closeConnection + this.reconnectToken = other.reconnectToken + } } export default Client diff --git a/src/Lobby.ts b/src/Lobby.ts index d17546c..388f7db 100644 --- a/src/Lobby.ts +++ b/src/Lobby.ts @@ -1,6 +1,5 @@ import type Client from "./Client.js"; import GameModes from "./GameMode.js"; -import { InsaneInt } from "./InsaneInt.js"; import type { ActionLobbyInfo, ActionServerToClient, @@ -32,24 +31,10 @@ export const getEnemy = (client: Client): [Lobby | null, Client | null] => { /** How long to keep a disconnected player's slot reserved (ms) */ const RECONNECT_GRACE_PERIOD = 60000; -interface DisconnectedGameState { - lives: number; - score: InsaneInt; - handsLeft: number; - ante: number; - skips: number; - furthestBlind: number; - livesBlocker: boolean; - isReady: boolean; - firstReady: boolean; - location: string; -} - interface DisconnectedSlot { - reconnectToken: string; + client: Client; role: 'host' | 'guest'; timer: ReturnType; - gameState: DisconnectedGameState; } class Lobby { @@ -145,20 +130,8 @@ class Lobby { console.log(`Player ${client.id} disconnected from lobby ${this.code}, reserving slot for ${RECONNECT_GRACE_PERIOD / 1000}s`) this.disconnectedSlot = { - reconnectToken: client.reconnectToken, + client, role, - gameState: { - lives: client.lives, - score: new InsaneInt(client.score.startingECount, client.score.coefficient, client.score.exponent), - handsLeft: client.handsLeft, - ante: client.ante, - skips: client.skips, - furthestBlind: client.furthestBlind, - livesBlocker: client.livesBlocker, - isReady: client.isReady, - firstReady: client.firstReady, - location: client.location, - }, timer: setTimeout(() => { // Grace period expired, do a full leave console.log(`Reconnect grace period expired for lobby ${this.code}`) @@ -179,44 +152,37 @@ class Lobby { enemy?.sendAction({ action: "enemyDisconnected" }); }; - /** Reconnecting client reclaims their slot */ - rejoin = (newClient: Client, reconnectToken: string): boolean => { - if (!this.disconnectedSlot || this.disconnectedSlot.reconnectToken !== reconnectToken) { - return false; + /** Reconnecting client reclaims their slot. + * Returns the restored Client (with all game state intact) on success, or null on failure. + * The caller MUST use the returned client for all future messages on this socket. */ + rejoin = (newClient: Client, reconnectToken: string): Client | null => { + if (!this.disconnectedSlot || this.disconnectedSlot.client.reconnectToken !== reconnectToken) { + return null; } - const { role, timer, gameState } = this.disconnectedSlot; + const { client: oldClient, role, timer } = this.disconnectedSlot; clearTimeout(timer); this.disconnectedSlot = null; - // Restore game state from the disconnected client - newClient.lives = gameState.lives; - newClient.score = new InsaneInt(gameState.score.startingECount, gameState.score.coefficient, gameState.score.exponent); - newClient.handsLeft = gameState.handsLeft; - newClient.ante = gameState.ante; - newClient.skips = gameState.skips; - newClient.furthestBlind = gameState.furthestBlind; - newClient.livesBlocker = gameState.livesBlocker; - newClient.isReady = gameState.isReady; - newClient.firstReady = gameState.firstReady; - newClient.location = gameState.location; - - // Place the new client in the correct slot + // Swap the new socket/connection onto the old client, preserving all game state + oldClient.replaceConnection(newClient); + + // Place the old client back in the correct slot if (role === 'host') { - this.host = newClient; + this.host = oldClient; } else { - this.guest = newClient; + this.guest = oldClient; } - newClient.setLobby(this); - this.handyAllowMPExtension.set(newClient.id, false); + oldClient.setLobby(this); + this.handyAllowMPExtension.set(oldClient.id, false); // Send rejoin confirmation with new reconnect token - newClient.sendAction({ + oldClient.sendAction({ action: "rejoinedLobby", code: this.code, type: this.gameMode, - reconnectToken: newClient.reconnectToken, + reconnectToken: oldClient.reconnectToken, }); // Notify the other player @@ -224,17 +190,17 @@ class Lobby { enemy?.sendAction({ action: "enemyReconnected" }); // Re-sync game state so both sides have correct values - newClient.sendAction({ action: "playerInfo", lives: newClient.lives }); + oldClient.sendAction({ action: "playerInfo", lives: oldClient.lives }); enemy?.sendAction({ action: "enemyInfo", - handsLeft: newClient.handsLeft, - score: newClient.score.toString(), - skips: newClient.skips, - lives: newClient.lives, + handsLeft: oldClient.handsLeft, + score: oldClient.score.toString(), + skips: oldClient.skips, + lives: oldClient.lives, }); this.broadcastLobbyInfo(); - return true; + return oldClient; }; join = (client: Client) => { diff --git a/src/actionHandlers.ts b/src/actionHandlers.ts index f7b1330..642f6b4 100644 --- a/src/actionHandlers.ts +++ b/src/actionHandlers.ts @@ -84,7 +84,7 @@ const disconnectFromLobbyAction = (client: Client) => { const rejoinLobbyAction = ( { code, reconnectToken }: ActionHandlerArgs, client: Client, -) => { +): Client | undefined => { const lobby = Lobby.get(code); if (!lobby) { client.sendAction({ @@ -94,12 +94,15 @@ const rejoinLobbyAction = ( return; } - if (!lobby.rejoin(client, reconnectToken)) { + const restoredClient = lobby.rejoin(client, reconnectToken); + if (!restoredClient) { client.sendAction({ action: "error", message: "Could not rejoin lobby. Token invalid or slot expired.", }); + return; } + return restoredClient; }; const lobbyInfoAction = (client: Client) => { diff --git a/src/main.ts b/src/main.ts index 7af973d..d3d12fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -122,7 +122,7 @@ const server = createServer((socket) => { // Enable OS-level TCP keepalive as secondary dead connection detection socket.setKeepAlive(true, 10000) - const client = new Client(socket.address(), sendActionToSocket(socket), socket.end) + let client = new Client(socket.address(), sendActionToSocket(socket), socket.end) client.sendAction({ action: 'connected' }) client.sendAction({ action: 'version' }) @@ -205,12 +205,14 @@ const server = createServer((socket) => { client, ) break - case 'rejoinLobby': - actionHandlers.rejoinLobby( + case 'rejoinLobby': { + const restored = actionHandlers.rejoinLobby( actionArgs as ActionHandlerArgs, client, ) + if (restored) client = restored break + } case 'lobbyInfo': actionHandlers.lobbyInfo(client) break