1
0

Fixing game logic for save/restore

This commit is contained in:
James Ketr 2025-10-08 16:28:58 -07:00
parent 130b0371c5
commit ffb6fe61b0
10 changed files with 309 additions and 111 deletions

View File

@ -180,8 +180,6 @@ const Activities: React.FC = () => {
rollForOrder = state === "game-order", rollForOrder = state === "game-order",
selectResources = turn && turn.actions && turn.actions.indexOf("select-resources") !== -1; selectResources = turn && turn.actions && turn.actions.indexOf("select-resources") !== -1;
console.log(`activities - `, state, turn, activities);
const discarders: React.ReactElement[] = []; const discarders: React.ReactElement[] = [];
let mustDiscard = false; let mustDiscard = false;
for (const key in players) { for (const key in players) {

View File

@ -933,6 +933,10 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
} }
}); });
useEffect(() => {
console.log(`board - tile elements`, tileElements);
}, [tileElements]);
const canAction = (action) => { const canAction = (action) => {
return turn && Array.isArray(turn.actions) && turn.actions.indexOf(action) !== -1; return turn && Array.isArray(turn.actions) && turn.actions.indexOf(action) !== -1;
}; };
@ -948,7 +952,6 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
const canPip = const canPip =
canAction("place-robber") && turn.color === color && (state === "initial-placement" || state === "normal"); canAction("place-robber") && turn.color === color && (state === "initial-placement" || state === "normal");
console.log(`board - tile elements`, tileElements);
return ( return (
<div className="Board" ref={board}> <div className="Board" ref={board}>
<div className="Tooltip">tooltip</div> <div className="Tooltip">tooltip</div>

View File

@ -27,7 +27,7 @@ const PlayerList: React.FC = () => {
const [players, setPlayers] = useState<Player[] | null>(null); const [players, setPlayers] = useState<Player[] | null>(null);
const [peers, setPeers] = useState<Record<string, Peer>>({}); const [peers, setPeers] = useState<Record<string, Peer>>({});
useEffect(() => { useEffect(() => {
console.log("PlayerList - Mounted - requesting fields"); console.log("player-list - Mounted - requesting fields");
if (sendJsonMessage) { if (sendJsonMessage) {
sendJsonMessage({ sendJsonMessage({
type: "get", type: "get",
@ -38,7 +38,7 @@ const PlayerList: React.FC = () => {
// Debug logging // Debug logging
useEffect(() => { useEffect(() => {
console.log("PlayerList - Debug state:", { console.log("player-list - Debug state:", {
session_id: session?.id, session_id: session?.id,
session_name: session?.name, session_name: session?.name,
players_count: players?.length, players_count: players?.length,
@ -84,17 +84,18 @@ const PlayerList: React.FC = () => {
const data: any = lastJsonMessage; const data: any = lastJsonMessage;
switch (data.type) { switch (data.type) {
case "game-update": { case "game-update": {
console.log(`PlayerList - game-update:`, data.update); console.log(`player-list - game-update:`, data.update);
// Handle participants list // Handle participants list
if ("participants" in data.update && data.update.participants) { if ("participants" in data.update && data.update.participants) {
const participantsList: Player[] = data.update.participants; const participantsList: Player[] = data.update.participants;
console.log(`PlayerList - participants:`, participantsList); console.log(`player-list - participants:`, participantsList);
participantsList.forEach((player) => { participantsList.forEach((player) => {
player.local = player.session_id === session?.id; player.local = player.session_id === session?.id;
}); });
participantsList.sort(sortPlayers); participantsList.sort(sortPlayers);
console.log(`player-list - sorted participants:`, participantsList);
setPlayers(participantsList); setPlayers(participantsList);
// Initialize peers with remote mute/video state // Initialize peers with remote mute/video state
@ -149,7 +150,7 @@ const PlayerList: React.FC = () => {
// Debug logging // Debug logging
useEffect(() => { useEffect(() => {
console.log("PlayerList - Debug state:", { console.log("player-list - Debug state:", {
session_id: session?.id, session_id: session?.id,
session_name: session?.name, session_name: session?.name,
players_count: players?.length, players_count: players?.length,
@ -162,7 +163,7 @@ const PlayerList: React.FC = () => {
return ( return (
<Box sx={{ position: "relative", width: "100%" }}> <Box sx={{ position: "relative", width: "100%" }}>
<Paper <Paper
className={`PlayerList Medium`} className={`player-list Medium`}
sx={{ sx={{
maxWidth: { xs: "100%", sm: 500 }, maxWidth: { xs: "100%", sm: 500 },
p: { xs: 1, sm: 2 }, p: { xs: 1, sm: 2 },

View File

@ -147,6 +147,11 @@ const RoomView = (props: RoomProps) => {
} }
const data: any = lastJsonMessage; const data: any = lastJsonMessage;
switch (data.type) { switch (data.type) {
case "ping":
// Respond to server ping immediately to maintain connection
console.log("App - Received ping from server, sending pong");
sendJsonMessage({ type: "pong" });
break;
case "error": case "error":
console.error(`App - error`, data.error); console.error(`App - error`, data.error);
setError(data.error); setError(data.error);

View File

@ -13,11 +13,11 @@ const rootEl = document.getElementById("root");
if (rootEl) { if (rootEl) {
const root = ReactDOM.createRoot(rootEl); const root = ReactDOM.createRoot(rootEl);
root.render( root.render(
<React.StrictMode> // <React.StrictMode>
<ThemeProvider theme={createTheme()}> <ThemeProvider theme={createTheme()}>
<App /> <App />
</ThemeProvider> </ThemeProvider>
</React.StrictMode> // </React.StrictMode>
); );
} }

View File

@ -6,7 +6,7 @@
"start": "export $(cat ../.env | xargs) && node dist/src/app.js", "start": "export $(cat ../.env | xargs) && node dist/src/app.js",
"start:legacy": "export $(cat ../.env | xargs) && node app.js", "start:legacy": "export $(cat ../.env | xargs) && node app.js",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start:dev": "ts-node-dev --respawn --transpile-only src/app.ts", "start:dev": "ts-node-dev --respawn --transpile-only --watch routes src/app.ts",
"list-games": "ts-node-dev --transpile-only tools/list-games.ts", "list-games": "ts-node-dev --transpile-only tools/list-games.ts",
"import-games": "ts-node-dev --transpile-only tools/import-games-to-db.ts", "import-games": "ts-node-dev --transpile-only tools/import-games-to-db.ts",
"test": "jest", "test": "jest",

View File

@ -46,6 +46,7 @@ import {
adjustResources, adjustResources,
} from "./games/helpers"; } from "./games/helpers";
import type { GameDB } from "./games/store"; import type { GameDB } from "./games/store";
import { transientState } from "./games/sessionState";
let gameDB: GameDB | undefined; let gameDB: GameDB | undefined;
initGameDB() initGameDB()
@ -624,7 +625,7 @@ const processRoll = (game: Game, session: Session, dice: number[]): any => {
// newPlayer is provided by ./games/playerFactory // newPlayer is provided by ./games/playerFactory
const getSession = (game: Game, id: string) => { const getSession = (game: Game, id: string): Session => {
if (!game.sessions) { if (!game.sessions) {
game.sessions = {}; game.sessions = {};
} }
@ -637,7 +638,7 @@ const getSession = (game: Game, id: string) => {
color: "", color: "",
lastActive: Date.now(), lastActive: Date.now(),
live: true, live: true,
} as unknown as Session; };
} }
const session = game.sessions[id]!; const session = game.sessions[id]!;
@ -665,13 +666,11 @@ const getSession = (game: Game, id: string) => {
if (age > 60 * 60 * 1000) { if (age > 60 * 60 * 1000) {
console.log(`${_session.id}: Expiring old session ${_id}: ${age / (60 * 1000)} minutes`); console.log(`${_session.id}: Expiring old session ${_id}: ${age / (60 * 1000)} minutes`);
delete game.sessions[_id]; delete game.sessions[_id];
if (_id in game.sessions) { transientState.clearSession(game.id, _id);
console.log("delete DID NOT WORK!");
}
} }
} }
return game.sessions[id]; return game.sessions[id]!;
}; };
const loadGame = async (id: string) => { const loadGame = async (id: string) => {
@ -680,17 +679,7 @@ const loadGame = async (id: string) => {
} }
if (id in games) { if (id in games) {
// If we have a cached game in memory, ensure any ephemeral flags that
// control per-session lifecycle (like _initialSnapshotSent) are cleared
// so that a newly attached websocket will receive the consolidated
// initial snapshot. This is important for long-running dev servers
// where the in-memory cache may persist between reconnects.
const cached = games[id]!; const cached = games[id]!;
for (let sid in cached.sessions) {
if (cached.sessions[sid] && cached.sessions[sid]._initialSnapshotSent) {
delete cached.sessions[sid]._initialSnapshotSent;
}
}
return cached; return cached;
} }
@ -719,6 +708,16 @@ const loadGame = async (id: string) => {
game = null; game = null;
} }
if (game) {
// After loading, restore transient state
transientState.restoreGame(id, game);
for (let sid in game.sessions) {
if (game.sessions[sid]) {
transientState.restoreSession(id, sid, game.sessions[sid]);
}
}
}
if (!game) { if (!game) {
game = await createGame(id); game = await createGame(id);
// Persist the newly-created game immediately // Persist the newly-created game immediately
@ -751,11 +750,6 @@ const loadGame = async (id: string) => {
} }
session.live = false; session.live = false;
// Ensure we treat initial snapshot as unsent on (re)load so new socket
// attachments will get a fresh 'initial-game' message.
if (session._initialSnapshotSent) {
delete session._initialSnapshotSent;
}
/* Populate the 'unselected' list from the session table */ /* Populate the 'unselected' list from the session table */
if (!game.sessions[id].color && game.sessions[id].name) { if (!game.sessions[id].color && game.sessions[id].name) {
@ -1107,6 +1101,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
Object.assign(session, tmp, { ws: session.ws, id: session.id }); Object.assign(session, tmp, { ws: session.ws, id: session.id });
console.log(`${info}: ${name} has been reallocated to a new session.`); console.log(`${info}: ${name} has been reallocated to a new session.`);
delete game.sessions[id]; delete game.sessions[id];
transientState.clearSession(game.id, id);
} else { } else {
return `${name} is already taken and has been active in the last minute.`; return `${name} is already taken and has been active in the last minute.`;
} }
@ -3346,30 +3341,53 @@ const ping = (session: Session) => {
return; return;
} }
(session as any)["ping"] = Date.now(); session.ping = Date.now();
// console.log(`Sending ping to ${session.name}`); console.log(`${session.id}: Sending ping to ${session.name}`);
try { try {
session.ws.send(JSON.stringify({ type: "ping", ping: (session as any)["ping"] })); session.ws.send(JSON.stringify({ type: "ping", ping: session.ping }));
} catch (e) { } catch (e) {
// ignore send errors console.error(`${session.id}: Failed to send ping:`, e);
// If send fails, the socket is likely dead - clean up
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
return;
} }
// Clear any existing timeout
if (session.keepAlive) { if (session.keepAlive) {
clearTimeout(session.keepAlive); clearTimeout(session.keepAlive);
} }
// Set timeout to disconnect if no pong received within 20 seconds
session.keepAlive = setTimeout(() => { session.keepAlive = setTimeout(() => {
// mark the session as inactive if the keepAlive fires console.warn(`${session.id}: No pong received from ${session.name} within 20s, closing connection`);
try { if (session.ws) {
if (session.ws) { try {
session.ws.close?.(); session.ws.close();
} catch (e) {
console.error(`${session.id}: Error closing socket:`, e);
} }
} catch (e) {
/* ignore */
} }
session.ws = undefined; session.ws = undefined;
session.keepAlive = undefined;
}, 20000); }, 20000);
}; };
// Add new function to schedule recurring pings
const schedulePing = (session: Session) => {
if (session.pingInterval) {
clearInterval(session.pingInterval);
}
// Send ping every 10 seconds
session.pingInterval = setInterval(() => {
ping(session);
}, 10000);
};
// wsInactive not present in this refactor; no-op placeholder removed // wsInactive not present in this refactor; no-op placeholder removed
const setGameState = (game: any, session: any, state: any): string | undefined => { const setGameState = (game: any, session: any, state: any): string | undefined => {
@ -3437,24 +3455,9 @@ const saveGame = async (game: any): Promise<void> => {
for (let id in game.sessions) { for (let id in game.sessions) {
const reduced = Object.assign({}, game.sessions[id]); const reduced = Object.assign({}, game.sessions[id]);
// Remove private or non-serializable fields from the session copy
if (reduced.player) delete reduced.player; // Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth)
if (reduced.ws) delete reduced.ws; transientState.stripSessionTransients(reduced);
if (reduced.keepAlive) delete reduced.keepAlive;
// Remove any internal helper fields (prefixed with '_') and any
// non-primitive values such as functions or timers which may cause
// JSON.stringify to throw due to circular structures.
Object.keys(reduced).forEach((k) => {
if (k.startsWith("_")) {
delete reduced[k];
} else if (typeof reduced[k] === "function") {
delete reduced[k];
}
});
// Do not persist ephemeral test/runtime-only flags
if (reduced._initialSnapshotSent) {
delete reduced._initialSnapshotSent;
}
reducedGame.sessions[id] = reduced; reducedGame.sessions[id] = reduced;
@ -3462,8 +3465,8 @@ const saveGame = async (game: any): Promise<void> => {
reducedSessions.push(reduced); reducedSessions.push(reduced);
} }
delete reducedGame.turnTimer; // Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA)
delete reducedGame.unselected; transientState.stripGameTransients(reducedGame);
/* Save per turn while debugging... */ /* Save per turn while debugging... */
game.step = game.step ? game.step : 0; game.step = game.step ? game.step : 0;
@ -3507,6 +3510,7 @@ const departLobby = (game: any, session: any, _color?: string): void => {
for (let id in game.sessions) { for (let id in game.sessions) {
if (game.sessions[id] === session) { if (game.sessions[id] === session) {
delete game.sessions[id]; delete game.sessions[id];
transientState.clearSession(game.id, id);
break; break;
} }
} }
@ -4099,6 +4103,8 @@ router.ws("/ws/:id", async (ws, req) => {
session.player.live = false; session.player.live = false;
} }
session.live = false; session.live = false;
session.initialSnapshotSent = false;
// Only cleanup the session.ws if it references the same socket object // Only cleanup the session.ws if it references the same socket object
try { try {
console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`); console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`);
@ -4106,6 +4112,18 @@ router.ws("/ws/:id", async (ws, req) => {
`${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}` `${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}`
); );
if (session.ws && session.ws === ws) { if (session.ws && session.ws === ws) {
// Clear ping interval
if (session.pingInterval) {
clearInterval(session.pingInterval);
session.pingInterval = undefined;
}
// Clear keepAlive timeout
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
/* Cleanup any voice channels */ /* Cleanup any voice channels */
if (gameId in audio) { if (gameId in audio) {
try { try {
@ -4153,10 +4171,12 @@ router.ws("/ws/:id", async (ws, req) => {
console.warn(`${short}: error closing session socket during game removal:`, e); console.warn(`${short}: error closing session socket during game removal:`, e);
} }
delete game.sessions[id]; delete game.sessions[id];
transientState.clearSession(game.id, id);
} }
} }
delete audio[gameId]; delete audio[gameId];
delete games[gameId]; delete games[gameId];
transientState.clearGame(gameId);
try { try {
if (!gameDB || !gameDB.deleteGame) { if (!gameDB || !gameDB.deleteGame) {
console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`); console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`);
@ -4230,14 +4250,7 @@ router.ws("/ws/:id", async (ws, req) => {
// websocket was just replaced (reconnect), send an initial consolidated // websocket was just replaced (reconnect), send an initial consolidated
// snapshot so clients can render deterministically without needing to // snapshot so clients can render deterministically without needing to
// wait for a flurry of incremental game-update events. // wait for a flurry of incremental game-update events.
if (!session._initialSnapshotSent) { sendInitialGameSnapshot(game, session);
try {
sendInitialGameSnapshot(game, session);
session._initialSnapshotSent = true;
} catch (e) {
console.error(`${session.id}: error sending initial snapshot`, e);
}
}
switch (incoming.type) { switch (incoming.type) {
case "join": case "join":
@ -4266,7 +4279,30 @@ router.ws("/ws/:id", async (ws, req) => {
break; break;
case "pong": case "pong":
resetDisconnectCheck(game, req); console.log(`${short}: Received pong from ${getName(session)}`);
// Clear the keepAlive timeout since we got a response
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
// Calculate latency if ping timestamp was sent
if (session.ping) {
session.lastPong = Date.now();
const latency = session.lastPong - session.ping;
// Only accept latency values that are within a reasonable window
// (e.g. 0 - 60s). Ignore stale or absurdly large stored ping
// timestamps which can occur if session state was persisted or
// restored with an old ping value.
if (latency >= 0 && latency < 60000) {
console.log(`${short}: Latency: ${latency}ms`);
} else {
console.warn(`${short}: Ignoring stale ping value; computed latency ${latency}ms`);
}
}
// No need to resetDisconnectCheck since it's non-functional
break; break;
case "game-update": case "game-update":
@ -4675,14 +4711,7 @@ router.ws("/ws/:id", async (ws, req) => {
// Ensure we only attempt to send the consolidated initial snapshot once // Ensure we only attempt to send the consolidated initial snapshot once
// per session lifecycle. Tests and clients expect a single 'initial-game' // per session lifecycle. Tests and clients expect a single 'initial-game'
// message when a socket first attaches. // message when a socket first attaches.
if (!session._initialSnapshotSent) { sendInitialGameSnapshot(game, session);
try {
sendInitialGameSnapshot(game, session);
session._initialSnapshotSent = true;
} catch (e) {
console.error(`${session.id}: error sending initial snapshot on connect`, e);
}
}
if (session.name) { if (session.name) {
sendUpdateToPlayers(game, { sendUpdateToPlayers(game, {
players: getFilteredPlayers(game), players: getFilteredPlayers(game),
@ -4708,16 +4737,9 @@ router.ws("/ws/:id", async (ws, req) => {
resetDisconnectCheck(game, req); resetDisconnectCheck(game, req);
console.log(`${short}: Game ${id} - WebSocket connect from ${getName(session)}`); console.log(`${short}: Game ${id} - WebSocket connect from ${getName(session)}`);
/* Send initial ping to initiate communication with client */ /* Start recurring ping mechanism */
if (!session.keepAlive) { console.log(`${short}: Starting ping interval for ${getName(session)}`);
console.log(`${short}: Sending initial ping`); schedulePing(session);
ping(session);
} else {
clearTimeout(session.keepAlive);
session.keepAlive = setTimeout(() => {
ping(session);
}, 2500);
}
}); });
const debugChat = (game: any, preamble: any) => { const debugChat = (game: any, preamble: any) => {
@ -4837,7 +4859,11 @@ const getFilteredGameForPlayer = (game: any, session: any) => {
* game state deterministically on first attach instead of having * game state deterministically on first attach instead of having
* to wait for a flurry of incremental game-update events. * to wait for a flurry of incremental game-update events.
*/ */
const sendInitialGameSnapshot = (game: any, session: any) => { const sendInitialGameSnapshot = (game: Game, session: Session) => {
if (session.initialSnapshotSent) {
return;
}
try { try {
const snapshot = getFilteredGameForPlayer(game, session); const snapshot = getFilteredGameForPlayer(game, session);
const message = JSON.stringify({ type: "initial-game", snapshot }); const message = JSON.stringify({ type: "initial-game", snapshot });
@ -4853,6 +4879,7 @@ const sendInitialGameSnapshot = (game: any, session: any) => {
} }
if (session && session.ws && session.ws.send) { if (session && session.ws && session.ws.send) {
session.ws.send(message); session.ws.send(message);
session.initialSnapshotSent = true;
} else { } else {
console.warn(`${session.id}: Unable to send initial snapshot - no websocket available`); console.warn(`${session.id}: Unable to send initial snapshot - no websocket available`);
} }
@ -5247,7 +5274,7 @@ router.get("/", (req, res /*, next*/) => {
player: playerId, player: playerId,
name: null, name: null,
lobbies: [], lobbies: [],
has_media: true // Default to true for regular users has_media: true, // Default to true for regular users
}); });
}); });

View File

@ -0,0 +1,107 @@
// server/routes/games/sessionState.ts
import {
TransientGameState,
TransientSessionState,
TRANSIENT_SESSION_KEYS,
TRANSIENT_GAME_KEYS
} from "./transientSchema";
class TransientStateManager {
private sessions = new Map<string, TransientSessionState>();
private games = new Map<string, TransientGameState>();
// Session transient state
preserveSession(gameId: string, sessionId: string, session: any): void {
const key = `${gameId}:${sessionId}`;
const transient: any = {};
// Automatically preserve all transient fields from schema
TRANSIENT_SESSION_KEYS.forEach(k => {
if (k in session) {
transient[k] = session[k];
}
});
this.sessions.set(key, transient);
}
restoreSession(gameId: string, sessionId: string, session: any): void {
const key = `${gameId}:${sessionId}`;
const transient = this.sessions.get(key);
if (transient) {
Object.assign(session, transient);
// Don't delete - keep for future loads
}
}
clearSession(gameId: string, sessionId: string): void {
const key = `${gameId}:${sessionId}`;
const transient = this.sessions.get(key);
if (transient) {
// Clean up timers
if (transient.keepAlive) clearTimeout(transient.keepAlive);
if (transient.pingInterval) clearTimeout(transient.pingInterval);
if (transient._getBatch?.timer) clearTimeout(transient._getBatch.timer);
if (transient._pendingTimeout) clearTimeout(transient._pendingTimeout);
}
this.sessions.delete(key);
}
// Game transient state
preserveGame(gameId: string, game: any): void {
const transient: any = {};
// Automatically preserve all transient fields from schema
TRANSIENT_GAME_KEYS.forEach(k => {
if (k in game) {
transient[k] = game[k];
}
});
this.games.set(gameId, transient);
}
restoreGame(gameId: string, game: any): void {
const transient = this.games.get(gameId);
if (transient) {
Object.assign(game, transient);
}
}
clearGame(gameId: string): void {
const transient = this.games.get(gameId);
if (transient?.turnTimer) {
clearTimeout(transient.turnTimer);
}
this.games.delete(gameId);
}
/**
* Remove all transient fields from a session object (for serialization)
* Automatically uses all keys from TRANSIENT_SESSION_SCHEMA
*/
stripSessionTransients(session: any): void {
// Remove all transient fields automatically
TRANSIENT_SESSION_KEYS.forEach(key => delete session[key]);
// Remove player reference (runtime only)
delete session.player;
// Catch-all: remove any underscore-prefixed fields and functions
Object.keys(session).forEach((k) => {
if (k.startsWith("_")) delete session[k];
else if (typeof session[k] === "function") delete session[k];
});
}
/**
* Remove all transient fields from a game object (for serialization)
* Automatically uses all keys from TRANSIENT_GAME_SCHEMA
*/
stripGameTransients(game: any): void {
TRANSIENT_GAME_KEYS.forEach(key => delete game[key]);
}
}
export const transientState = new TransientStateManager();

View File

@ -0,0 +1,52 @@
/**
* Transient State Schemas - SINGLE SOURCE OF TRUTH
*
* Define transient fields here ONCE. Both TypeScript types and runtime operations
* derive from these schemas, ensuring DRY compliance.
*
* To add a new transient field:
* 1. Add it to the appropriate schema below
* 2. That's it! All preserve/restore/strip operations automatically include it
*/
/**
* Transient Session Fields Schema
* These fields are never persisted to the database
*/
export const TRANSIENT_SESSION_SCHEMA = {
ws: undefined as any,
keepAlive: undefined as NodeJS.Timeout | undefined,
pingInterval: undefined as NodeJS.Timeout | undefined,
lastPong: undefined as number | undefined,
initialSnapshotSent: undefined as boolean | undefined,
_getBatch: undefined as { fields: Set<string>; timer?: any } | undefined,
_pendingMessage: undefined as any,
_pendingTimeout: undefined as any,
live: false as boolean,
hasAudio: undefined as boolean | undefined,
audio: undefined as any,
video: undefined as any,
ping: undefined as number | undefined,
};
/**
* Transient Game Fields Schema
* These fields are never persisted to the database
*/
export const TRANSIENT_GAME_SCHEMA = {
turnTimer: undefined as any,
unselected: undefined as any[] | undefined,
};
// Derive runtime key arrays from schemas
export const TRANSIENT_SESSION_KEYS = Object.keys(TRANSIENT_SESSION_SCHEMA) as (keyof typeof TRANSIENT_SESSION_SCHEMA)[];
export const TRANSIENT_GAME_KEYS = Object.keys(TRANSIENT_GAME_SCHEMA) as (keyof typeof TRANSIENT_GAME_SCHEMA)[];
// Export TypeScript types derived from schemas
export type TransientSessionState = {
[K in keyof typeof TRANSIENT_SESSION_SCHEMA]?: typeof TRANSIENT_SESSION_SCHEMA[K];
};
export type TransientGameState = {
[K in keyof typeof TRANSIENT_GAME_SCHEMA]?: typeof TRANSIENT_GAME_SCHEMA[K];
};

View File

@ -2,6 +2,11 @@ export type ResourceKey = "wood" | "brick" | "sheep" | "wheat" | "stone";
export type ResourceMap = Partial<Record<ResourceKey, number>>; export type ResourceMap = Partial<Record<ResourceKey, number>>;
export interface TransientGameState {
turnTimer?: any;
unselected?: any[];
}
export interface Player { export interface Player {
name?: string; name?: string;
color?: string; color?: string;
@ -75,29 +80,29 @@ export interface DevelopmentCard {
[key: string]: any; [key: string]: any;
} }
export interface Session { // Import from schema for DRY compliance
import { TransientSessionState } from './transientSchema';
/**
* Persistent Session data (saved to DB)
*/
export interface PersistentSessionData {
id: string; id: string;
name: string;
color: string;
lastActive: number;
userId?: number; userId?: number;
name?: string;
color?: string;
ws?: any; // WebSocket instance; keep as any to avoid dependency on ws types
player?: Player; player?: Player;
live?: boolean;
lastActive?: number;
keepAlive?: any;
connected?: boolean; connected?: boolean;
hasAudio?: boolean;
audio?: any;
video?: any;
ping?: number;
_initialSnapshotSent?: boolean;
_getBatch?: { fields: Set<string>; timer?: any };
_pendingMessage?: any;
_pendingTimeout?: any;
resources?: number; resources?: number;
[key: string]: any;
} }
/**
* Runtime Session type = Persistent + Transient
* At runtime, sessions have both persistent and transient fields
*/
export type Session = PersistentSessionData & TransientSessionState;
export interface OfferItem { export interface OfferItem {
type: string; // 'bank' or resource key or other type: string; // 'bank' or resource key or other
count: number; count: number;