Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 30 additions & 15 deletions src/Lobby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const getEnemy = (client: Client): [Lobby | null, Client | null] => {
const RECONNECT_GRACE_PERIOD = 60000;

interface DisconnectedSlot {
reconnectToken: string;
client: Client;
role: 'host' | 'guest';
timer: ReturnType<typeof setTimeout>;
}
Expand Down Expand Up @@ -130,7 +130,7 @@ 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,
timer: setTimeout(() => {
// Grace period expired, do a full leave
Expand All @@ -152,40 +152,55 @@ 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 } = this.disconnectedSlot;
const { client: oldClient, role, timer } = this.disconnectedSlot;
clearTimeout(timer);
this.disconnectedSlot = null;

// 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
const enemy = role === 'host' ? this.guest : this.host;
enemy?.sendAction({ action: "enemyReconnected" });

// Re-sync game state so both sides have correct values
oldClient.sendAction({ action: "playerInfo", lives: oldClient.lives });
enemy?.sendAction({
action: "enemyInfo",
handsLeft: oldClient.handsLeft,
score: oldClient.score.toString(),
skips: oldClient.skips,
lives: oldClient.lives,
});

this.broadcastLobbyInfo();
return true;
return oldClient;
};

join = (client: Client) => {
Expand Down
7 changes: 5 additions & 2 deletions src/actionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const disconnectFromLobbyAction = (client: Client) => {
const rejoinLobbyAction = (
{ code, reconnectToken }: ActionHandlerArgs<ActionRejoinLobby>,
client: Client,
) => {
): Client | undefined => {
const lobby = Lobby.get(code);
if (!lobby) {
client.sendAction({
Expand All @@ -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) => {
Expand Down
8 changes: 5 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })

Expand Down Expand Up @@ -205,12 +205,14 @@ const server = createServer((socket) => {
client,
)
break
case 'rejoinLobby':
actionHandlers.rejoinLobby(
case 'rejoinLobby': {
const restored = actionHandlers.rejoinLobby(
actionArgs as ActionHandlerArgs<ActionRejoinLobby>,
client,
)
if (restored) client = restored
break
}
case 'lobbyInfo':
actionHandlers.lobbyInfo(client)
break
Expand Down