From 0818145a819d16d0a106f9c6c0605b3de8973674 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Fri, 10 Oct 2025 16:55:20 -0700 Subject: [PATCH] Lots of refactoring --- .gitignore | 1 + client/src/App.tsx | 6 +- client/src/HouseRules.tsx | 21 +- client/src/MediaControl.tsx | 16 - client/src/PlayerList.tsx | 2 +- server/routes/games.ts | 1008 ++-------------------------- server/routes/games/gameFactory.ts | 304 +++++++++ server/routes/games/helpers.ts | 363 +++++++++- server/routes/games/robber.ts | 17 + server/routes/games/rules.ts | 139 ++++ server/routes/games/serialize.ts | 19 +- server/routes/games/state.ts | 34 - server/routes/games/store.ts | 353 +++++----- server/routes/games/types.ts | 18 +- server/routes/webrtc-signaling.ts | 22 +- server/src/app.ts | 44 +- server/tools/import-games-to-db.ts | 72 +- server/tools/list-games.ts | 51 +- server/util/layout.ts | 1 + server/util/validLocations.ts | 57 +- 20 files changed, 1234 insertions(+), 1314 deletions(-) create mode 100644 server/routes/games/gameFactory.ts create mode 100644 server/routes/games/robber.ts create mode 100644 server/routes/games/rules.ts delete mode 100644 server/routes/games/state.ts diff --git a/.gitignore b/.gitignore index d80695c..7a34e62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +original/ test-output/ certs/ **/node_modules/ diff --git a/client/src/App.tsx b/client/src/App.tsx index 91b1d2a..990d4af 100755 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -53,7 +53,7 @@ const App = () => { setTimeout(() => { setError(null); }, 5000); - console.error(`App - error`, error); + console.error(`app - error`, error); } }, [error]); @@ -61,13 +61,13 @@ const App = () => { if (!session) { return; } - console.log(`App - sessionId`, session.id); + console.log(`app - sessionId`, session.id); }, [session]); const getSession = useCallback(async () => { try { const session = await sessionApi.getCurrent(); - console.log(`App - got sessionId`, session.id); + console.log(`app - got sessionId`, session.id); setSession(session); setSessionRetryAttempt(0); } catch (err) { diff --git a/client/src/HouseRules.tsx b/client/src/HouseRules.tsx index 596db0a..b301712 100644 --- a/client/src/HouseRules.tsx +++ b/client/src/HouseRules.tsx @@ -351,10 +351,8 @@ const HouseRules: React.FC = ({ houseRulesActive, setHouseRules @@ -364,6 +362,7 @@ const HouseRules: React.FC = ({ houseRulesActive, setHouseRules fields. Picture yourself snagging this beautifully illustrated card—featuring hardworking villagers and a majestic castle! + ), }, @@ -378,10 +377,8 @@ const HouseRules: React.FC = ({ houseRulesActive, setHouseRules @@ -392,6 +389,7 @@ const HouseRules: React.FC = ({ houseRulesActive, setHouseRules moment someone else builds a larger network of harbors, they’ll steal both the card and the glory right from under your nose. Keep those ships moving and never let your rivals toast to your downfall! + ), }, @@ -406,10 +404,8 @@ const HouseRules: React.FC = ({ houseRulesActive, setHouseRules @@ -418,6 +414,7 @@ const HouseRules: React.FC = ({ houseRulesActive, setHouseRules handed this charming card—featuring industrious villagers raking hay with a castle looming in the background—until someone even slower takes it from you with a sheepish grin! + ), }, diff --git a/client/src/MediaControl.tsx b/client/src/MediaControl.tsx index 1c64162..f323e71 100644 --- a/client/src/MediaControl.tsx +++ b/client/src/MediaControl.tsx @@ -1518,22 +1518,6 @@ const MediaControl: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]); - // Debug target element - useEffect(() => { - console.log("Target ref current:", targetRef.current, "for peer:", peer?.session_id); - if (targetRef.current) { - console.log("Target element rect:", targetRef.current.getBoundingClientRect()); - console.log("Target element computed style:", { - position: getComputedStyle(targetRef.current).position, - left: getComputedStyle(targetRef.current).left, - top: getComputedStyle(targetRef.current).top, - transform: getComputedStyle(targetRef.current).transform, - width: getComputedStyle(targetRef.current).width, - height: getComputedStyle(targetRef.current).height, - }); - } - }, [peer?.session_id]); - const toggleMute = useCallback( (e: React.MouseEvent | React.TouchEvent) => { e.stopPropagation(); diff --git a/client/src/PlayerList.tsx b/client/src/PlayerList.tsx index 16efdcf..59d5820 100644 --- a/client/src/PlayerList.tsx +++ b/client/src/PlayerList.tsx @@ -121,7 +121,7 @@ const PlayerList: React.FC = () => { break; } default: - console.log(`player-list - ignoring message: ${data.type}`); + // console.log(`player-list - ignoring message: ${data.type}`); break; } }, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]); diff --git a/server/routes/games.ts b/server/routes/games.ts index 27c13f1..3e0b36f 100755 --- a/server/routes/games.ts +++ b/server/routes/games.ts @@ -1,19 +1,8 @@ import express from "express"; import crypto from "crypto"; -import randomWords from "random-words"; -import equal from "fast-deep-equal"; import { layout, staticData } from "../util/layout"; import basePath from "../basepath"; -import { - MAX_SETTLEMENTS, - MAX_CITIES, - types, - debug, - all, - info, - SEND_THROTTLE_MS, - INCOMING_GET_BATCH_MS, -} from "./games/constants"; +import { types, debug, all, info, INCOMING_GET_BATCH_MS } from "./games/constants"; import { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLocations"; import { @@ -29,23 +18,18 @@ import { PLAYER_COLORS, RESOURCE_TYPES, } from "./games/types"; -import { newPlayer } from "./games/playerFactory"; -import { normalizeIncoming, shuffleArray } from "./games/utils"; +import { normalizeIncoming } from "./games/utils"; import { - audio as audioMap, + audio, join as webrtcJoin, part as webrtcPart, handleRelayICECandidate, handleRelaySessionDescription, broadcastPeerStateUpdate, } from "./webrtc-signaling"; -// import type { GameState } from './games/state'; // unused import removed during typing pass const router = express.Router(); -// normalizeIncoming imported from './games/utils' - -import { initGameDB } from "./games/store"; import { addActivity, addChatMessage, @@ -56,25 +40,20 @@ import { setForCityPlacement, setForSettlementPlacement, adjustResources, + sendUpdateToPlayers, + getFilteredPlayers, + sendUpdateToPlayer, + startTurnTimer, + getName, + queueSend, + shuffle, + resetTurnTimer, } from "./games/helpers"; -import type { GameDB } from "./games/store"; +import { gameDB, games } from "./games/store"; import { transientState } from "./games/sessionState"; - -let gameDB: GameDB | undefined; -initGameDB() - .then((db) => { - gameDB = db; - }) - .catch((e) => { - console.error("Failed to initialize game DB", e); - }); - -// shuffleArray imported from './games/utils.ts' - -const games: Record = {}; - -// Re-exported audio map from webrtc-signaling for in-file use -const audio = audioMap; +import { createGame, resetGame, setBeginnerGame } from "./games/gameFactory"; +import { getVictoryPointRule, setRules, supportedRules } from "./games/rules"; +import { pickRobber } from "./games/robber"; const processTies = (players: Player[]): boolean => { /* Sort the players into buckets based on their @@ -197,7 +176,7 @@ const processGameOrder = (game: Game, player: Player, dice: number): string | un `Player order set to ` + players.map((player) => `${player.position}: ${player.name}`).join(", ") + `.` ); - game.playerOrder = players.map((player) => player.color as string); + game.playerOrder = players.map((player) => player.color); game.state = "initial-placement"; game.direction = "forward"; const first = players[0]; @@ -205,7 +184,7 @@ const processGameOrder = (game: Game, player: Player, dice: number): string | un name: first?.name as string, color: first?.color as PlayerColor, }; - setForSettlementPlacement(game, getValidCorners(game, "")); + setForSettlementPlacement(game, getValidCorners(game)); addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); addChatMessage(game, null, `Initial settlement placement has started!`); addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`); @@ -381,7 +360,7 @@ const distributeResources = (game: Game, roll: number): void => { /* Find which corners are on each tile */ matchedTiles.forEach((tile: Tile) => { const tileOrder = game.tileOrder; - const gameTiles = game.tiles; + const gameTiles = staticData.tiles; if (tile.index >= tileOrder.length) { return; } @@ -455,21 +434,6 @@ const distributeResources = (game: Game, roll: number): void => { } }; -const pickRobber = (game: Game): void => { - const selection = Math.floor(Math.random() * 3); - switch (selection) { - case 0: - game.robberName = "Robert"; - break; - case 1: - game.robberName = "Roberta"; - break; - case 2: - game.robberName = "Velocirobber"; - break; - } -}; - const processRoll = (game: Game, session: Session, dice: number[]): any => { if (!dice[1]) { console.error(`Invalid roll sequence!`); @@ -648,7 +612,7 @@ const processRoll = (game: Game, session: Session, dice: number[]): any => { addChatMessage(game, null, `A new robber has arrived and must be placed by ${game.turn.name}.`); game.turn.actions = ["place-robber"]; game.turn.limits = { pips: [] }; - for (let i = 0; i < 19; i++) { + for (let i = 0; i < staticData.tiles.length; i++) { if (i === game.robber) { continue; } @@ -744,24 +708,7 @@ const loadGame = async (id: string): Promise => { return cached; } - // Load game from the configured game DB. In DB-only mode a missing DB or - // missing game is considered an error; we still allow creating a new game - // when one doesn't exist. - // Ensure the gameDB is initialized (handle startup race where init may - // still be in progress). If initialization fails, surface a clear error. - if (!gameDB) { - try { - gameDB = await initGameDB(); - } catch (e) { - throw new Error("Game DB is not available; persistence is required in DB-only mode"); - } - } - - if (!gameDB.getGameById) { - throw new Error("Game DB does not expose getGameById; persistence is required"); - } - - let game: any = null; + let game: Game | null = null; try { game = await gameDB.getGameById(id); } catch (e) { @@ -780,10 +727,13 @@ const loadGame = async (id: string): Promise => { } if (!game) { - game = await createGame(id); + // If an id was requested, create the new game using that id + // so clients that connect to /ws/:id or POST /:id will get a game + // with the expected slug instead of a random one. + game = await createGame(id || null); // Persist the newly-created game immediately try { - await gameDB.saveGameState(game.id, game); + await gameDB.saveGame(game); } catch (e) { console.error(`${info}: Failed to persist newly created game ${game.id}`, e); } @@ -792,29 +742,29 @@ const loadGame = async (id: string): Promise => { /* Clear out cached names from player colors and rebuild them * from the information in the saved game sessions */ for (let color in game.players) { - delete game.players[color].name; - game.players[color].status = "Not active"; + game.players[color]!.name = ""; + game.players[color]!.status = "Not active"; } /* Reconnect session player colors to the player objects */ game.unselected = []; for (let id in game.sessions) { - const session = game.sessions[id]; + const session = game.sessions[id]!; if (session.name && session.color && session.color !== "unassigned" && session.color in game.players) { - session.player = game.players[session.color]; + session.player = game.players[session.color]!; session.player.name = session.name; session.player.status = "Active"; session.player.live = false; } else { - session.color = ""; - session.player = undefined; + session.color = "unassigned"; + delete session.player; } session.live = false; /* Populate the 'unselected' list from the session table */ - if ((!game.sessions[id].color || game.sessions[id].color === "unassigned") && game.sessions[id].name) { - game.unselected.push(game.sessions[id]); + if ((!session.color || session.color === "unassigned") && session.name) { + game.unselected.push(session); } } @@ -825,11 +775,11 @@ const loadGame = async (id: string): Promise => { (!game.turn.limits || Object.keys(game.turn.limits).length === 0) ) { console.log(`${info}: Reconstructing turn.limits for initial-placement state after reload`); - const currentColor = game.turn.color || ""; + const currentColor = game.turn.color; // Check if we need to place a settlement (no action or place-settlement action) if (!game.turn.actions || game.turn.actions.length === 0 || game.turn.actions.indexOf("place-settlement") !== -1) { - setForSettlementPlacement(game, getValidCorners(game, currentColor ? currentColor : "")); + setForSettlementPlacement(game, getValidCorners(game, currentColor)); console.log( `${info}: Set turn limits for settlement placement (${game.turn.limits?.corners?.length || 0} valid corners)` ); @@ -1871,25 +1821,6 @@ const canMeetOffer = (player: Player, offer: Offer): boolean => { return true; }; -const gameSignature = (game: Game): string => { - if (!game) { - return ""; - } - const salt = 251; - const signature = - (game.borderOrder || []).map((border: any) => `00${(Number(border) ^ salt).toString(16)}`.slice(-2)).join("") + - "-" + - (game.pipOrder || []) - .map((pip: any, index: number) => `00${(Number(pip) ^ salt ^ (salt * index)).toString(16)}`.slice(-2)) - .join("") + - "-" + - (game.tileOrder || []) - .map((tile: any, index: number) => `00${(Number(tile) ^ salt ^ (salt * index)).toString(16)}`.slice(-2)) - .join(""); - - return signature; -}; - const setGameFromSignature = (game: Game, border: string, pip: string, tile: string): boolean => { const salt = 251; const borders = [], @@ -1903,19 +1834,22 @@ const setGameFromSignature = (game: Game, border: string, pip: string, tile: str return false; } } - for (let i = 0; i < 19; i++) { + for (let i = 0; i < staticData.tiles.length; i++) { const parsed = parseInt(pip.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(parsed)) return false; - pips[i] = parsed ^ salt ^ (salt * i) % 256; - if (pips[i]! > 18) { + // Ensure we decode to a single byte explicitly. Parenthesize to avoid + // surprises from operator precedence (% vs ^) and mask to 0..255. + pips[i] = (parsed ^ salt ^ (salt * i)) & 0xff; + if (pips[i]! > staticData.tiles.length - 1) { return false; } } - for (let i = 0; i < 19; i++) { + for (let i = 0; i < staticData.tiles.length; i++) { const parsed = parseInt(tile.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(parsed)) return false; - tiles[i] = parsed ^ salt ^ (salt * i) % 256; - if (tiles[i]! > 18) { + // As above, decode unambiguously into a single byte value. + tiles[i] = (parsed ^ salt ^ (salt * i)) & 0xff; + if (tiles[i]! > staticData.tiles.length - 1) { return false; } } @@ -2213,71 +2147,6 @@ const clearTimeNotice = (game: Game, session: Session): string | undefined => { return undefined; }; -const startTurnTimer = (game: Game, session: Session) => { - const timeout = 90; - if (!session.ws) { - console.log(`${session.short}: Aborting turn timer as ${session.name} is disconnected.`); - } else { - console.log(`${session.short}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`); - } - if (game.turnTimer) { - clearTimeout(game.turnTimer); - } - if (!session.connected) { - game.turnTimer = 0; - return; - } - game.turnTimer = setTimeout(() => { - console.log(`${session.short}: Turn timer expired for ${session.name}`); - if (session.player) { - session.player.turnNotice = "It is still your turn."; - } - sendUpdateToPlayer(game, session, { - private: session.player, - }); - resetTurnTimer(game, session); - }, timeout * 1000); -}; - -const resetTurnTimer = (game: Game, session: Session): void => { - startTurnTimer(game, session); -}; - -const stopTurnTimer = (game: Game): void => { - if (game.turnTimer) { - console.log(`${info}: Stopping turn timer.`); - try { - clearTimeout(game.turnTimer); - } catch (e) { - /* ignore if not a real timeout */ - } - game.turnTimer = 0; - } - return undefined; -}; - -const shuffle = (game: any, session: any): string | undefined => { - if (game.state !== "lobby") { - return `Game no longer in lobby (${game.state}). Can not shuffle board.`; - } - if (game.turns > 0) { - return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`; - } - shuffleBoard(game); - console.log(`${session.short}: Shuffled to new signature: ${game.signature}`); - - sendUpdateToPlayers(game, { - pipOrder: game.pipOrder, - tileOrder: game.tileOrder, - borderOrder: game.borderOrder, - robber: game.robber, - robberName: game.robberName, - signature: game.signature, - animationSeeds: game.animationSeeds, - }); - return undefined; -}; - const pass = (game: any, session: any): string | undefined => { const name = session.name; if (game.turn.name !== name) { @@ -2670,7 +2539,7 @@ const playCard = (game: Game, session: Session, card: any): string | undefined = ); game.turn.actions = ["place-robber", "playing-knight"]; game.turn.limits = { pips: [] } as any; - for (let i = 0; i < 19; i++) { + for (let i = 0; i < staticData.tiles.length; i++) { if (i === game.robber) continue; (game.turn.limits as any).pips.push(i); } @@ -2909,7 +2778,7 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi * player and when the first player finishes, initial placement is done * and normal play begins. */ if (game.state === "initial-placement") { - const order: string[] = game.playerOrder || []; + const order: PlayerColor[] = game.playerOrder; const idx = order.indexOf(session.color); // defensive: if player not found, just clear actions and continue if (idx === -1 || order.length === 0) { @@ -2924,7 +2793,7 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi game.direction = "backward"; const nextColor = order[order.length - 1]; if (nextColor && game.players && game.players[nextColor]) { - const limits = getValidCorners(game, ""); + const limits = getValidCorners(game); console.log( `${info}: initial-placement - ${ session.name @@ -2951,7 +2820,7 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi name: game.players[nextColor].name, color: nextColor, } as unknown as Turn; - setForSettlementPlacement(game, getValidCorners(game, nextColor as string)); + setForSettlementPlacement(game, getValidCorners(game, nextColor)); addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`); } } @@ -2971,7 +2840,7 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi } else { const nextColor = order[idx - 1]; if (nextColor && game.players && game.players[nextColor]) { - const limits = getValidCorners(game, ""); + const limits = getValidCorners(game); console.log( `${info}: initial-placement - ${ session.name @@ -3011,142 +2880,6 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi }); return undefined; }; - -const getVictoryPointRule = (game: any): number => { - const minVP = 10; - if (!isRuleEnabled(game, "victory-points") || !("points" in game.rules["victory-points"])) { - return minVP; - } - return game.rules["victory-points"].points; -}; -const supportedRules: Record string | void | undefined> = { - "victory-points": (game: any, session: any, rule: any, rules: any) => { - if (!("points" in rules[rule])) { - return `No points specified for victory-points`; - } - if (!rules[rule].enabled) { - addChatMessage(game, null, `${getName(session)} has disabled the Victory Point ` + `house rule.`); - } else { - addChatMessage(game, null, `${getName(session)} set the minimum Victory Points to ` + `${rules[rule].points}`); - } - return undefined; - }, - "roll-double-roll-again": (game: any, session: any, rule: any, rules: any) => { - addChatMessage( - game, - null, - `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Roll Double, Roll Again house rule.` - ); - return undefined; - }, - volcano: (game: any, session: any, rule: any, rules: any) => { - if (!rules[rule].enabled) { - addChatMessage(game, null, `${getName(session)} has disabled the Volcano ` + `house rule.`); - } else { - if (!(rule in game.rules) || !game.rules[rule].enabled) { - addChatMessage( - game, - null, - `${getName(session)} enabled the Volcano ` + - `house rule with roll set to ` + - `${rules[rule].number} and 'Volanoes have gold' mode ` + - `${rules[rule].gold ? "en" : "dis"}abled.` - ); - } else { - if (game.rules[rule].number !== rules[rule].number) { - addChatMessage(game, null, `${getName(session)} set the Volcano roll to ` + `${rules[rule].number}`); - } - - if (game.rules[rule].gold !== rules[rule].gold) { - addChatMessage( - game, - null, - `${getName(session)} has ` + `${rules[rule].gold ? "en" : "dis"}abled the ` + `'Volcanoes have gold' mode.` - ); - } - } - } - }, - - "twelve-and-two-are-synonyms": (game: any, session: any, rule: any, rules: any) => { - addChatMessage( - game, - null, - `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Twelve and Two are Synonyms house rule.` - ); - game.rules[rule] = rules[rule]; - }, - "most-developed": (game: any, session: any, rule: any, rules: any) => { - addChatMessage( - game, - null, - `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Most Developed house rule.` - ); - }, - "port-of-call": (game: any, session: any, rule: any, rules: any) => { - addChatMessage( - game, - null, - `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Another Round of Port house rule.` - ); - }, - "slowest-turn": (game: any, session: any, rule: any, rules: any) => { - addChatMessage( - game, - null, - `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Slowest Turn house rule.` - ); - }, - "tiles-start-facing-down": (game: any, session: any, rule: any, rules: any) => { - addChatMessage( - game, - null, - `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Tiles Start Facing Down house rule.` - ); - if (rules[rule].enabled) { - shuffle(game, session); - } - }, - "robin-hood-robber": (game: any, session: any, rule: any, rules: any) => { - addChatMessage( - game, - null, - `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Robin Hood Robber house rule.` - ); - }, -}; - -const setRules = (game: any, session: any, rules: any): string | undefined => { - if (game.state !== "lobby") { - return `You can not modify House Rules once the game has started.`; - } - - for (let rule in rules) { - if (equal(game.rules[rule], rules[rule])) { - continue; - } - - if (rule in supportedRules) { - const handler = supportedRules[rule]; - if (handler) { - const warning = handler(game, session, rule, rules); - if (warning) { - return warning; - } - } - game.rules[rule] = rules[rule]; - } else { - return `Rule ${rule} not recognized.`; - } - } - - sendUpdateToPlayers(game, { - rules: game.rules, - chat: game.chat, - }); - return undefined; -}; - const discard = (game: any, session: any, discards: Record): string | undefined => { const player = session.player; @@ -3197,7 +2930,7 @@ const discard = (game: any, session: any, discards: Record): string addChatMessage(game, null, `Drat! A new robber has arrived and must be placed by ${game.turn.name}!`); game.turn.actions = ["place-robber"]; game.turn.limits = { pips: [] }; - for (let i = 0; i < 19; i++) { + for (let i = 0; i < staticData.tiles.length; i++) { if (i === game.robber) { continue; } @@ -3544,7 +3277,7 @@ const ping = (session: Session) => { } session.ping = Date.now(); - console.log(`${session.short}: Sending ping to ${session.name}`); + // console.log(`${session.short}: Sending ping to ${session.name}`); try { session.ws.send(JSON.stringify({ type: "ping", ping: session.ping })); @@ -3611,7 +3344,7 @@ const schedulePing = (session: Session) => { // wsInactive not present in this refactor; no-op placeholder removed -const setGameState = (game: any, session: any, state: any): string | undefined => { +const setGame = (game: any, session: any, state: any): string | undefined => { if (!state) { return `Invalid state.`; } @@ -3664,52 +3397,6 @@ const resetDisconnectCheck = (_game: any, req: any): void => { // WebRTC join/part handling moved to server/routes/webrtc-signaling.ts // use webrtcJoin(audio[gameId], session, config) and webrtcPart(audio[gameId], session) -const getName = (session: any): string => { - return session ? (session.name ? session.name : session.id) : "Admin"; -}; - -const saveGame = async (game: any): Promise => { - /* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then - * delete the player field from them */ - const reducedGame = Object.assign({}, game, { sessions: {} }), - reducedSessions = []; - - for (let id in game.sessions) { - const reduced = Object.assign({}, game.sessions[id]); - - // Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth) - transientState.stripSessionTransients(reduced); - - reducedGame.sessions[id] = reduced; - - /* Do not send session-id as those are secrets */ - reducedSessions.push(reduced); - } - - // Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA) - transientState.stripGameTransients(reducedGame); - - /* Save per turn while debugging... */ - game.step = game.step ? game.step : 0; - /* - await writeFile(`/db/games/${game.id}.${game.step++}`, JSON.stringify(reducedGame, null, 2)) - .catch((error) => { - console.error(`${session.id} Unable to write to /db/games/${game.id}`); - console.error(error); - }); - */ - if (!gameDB || !gameDB.saveGameState) { - console.error(`${info}: gameDB.saveGameState is not available; cannot persist game ${game.id}`); - return; - } - - try { - await gameDB.saveGameState(game.id, reducedGame); - } catch (e) { - console.error(`${info}: gameDB.saveGameState failed for ${game.id}`, e); - } -}; - const departLobby = (game: any, session: any, _color?: string): void => { const update: any = {}; update.unselected = getFilteredUnselected(game); @@ -3740,89 +3427,6 @@ const departLobby = (game: any, session: any, _color?: string): void => { sendUpdateToPlayers(game, update); }; -const queueSend = (session: any, message: any): void => { - if (!session || !session.ws) return; - try { - // Ensure we compare a stable serialization: if message is JSON text, - // parse it and re-serialize with sorted keys so semantically-equal - // objects compare equal even when property order differs. - const stableStringify = (msg: any): string => { - try { - const obj = typeof msg === "string" ? JSON.parse(msg) : msg; - const ordered = (v: any): any => { - if (v === null || typeof v !== "object") return v; - if (Array.isArray(v)) return v.map(ordered); - const keys = Object.keys(v).sort(); - const out: any = {}; - for (const k of keys) out[k] = ordered(v[k]); - return out; - }; - return JSON.stringify(ordered(obj)); - } catch (e) { - // If parsing fails, fall back to original string representation - return typeof msg === "string" ? msg : JSON.stringify(msg); - } - }; - const stableMessage = stableStringify(message); - const now = Date.now(); - if (!session._lastSent) session._lastSent = 0; - const elapsed = now - session._lastSent; - // If the exact same message (in stable form) was sent last time and - // nothing is pending, skip sending to avoid pointless duplicate - // traffic. - if (!session._pendingTimeout && session._lastMessage === stableMessage) { - return; - } - - // If we haven't sent recently and there's no pending timer, send now - if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) { - try { - session.ws.send(typeof message === "string" ? message : JSON.stringify(message)); - session._lastSent = Date.now(); - session._lastMessage = stableMessage; - } catch (e) { - console.warn(`${session.id}: queueSend immediate send failed:`, e); - } - return; - } - - // Otherwise, store latest message and schedule a send - // If the pending message would equal the last-sent message, don't bother - // storing/scheduling it. - if (session._lastMessage === stableMessage) { - return; - } - session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message); - if (session._pendingTimeout) { - // already scheduled; newest message will be sent when timer fires - return; - } - const delay = Math.max(1, SEND_THROTTLE_MS - elapsed); - session._pendingTimeout = setTimeout(() => { - try { - if (session.ws && session._pendingMessage) { - session.ws.send(session._pendingMessage); - session._lastSent = Date.now(); - // compute stable form of what we actually sent - try { - session._lastMessage = stableStringify(session._pendingMessage); - } catch (e) { - session._lastMessage = session._pendingMessage; - } - } - } catch (e) { - console.warn(`${session.id}: queueSend delayed send failed:`, e); - } - // clear pending fields - session._pendingMessage = undefined; - clearTimeout(session._pendingTimeout); - session._pendingTimeout = undefined; - }, delay); - } catch (e) { - console.warn(`${session.id}: queueSend exception:`, e); - } -}; - const sendGameToPlayer = (game: any, session: any): void => { console.log(`${session.short}: -> sendGamePlayer:${getName(session)} - full game`); if (!session.ws) { @@ -3855,90 +3459,6 @@ const sendGameToPlayers = (game: any): void => { } }; -const sendUpdateToPlayers = async (game: any, update: any): Promise => { - /* Ensure clearing of a field actually gets sent by setting - * undefined to 'false' - */ - for (let key in update) { - if (update[key] === undefined) { - update[key] = false; - } - } - - calculatePoints(game, update); - - if (debug.update) { - console.log(`[ all ]: -> sendUpdateToPlayers - `, update); - } else { - const keys = Object.getOwnPropertyNames(update); - console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`); - } - - const message = JSON.stringify({ - type: "game-update", - update, - }); - for (let key in game.sessions) { - const session = game.sessions[key]; - /* Only send player and game data to named players */ - if (!session.name) { - console.log(`${session.short}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`); - if (session.ws) { - session.ws.send( - JSON.stringify({ - type: "game-update", - update: { name: "" }, - }) - ); - } - continue; - } - if (!session.ws) { - console.log(`${session.short}: -> sendUpdateToPlayers: ` + `Currently no connection.`); - } else { - queueSend(session, message); - } - } -}; - -const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise => { - /* If this player does not have a name, *ONLY* send the name, regardless - * of what is requested */ - if (!session.name) { - console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`); - update = { name: "" }; - } - - /* Ensure clearing of a field actually gets sent by setting - * undefined to 'false' - */ - for (let key in update) { - if (update[key] === undefined) { - update[key] = false; - } - } - - calculatePoints(game, update); - - if (debug.update) { - console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - `, update); - } else { - const keys = Object.getOwnPropertyNames(update); - console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`); - } - - const message = JSON.stringify({ - type: "game-update", - update, - }); - - if (!session.ws) { - console.log(`${session.short}: -> sendUpdateToPlayer: ` + `Currently no connection.`); - } else { - queueSend(session, message); - } -}; - const getFilteredUnselected = (game: any): string[] => { if (!game.unselected) { return []; @@ -3974,15 +3494,16 @@ const parseChatCommands = (game: any, message: string): void => { } }; -const sendError = (session: any, error: string): void => { +const sendError = (session: Session, error: string): void => { + console.error(`${session.short}: Error: ${error}`); try { - session?.ws?.send(JSON.stringify({ type: "error", error })); + session.ws.send(JSON.stringify({ type: "error", data: error })); } catch (e) { /* ignore */ } }; -const sendWarning = (session: any, warning: string): void => { +const sendWarning = (session: Session, warning: string): void => { console.warn(`${session.short}: Warning: ${warning}`); try { session?.ws?.send(JSON.stringify({ type: "warning", warning })); @@ -3991,27 +3512,6 @@ const sendWarning = (session: any, warning: string): void => { } }; -const getFilteredPlayers = (game: any): Record => { - const filtered: Record = {}; - for (let color in game.players) { - const player = Object.assign({}, game.players[color]); - filtered[color] = player; - if (player.status === "Not active") { - if (game.state !== "lobby") { - delete filtered[color]; - } - continue; - } - player.resources = 0; - ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource: string) => { - player.resources += (player as any)[resource]; - delete (player as any)[resource]; - }); - delete player.development; - } - return filtered; -}; - /** * Get participants list for the game room * Uses the reusable room helper and adds game-specific data (color) @@ -4055,101 +3555,6 @@ const getParticipants = (game: any): any[] => { return participants; }; -const calculatePoints = (game: any, update: any): void => { - if (game.state === "winner") { - return; - } - /* Calculate points and determine if there is a winner */ - for (let key in game.players) { - const player = game.players[key]; - if (player.status === "Not active") { - continue; - } - const currentPoints = player.points; - - player.points = 0; - if (key === game.longestRoad) { - player.points += 2; - } - if (key === game.largestArmy) { - player.points += 2; - } - if (key === game.mostPorts) { - player.points += 2; - } - if (key === game.mostDeveloped) { - player.points += 2; - } - player.points += MAX_SETTLEMENTS - player.settlements; - player.points += 2 * (MAX_CITIES - player.cities); - - player.unplayed = 0; - player.potential = 0; - player.development.forEach((card: any) => { - if (card.type === "vp") { - if (card.played) { - player.points++; - } else { - player.potential++; - } - } - if (!card.played) { - player.unplayed++; - } - }); - - if (player.points === currentPoints) { - continue; - } - - if (player.points < getVictoryPointRule(game)) { - update.players = getFilteredPlayers(game); - continue; - } - - /* This player has enough points! Check if they are the current - * player and if so, declare victory! */ - console.log(`${info}: Whoa! ${player.name} has ${player.points}!`); - for (let key in game.sessions) { - if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") { - continue; - } - const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`; - addChatMessage(game, null, message); - console.log(`${info}: ${message}`); - update.winner = Object.assign({}, player, { - state: "winner", - stolen: game.stolen, - chat: game.chat, - turns: game.turns, - players: game.players, - elapsedTime: Date.now() - game.startTime, - }); - game.winner = update.winner; - game.state = "winner"; - game.waiting = []; - stopTurnTimer(game); - sendUpdateToPlayers(game, { - state: game.state, - winner: game.winner, - players: game.players /* unfiltered */, - }); - } - } - - /* If the game isn't in a win state, do not share development card information - * with other players */ - if (game.state !== "winner") { - for (let key in game.players) { - const player = game.players[key]; - if (player.status === "Not active") { - continue; - } - delete player.potential; - } - } -}; - const clearGame = (game: any, _session: any): string | undefined => { void _session; resetGame(game); @@ -4539,13 +3944,13 @@ router.ws("/ws/:id", async (ws, req) => { } // Calculate latency if ping timestamp was sent - if (session.ping) { - session.lastPong = Date.now(); - const latency = session.lastPong - session.ping; - console.log(`${short}: Received pong from ${getName(session)}. Latency: ${latency}ms`); - } else { - console.log(`${short}: Received pong from ${getName(session)}.`); - } + // if (session.ping) { + // session.lastPong = Date.now(); + // const latency = session.lastPong - session.ping; + // console.log(`${short}: Received pong from ${getName(session)}. Latency: ${latency}ms`); + // } else { + // console.log(`${short}: Received pong from ${getName(session)}.`); + // } // No need to resetDisconnectCheck since it's non-functional break; @@ -4572,7 +3977,7 @@ router.ws("/ws/:id", async (ws, req) => { if (error) { sendError(session, error); } else { - saveGame(game); + gameDB.saveGame(game); } break; @@ -4580,11 +3985,11 @@ router.ws("/ws/:id", async (ws, req) => { console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`); switch (data.field) { case "state": - warning = setGameState(game, session, data.value); + warning = setGame(game, session, data.value); if (warning) { sendWarning(session, warning); } else { - saveGame(game); + gameDB.saveGame(game); } break; @@ -4593,7 +3998,7 @@ router.ws("/ws/:id", async (ws, req) => { if (warning) { sendWarning(session, warning); } else { - saveGame(game); + gameDB.saveGame(game); } break; default: @@ -4731,7 +4136,7 @@ router.ws("/ws/:id", async (ws, req) => { addChatMessage(game, session, `${session.name}: ${data.message}`, true); parseChatCommands(game, data.message); sendUpdateToPlayers(game, { chat: game.chat }); - saveGame(game); + gameDB.saveGame(game); break; case "media-status": @@ -4753,7 +4158,7 @@ router.ws("/ws/:id", async (ws, req) => { /* The rest of the actions and commands require an active game * participant */ - if (!session.player) { + if (session.player?.color === "unassigned") { error = `Player must have an active color.`; sendError(session, error); return; @@ -4928,7 +4333,7 @@ router.ws("/ws/:id", async (ws, req) => { /* If action was taken, persist the game */ if (processed) { - saveGame(game); + gameDB.saveGame(game); } /* If the current player took an action, reset the session timer */ @@ -5213,277 +4618,6 @@ const trackTheft = (game: any, from: any, to: any, type: any, count: any) => { stats[to].stole[type] += count; }; -const resetGame = (game: any) => { - Object.assign(game, { - startTime: Date.now(), - state: "lobby", - turns: 0, - step: 0 /* used for the suffix # in game backups */, - turn: {}, - sheep: 19, - ore: 19, - wool: 19, - brick: 19, - wheat: 19, - placements: { - corners: [], - roads: [], - }, - developmentCards: [], - chat: [], - activities: [], - pipOrder: game.pipOrder, - borderOrder: game.borderOrder, - tileOrder: game.tileOrder, - signature: game.signature, - players: game.players, - stolen: { - robber: { - stole: { - total: 0, - }, - }, - total: 0, - }, - longestRoad: "", - longestRoadLength: 0, - largestArmy: "", - largestArmySize: 0, - mostDeveloped: "", - mostDevelopmentCards: 0, - mostPorts: "", - mostPortCount: 0, - winner: undefined, - active: 0, - }); - - stopTurnTimer(game); - - /* Populate the game corner and road placement data as cleared */ - for (let i = 0; i < layout.corners.length; i++) { - game.placements.corners[i] = { - color: undefined, - type: undefined, - }; - } - - for (let i = 0; i < layout.roads.length; i++) { - game.placements.roads[i] = { - color: undefined, - longestRoad: undefined, - }; - } - - /* Put the robber back on the Desert */ - for (let i = 0; i < game.pipOrder.length; i++) { - if (game.pipOrder[i] === 18) { - game.robber = i; - break; - } - } - - /* Populate the game development cards with a fresh deck */ - for (let i = 1; i <= 14; i++) { - game.developmentCards.push({ - type: "army", - card: i, - }); - } - - ["monopoly", "monopoly", "road-1", "road-2", "year-of-plenty", "year-of-plenty"].forEach((card) => - game.developmentCards.push({ - type: "progress", - card: card, - }) - ); - - ["market", "library", "palace", "university"].forEach((card) => - game.developmentCards.push({ - type: "vp", - card: card, - }) - ); - - shuffleArray(game.developmentCards); - - /* Reset all player data, and add in any missing colors */ - PLAYER_COLORS.forEach((color) => { - if (color in game.players) { - clearPlayer(game.players[color]); - } else { - game.players[color] = newPlayer(color); - } - }); - - /* Ensure sessions are connected to player objects */ - for (let key in game.sessions) { - const session = game.sessions[key]; - if (session.color !== "unassigned") { - game.active++; - session.player = game.players[session.color]; - session.player.status = "Active"; - session.player.lastActive = Date.now(); - session.player.live = session.live; - session.player.name = session.name; - session.player.color = session.color; - } - } - - game.animationSeeds = []; - for (let i = 0; i < game.tileOrder.length; i++) { - game.animationSeeds.push(Math.random()); - } -}; - -const createGame = async (id: any) => { - /* Look for a new game with random words that does not already exist */ - while (!id) { - id = randomWords(4).join("-"); - try { - /* If a game with this id exists in the DB, look for a new name */ - if (!gameDB || !gameDB.getGameById) { - throw new Error("Game DB not available for uniqueness check"); - } - let exists = false; - try { - const g = await gameDB.getGameById(id); - if (g) exists = true; - } catch (e) { - // if DB check fails treat as non-existent and continue searching - } - if (exists) { - id = ""; - } - } catch (error) { - break; - } - } - console.log(`${info}: creating ${id}`); - - const game: Game = { - id: id, - developmentCards: [], - players: { - O: newPlayer("O"), - R: newPlayer("R"), - B: newPlayer("B"), - W: newPlayer("W"), - }, - sessions: {}, - unselected: [], - placements: { - corners: [], - roads: [], - }, - turn: { - name: "", - color: "unassigned", - actions: [], - limits: {}, - roll: 0, - }, - rules: { - "victory-points": { - points: 10, - }, - }, - pipOrder: [], - borderOrder: [], - tileOrder: [], - step: 0 /* used for the suffix # in game backups */, - }; - - setBeginnerGame(game); - resetGame(game); - - addChatMessage(game, null, `New game created with Beginner's Layout: ${game.id}`); - - games[game.id] = game; - audio[game.id] = {}; - return game; -}; - -const setBeginnerGame = (game: any): void => { - pickRobber(game); - shuffleArray(game.developmentCards); - game.borderOrder = []; - for (let i = 0; i < 6; i++) { - game.borderOrder.push(i); - } - game.tileOrder = [9, 12, 1, 5, 16, 13, 17, 6, 2, 0, 3, 10, 4, 11, 7, 14, 18, 8, 15]; - game.robber = 9; - game.animationSeeds = []; - for (let i = 0; i < game.tileOrder.length; i++) { - game.animationSeeds.push(Math.random()); - } - game.pipOrder = [5, 1, 6, 7, 2, 9, 11, 12, 8, 18, 3, 4, 10, 16, 13, 0, 14, 15, 17]; - game.signature = gameSignature(game); -}; - -const shuffleBoard = (game: any): void => { - pickRobber(game); - - const seq = []; - for (let i = 0; i < 6; i++) { - seq.push(i); - } - shuffleArray(seq); - game.borderOrder = seq.slice(); - - for (let i = 6; i < 19; i++) { - seq.push(i); - } - shuffleArray(seq); - game.tileOrder = seq.slice(); - - /* Pip order is from one of the random corners, then rotate around - * and skip over the desert (robber) */ - - /* Board: - * 0 1 2 - * 3 4 5 6 - * 7 8 9 10 11 - * 12 13 14 15 - * 16 17 18 - */ - const order = [ - [0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9], - [2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 5, 10, 14, 13, 8, 4, 9], - [11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 10, 14, 13, 8, 4, 5, 9], - [18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 14, 13, 8, 4, 5, 10, 9], - [16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 13, 8, 4, 5, 10, 14, 9], - [7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 8, 4, 5, 10, 14, 13, 9], - ]; - const sequence = order[Math.floor(Math.random() * order.length)]; - if (!sequence || !Array.isArray(sequence)) { - // Defensive: should not happen, but guard for TS strictness - return; - } - game.pipOrder = []; - game.animationSeeds = []; - for (let i = 0, p = 0; i < sequence.length; i++) { - const target: number = sequence[i]!; - /* If the target tile is the desert (18), then set the - * pip value to the robber (18) otherwise set - * the target pip value to the currently incremeneting - * pip value. */ - const tileIdx = game.tileOrder[target]; - const tileType = staticData.tiles[tileIdx]?.type; - if (!game.pipOrder) game.pipOrder = []; - if (tileType === "desert") { - game.robber = target; - game.pipOrder[target] = 18; - } else { - game.pipOrder[target] = p++; - } - game.animationSeeds.push(Math.random()); - } - - shuffleArray(game.developmentCards); - - game.signature = gameSignature(game); -}; - /* Simple NO-OP to set session cookie so player-id can use it as the * index */ router.get("/", (req, res /*, next*/) => { @@ -5552,7 +4686,7 @@ router.post("/:id?", async (req, res /*, next*/) => { } else { console.log(`[${playerId.substring(0, 8)}]: Creating new game.`); } - const game = await loadGame(String(id || "")); /* will create game if it doesn't exist */ + const game = await loadGame(id || ""); /* will create game if it doesn't exist */ console.log(`[${playerId.substring(0, 8)}]: ${game.id} loaded.`); return res.status(200).send({ id: game.id }); diff --git a/server/routes/games/gameFactory.ts b/server/routes/games/gameFactory.ts new file mode 100644 index 0000000..c4ac4c9 --- /dev/null +++ b/server/routes/games/gameFactory.ts @@ -0,0 +1,304 @@ +import randomWords from "random-words"; + +import { games, gameDB } from "./store"; +import { PLAYER_COLORS, type Game } from "./types"; +import { addChatMessage, clearPlayer, stopTurnTimer } from "./helpers"; +import newPlayer from "./playerFactory"; +import { info } from "./constants"; +import { layout, staticData } from "../../util/layout"; +import { shuffleArray } from "./utils"; +import { pickRobber } from "./robber"; +import { audio } from "../webrtc-signaling"; + +export const resetGame = (game: any) => { + Object.assign(game, { + startTime: Date.now(), + state: "lobby", + turns: 0, + step: 0 /* used for the suffix # in game backups */, + turn: {}, + sheep: 19, + ore: 19, + wool: 19, + brick: 19, + wheat: 19, + placements: { + corners: [], + roads: [], + }, + developmentCards: [], + chat: [], + activities: [], + pipOrder: game.pipOrder, + borderOrder: game.borderOrder, + tileOrder: game.tileOrder, + signature: game.signature, + players: game.players, + stolen: { + robber: { + stole: { + total: 0, + }, + }, + total: 0, + }, + longestRoad: "", + longestRoadLength: 0, + largestArmy: "", + largestArmySize: 0, + mostDeveloped: "", + mostDevelopmentCards: 0, + mostPorts: "", + mostPortCount: 0, + winner: undefined, + active: 0, + }); + + stopTurnTimer(game); + + /* Populate the game corner and road placement data as cleared */ + for (let i = 0; i < layout.corners.length; i++) { + game.placements.corners[i] = { + color: undefined, + type: undefined, + }; + } + + for (let i = 0; i < layout.roads.length; i++) { + game.placements.roads[i] = { + color: undefined, + longestRoad: undefined, + }; + } + + /* Put the robber back on the Desert */ + for (let i = 0; i < game.pipOrder.length; i++) { + if (game.pipOrder[i] === 18) { + game.robber = i; + break; + } + } + + /* Populate the game development cards with a fresh deck */ + for (let i = 1; i <= 14; i++) { + game.developmentCards.push({ + type: "army", + card: i, + }); + } + + ["monopoly", "monopoly", "road-1", "road-2", "year-of-plenty", "year-of-plenty"].forEach((card) => + game.developmentCards.push({ + type: "progress", + card: card, + }) + ); + + ["market", "library", "palace", "university"].forEach((card) => + game.developmentCards.push({ + type: "vp", + card: card, + }) + ); + + shuffleArray(game.developmentCards); + + /* Reset all player data, and add in any missing colors */ + PLAYER_COLORS.forEach((color) => { + if (color in game.players) { + clearPlayer(game.players[color]); + } else { + game.players[color] = newPlayer(color); + } + }); + + /* Ensure sessions are connected to player objects */ + for (let key in game.sessions) { + const session = game.sessions[key]; + if (session.color !== "unassigned") { + game.active++; + session.player = game.players[session.color]; + session.player.status = "Active"; + session.player.lastActive = Date.now(); + session.player.live = session.live; + session.player.name = session.name; + session.player.color = session.color; + } + } + + game.animationSeeds = []; + for (let i = 0; i < game.tileOrder.length; i++) { + game.animationSeeds.push(Math.random()); + } +}; + +export const setBeginnerGame = (game: Game) => { + pickRobber(game); + shuffleArray(game.developmentCards); + game.borderOrder = []; + for (let i = 0; i < 6; i++) { + game.borderOrder.push(i); + } + game.tileOrder = [9, 12, 1, 5, 16, 13, 17, 6, 2, 0, 3, 10, 4, 11, 7, 14, 18, 8, 15]; + game.robber = 9; + game.animationSeeds = []; + for (let i = 0; i < game.tileOrder.length; i++) { + game.animationSeeds.push(Math.random()); + } + game.pipOrder = [5, 1, 6, 7, 2, 9, 11, 12, 8, 18, 3, 4, 10, 16, 13, 0, 14, 15, 17]; + game.signature = gameSignature(game); +}; + +export const shuffleBoard = (game: any): void => { + pickRobber(game); + + const seq = []; + for (let i = 0; i < 6; i++) { + seq.push(i); + } + shuffleArray(seq); + game.borderOrder = seq.slice(); + + for (let i = 6; i < 19; i++) { + seq.push(i); + } + shuffleArray(seq); + game.tileOrder = seq.slice(); + + /* Pip order is from one of the random corners, then rotate around + * and skip over the desert (robber) */ + + /* Board: + * 0 1 2 + * 3 4 5 6 + * 7 8 9 10 11 + * 12 13 14 15 + * 16 17 18 + */ + const order = [ + [0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9], + [2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 5, 10, 14, 13, 8, 4, 9], + [11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 10, 14, 13, 8, 4, 5, 9], + [18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 14, 13, 8, 4, 5, 10, 9], + [16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 13, 8, 4, 5, 10, 14, 9], + [7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 8, 4, 5, 10, 14, 13, 9], + ]; + const sequence = order[Math.floor(Math.random() * order.length)]; + if (!sequence || !Array.isArray(sequence)) { + // Defensive: should not happen, but guard for TS strictness + return; + } + game.pipOrder = []; + game.animationSeeds = []; + for (let i = 0, p = 0; i < sequence.length; i++) { + const target: number = sequence[i]!; + /* If the target tile is the desert (18), then set the + * pip value to the robber (18) otherwise set + * the target pip value to the currently incremeneting + * pip value. */ + const tileIdx = game.tileOrder[target]; + const tileType = staticData.tiles[tileIdx]?.type; + if (!game.pipOrder) game.pipOrder = []; + if (tileType === "desert") { + game.robber = target; + game.pipOrder[target] = 18; + } else { + game.pipOrder[target] = p++; + } + game.animationSeeds.push(Math.random()); + } + + shuffleArray(game.developmentCards); + + game.signature = gameSignature(game); +}; + +export const createGame = async (id: string | null = null) => { + + /* Look for a new game with random words that does not already exist */ + while (!id) { + id = randomWords(4).join("-"); + try { + /* If a game with this id exists in the DB, look for a new name */ + if (!gameDB || !gameDB.getGameById) { + throw new Error("Game DB not available for uniqueness check"); + } + let exists = false; + try { + const g = await gameDB.getGameById(id); + if (g) exists = true; + } catch (e) { + // if DB check fails treat as non-existent and continue searching + } + if (exists) { + id = ""; + } + } catch (error) { + break; + } + } + console.log(`${info}: creating ${id}`); + + const game: Game = { + id: id, + developmentCards: [], + playerOrder: [], + turns: 0, + players: { + O: newPlayer("O"), + R: newPlayer("R"), + B: newPlayer("B"), + W: newPlayer("W"), + }, + sessions: {}, + unselected: [], + placements: { + corners: [], + roads: [], + }, + turn: { + name: "", + color: "unassigned", + actions: [], + limits: {}, + roll: 0, + }, + rules: { + "victory-points": { + points: 10, + }, + }, + pipOrder: [], + borderOrder: [], + tileOrder: [], + step: 0 /* used for the suffix # in game backups */, + }; + + setBeginnerGame(game); + resetGame(game); + + addChatMessage(game, null, `New game created with Beginner's Layout: ${game.id}`); + + games[game.id] = game; + audio[game.id] = {}; + return game; +}; + +const gameSignature = (game: Game): string => { + if (!game) { + return ""; + } + const salt = 251; + const signature = + (game.borderOrder || []).map((border: any) => `00${(Number(border) ^ salt).toString(16)}`.slice(-2)).join("") + + "-" + + (game.pipOrder || []) + .map((pip: any, index: number) => `00${(Number(pip) ^ salt ^ (salt * index)).toString(16)}`.slice(-2)) + .join("") + + "-" + + (game.tileOrder || []) + .map((tile: any, index: number) => `00${(Number(tile) ^ salt ^ (salt * index)).toString(16)}`.slice(-2)) + .join(""); + + return signature; +}; diff --git a/server/routes/games/helpers.ts b/server/routes/games/helpers.ts index fab09de..88a2ab3 100644 --- a/server/routes/games/helpers.ts +++ b/server/routes/games/helpers.ts @@ -1,5 +1,8 @@ -import type { Game, Session, Player } from "./types"; +import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types"; import { newPlayer } from "./playerFactory"; +import { debug, info, MAX_CITIES, MAX_SETTLEMENTS, SEND_THROTTLE_MS } from "./constants"; +import { shuffleBoard } from "./gameFactory"; +import { getVictoryPointRule } from "./rules"; export const addActivity = (game: Game, session: Session | null, message: string): void => { let date = Date.now(); @@ -80,7 +83,7 @@ export const getFirstPlayerName = (game: Game): string => { }; export const getNextPlayerSession = (game: Game, name: string): Session | undefined => { - let color: string | undefined; + let color: PlayerColor | undefined; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.name === name) { @@ -107,7 +110,7 @@ export const getNextPlayerSession = (game: Game, name: string): Session | undefi }; export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => { - let color: string | undefined; + let color: PlayerColor | undefined; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.name === name) { @@ -164,7 +167,7 @@ export const setForCityPlacement = (game: Game, limits: any): void => { game.turn.limits = { corners: limits }; }; -export const setForSettlementPlacement = (game: Game, limits: number[] | undefined, _extra?: any): void => { +export const setForSettlementPlacement = (game: Game, limits: number[]): void => { game.turn.actions = ["place-settlement"]; game.turn.limits = { corners: limits }; }; @@ -187,3 +190,355 @@ export const adjustResources = (player: Player, deltas: Partial { + const timeout = 90; + if (!session.ws) { + console.log(`${session.short}: Aborting turn timer as ${session.name} is disconnected.`); + } else { + console.log(`${session.short}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`); + } + if (game.turnTimer) { + clearTimeout(game.turnTimer); + } + if (!session.connected) { + game.turnTimer = 0; + return; + } + game.turnTimer = setTimeout(() => { + console.log(`${session.short}: Turn timer expired for ${session.name}`); + if (session.player) { + session.player.turnNotice = "It is still your turn."; + } + sendUpdateToPlayer(game, session, { + private: session.player, + }); + resetTurnTimer(game, session); + }, timeout * 1000); +}; + +const calculatePoints = (game: any, update: any): void => { + if (game.state === "winner") { + return; + } + /* Calculate points and determine if there is a winner */ + for (let key in game.players) { + const player = game.players[key]; + if (player.status === "Not active") { + continue; + } + const currentPoints = player.points; + + player.points = 0; + if (key === game.longestRoad) { + player.points += 2; + } + if (key === game.largestArmy) { + player.points += 2; + } + if (key === game.mostPorts) { + player.points += 2; + } + if (key === game.mostDeveloped) { + player.points += 2; + } + player.points += MAX_SETTLEMENTS - player.settlements; + player.points += 2 * (MAX_CITIES - player.cities); + + player.unplayed = 0; + player.potential = 0; + player.development.forEach((card: any) => { + if (card.type === "vp") { + if (card.played) { + player.points++; + } else { + player.potential++; + } + } + if (!card.played) { + player.unplayed++; + } + }); + + if (player.points === currentPoints) { + continue; + } + + if (player.points < getVictoryPointRule(game)) { + update.players = getFilteredPlayers(game); + continue; + } + + /* This player has enough points! Check if they are the current + * player and if so, declare victory! */ + console.log(`${info}: Whoa! ${player.name} has ${player.points}!`); + for (let key in game.sessions) { + if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") { + continue; + } + const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`; + addChatMessage(game, null, message); + console.log(`${info}: ${message}`); + update.winner = Object.assign({}, player, { + state: "winner", + stolen: game.stolen, + chat: game.chat, + turns: game.turns, + players: game.players, + elapsedTime: Date.now() - game.startTime, + }); + game.winner = update.winner; + game.state = "winner"; + game.waiting = []; + stopTurnTimer(game); + sendUpdateToPlayers(game, { + state: game.state, + winner: game.winner, + players: game.players /* unfiltered */, + }); + } + } + + /* If the game isn't in a win state, do not share development card information + * with other players */ + if (game.state !== "winner") { + for (let key in game.players) { + const player = game.players[key]; + if (player.status === "Not active") { + continue; + } + delete player.potential; + } + } +}; + +export const resetTurnTimer = (game: Game, session: Session): void => { + startTurnTimer(game, session); +}; + +export const stopTurnTimer = (game: Game): void => { + if (game.turnTimer) { + console.log(`${info}: Stopping turn timer.`); + try { + clearTimeout(game.turnTimer); + } catch (e) { + /* ignore if not a real timeout */ + } + game.turnTimer = 0; + } + return undefined; +}; + +export const sendUpdateToPlayers = async (game: any, update: any): Promise => { + /* Ensure clearing of a field actually gets sent by setting + * undefined to 'false' + */ + for (let key in update) { + if (update[key] === undefined) { + update[key] = false; + } + } + + calculatePoints(game, update); + + if (debug.update) { + console.log(`[ all ]: -> sendUpdateToPlayers - `, update); + } else { + const keys = Object.getOwnPropertyNames(update); + console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`); + } + + const message = JSON.stringify({ + type: "game-update", + update, + }); + for (let key in game.sessions) { + const session = game.sessions[key]; + /* Only send player and game data to named players */ + if (!session.name) { + console.log(`${session.short}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`); + if (session.ws) { + session.ws.send( + JSON.stringify({ + type: "game-update", + update: { name: "" }, + }) + ); + } + continue; + } + if (!session.ws) { + console.log(`${session.short}: -> sendUpdateToPlayers: ` + `Currently no connection.`); + } else { + queueSend(session, message); + } + } +}; + +export const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise => { + /* If this player does not have a name, *ONLY* send the name, regardless + * of what is requested */ + if (!session.name) { + console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`); + update = { name: "" }; + } + + /* Ensure clearing of a field actually gets sent by setting + * undefined to 'false' + */ + for (let key in update) { + if (update[key] === undefined) { + update[key] = false; + } + } + + calculatePoints(game, update); + + if (debug.update) { + console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - `, update); + } else { + const keys = Object.getOwnPropertyNames(update); + console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`); + } + + const message = JSON.stringify({ + type: "game-update", + update, + }); + + if (!session.ws) { + console.log(`${session.short}: -> sendUpdateToPlayer: ` + `Currently no connection.`); + } else { + queueSend(session, message); + } +}; + +export const queueSend = (session: any, message: any): void => { + if (!session || !session.ws) return; + try { + // Ensure we compare a stable serialization: if message is JSON text, + // parse it and re-serialize with sorted keys so semantically-equal + // objects compare equal even when property order differs. + const stableStringify = (msg: any): string => { + try { + const obj = typeof msg === "string" ? JSON.parse(msg) : msg; + const ordered = (v: any): any => { + if (v === null || typeof v !== "object") return v; + if (Array.isArray(v)) return v.map(ordered); + const keys = Object.keys(v).sort(); + const out: any = {}; + for (const k of keys) out[k] = ordered(v[k]); + return out; + }; + return JSON.stringify(ordered(obj)); + } catch (e) { + // If parsing fails, fall back to original string representation + return typeof msg === "string" ? msg : JSON.stringify(msg); + } + }; + const stableMessage = stableStringify(message); + const now = Date.now(); + if (!session._lastSent) session._lastSent = 0; + const elapsed = now - session._lastSent; + // If the exact same message (in stable form) was sent last time and + // nothing is pending, skip sending to avoid pointless duplicate + // traffic. + if (!session._pendingTimeout && session._lastMessage === stableMessage) { + return; + } + + // If we haven't sent recently and there's no pending timer, send now + if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) { + try { + session.ws.send(typeof message === "string" ? message : JSON.stringify(message)); + session._lastSent = Date.now(); + session._lastMessage = stableMessage; + } catch (e) { + console.warn(`${session.id}: queueSend immediate send failed:`, e); + } + return; + } + + // Otherwise, store latest message and schedule a send + // If the pending message would equal the last-sent message, don't bother + // storing/scheduling it. + if (session._lastMessage === stableMessage) { + return; + } + session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message); + if (session._pendingTimeout) { + // already scheduled; newest message will be sent when timer fires + return; + } + const delay = Math.max(1, SEND_THROTTLE_MS - elapsed); + session._pendingTimeout = setTimeout(() => { + try { + if (session.ws && session._pendingMessage) { + session.ws.send(session._pendingMessage); + session._lastSent = Date.now(); + // compute stable form of what we actually sent + try { + session._lastMessage = stableStringify(session._pendingMessage); + } catch (e) { + session._lastMessage = session._pendingMessage; + } + } + } catch (e) { + console.warn(`${session.id}: queueSend delayed send failed:`, e); + } + // clear pending fields + session._pendingMessage = undefined; + clearTimeout(session._pendingTimeout); + session._pendingTimeout = undefined; + }, delay); + } catch (e) { + console.warn(`${session.id}: queueSend exception:`, e); + } +}; + +export const shuffle = (game: Game, session: Session): string | undefined => { + if (game.state !== "lobby") { + return `Game no longer in lobby (${game.state}). Can not shuffle board.`; + } + if (game.turns > 0) { + return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`; + } + shuffleBoard(game); + console.log(`${session.short}: Shuffled to new signature: ${game.signature}`); + + sendUpdateToPlayers(game, { + pipOrder: game.pipOrder, + tileOrder: game.tileOrder, + borderOrder: game.borderOrder, + robber: game.robber, + robberName: game.robberName, + signature: game.signature, + animationSeeds: game.animationSeeds, + }); + return undefined; +}; + +export const getName = (session: Session): string => { + return session ? (session.name ? session.name : session.id) : "Admin"; +}; + +export const getFilteredPlayers = (game: Game): Record => { + const filtered: Record = {}; + for (let color in game.players) { + const player = Object.assign({}, game.players[color]); + filtered[color] = player; + if (player.status === "Not active") { + if (game.state !== "lobby") { + delete filtered[color]; + } + continue; + } + player.resources = 0; + RESOURCE_TYPES.forEach((resource) => { + player.resources += player[resource]; + delete player[resource]; + }); + player.development = []; + } + return filtered; +}; diff --git a/server/routes/games/robber.ts b/server/routes/games/robber.ts new file mode 100644 index 0000000..8090963 --- /dev/null +++ b/server/routes/games/robber.ts @@ -0,0 +1,17 @@ +import { Game } from "./types"; + +export const pickRobber = (game: Game): void => { + const selection = Math.floor(Math.random() * 3); + switch (selection) { + case 0: + game.robberName = "Robert"; + break; + case 1: + game.robberName = "Roberta"; + break; + case 2: + game.robberName = "Velocirobber"; + break; + } +}; + diff --git a/server/routes/games/rules.ts b/server/routes/games/rules.ts new file mode 100644 index 0000000..dfaf516 --- /dev/null +++ b/server/routes/games/rules.ts @@ -0,0 +1,139 @@ +import equal from "fast-deep-equal"; +import { isRuleEnabled } from "../../util/validLocations"; +import { addChatMessage, getName, sendUpdateToPlayers, shuffle } from "./helpers"; + +export const getVictoryPointRule = (game: any): number => { + const minVP = 10; + if (!isRuleEnabled(game, "victory-points") || !("points" in game.rules["victory-points"])) { + return minVP; + } + return game.rules["victory-points"].points; +}; + +export const supportedRules: Record string | void | undefined> = { + "victory-points": (game: any, session: any, rule: any, rules: any) => { + if (!("points" in rules[rule])) { + return `No points specified for victory-points`; + } + if (!rules[rule].enabled) { + addChatMessage(game, null, `${getName(session)} has disabled the Victory Point ` + `house rule.`); + } else { + addChatMessage(game, null, `${getName(session)} set the minimum Victory Points to ` + `${rules[rule].points}`); + } + return undefined; + }, + "roll-double-roll-again": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Roll Double, Roll Again house rule.` + ); + return undefined; + }, + volcano: (game: any, session: any, rule: any, rules: any) => { + if (!rules[rule].enabled) { + addChatMessage(game, null, `${getName(session)} has disabled the Volcano ` + `house rule.`); + } else { + if (!(rule in game.rules) || !game.rules[rule].enabled) { + addChatMessage( + game, + null, + `${getName(session)} enabled the Volcano ` + + `house rule with roll set to ` + + `${rules[rule].number} and 'Volanoes have gold' mode ` + + `${rules[rule].gold ? "en" : "dis"}abled.` + ); + } else { + if (game.rules[rule].number !== rules[rule].number) { + addChatMessage(game, null, `${getName(session)} set the Volcano roll to ` + `${rules[rule].number}`); + } + + if (game.rules[rule].gold !== rules[rule].gold) { + addChatMessage( + game, + null, + `${getName(session)} has ` + `${rules[rule].gold ? "en" : "dis"}abled the ` + `'Volcanoes have gold' mode.` + ); + } + } + } + }, + + "twelve-and-two-are-synonyms": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Twelve and Two are Synonyms house rule.` + ); + game.rules[rule] = rules[rule]; + }, + "most-developed": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Most Developed house rule.` + ); + }, + "port-of-call": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Another Round of Port house rule.` + ); + }, + "slowest-turn": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Slowest Turn house rule.` + ); + }, + "tiles-start-facing-down": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Tiles Start Facing Down house rule.` + ); + if (rules[rule].enabled) { + shuffle(game, session); + } + }, + "robin-hood-robber": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Robin Hood Robber house rule.` + ); + }, +}; + +export const setRules = (game: any, session: any, rules: any): string | undefined => { + if (game.state !== "lobby") { + return `You can not modify House Rules once the game has started.`; + } + + for (let rule in rules) { + if (equal(game.rules[rule], rules[rule])) { + continue; + } + + if (rule in supportedRules) { + const handler = supportedRules[rule]; + if (handler) { + const warning = handler(game, session, rule, rules); + if (warning) { + return warning; + } + } + game.rules[rule] = rules[rule]; + } else { + return `Rule ${rule} not recognized.`; + } + } + + sendUpdateToPlayers(game, { + rules: game.rules, + chat: game.chat, + }); + return undefined; +}; diff --git a/server/routes/games/serialize.ts b/server/routes/games/serialize.ts index 8da627a..23ca010 100644 --- a/server/routes/games/serialize.ts +++ b/server/routes/games/serialize.ts @@ -1,19 +1,20 @@ -import type { GameState } from './state'; +import { createGame } from "./gameFactory"; +import type { Game } from "./types"; -export function serializeGame(game: GameState): string { +export const serializeGame = (game: Game): string => { // Use a deterministic JSON serializer for snapshots; currently use JSON.stringify return JSON.stringify(game); -} +}; -export function deserializeGame(serialized: string): GameState { +export const deserializeGame = async (serialized: string): Promise => { try { - return JSON.parse(serialized) as GameState; + return JSON.parse(serialized) as Game; } catch (e) { // If parsing fails, return a minimal empty game state to avoid crashes - return { players: [], placements: { corners: [], roads: [] } } as GameState; + return await createGame(); } -} +}; -export function cloneGame(game: GameState): GameState { +export const cloneGame = async (game: Game): Promise => { return deserializeGame(serializeGame(game)); -} +}; diff --git a/server/routes/games/state.ts b/server/routes/games/state.ts deleted file mode 100644 index 727a37b..0000000 --- a/server/routes/games/state.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Player } from './types'; - -export interface PlacementCorner { - color?: string | null; - type?: string | null; // settlement/city - data?: any; -} - -export interface PlacementRoad { - color?: string | null; - data?: any; -} - -export interface Placements { - corners: PlacementCorner[]; - roads: PlacementRoad[]; -} - -export interface GameState { - id?: string | number; - name?: string; - players: Player[]; - placements: Placements; - rules?: Record; - state?: string; - robber?: number; - turn?: number; - history?: any[]; - createdAt?: string; - [key: string]: any; -} - -export type GameId = string | number; - diff --git a/server/routes/games/store.ts b/server/routes/games/store.ts index 9ff7bf8..7053be1 100644 --- a/server/routes/games/store.ts +++ b/server/routes/games/store.ts @@ -1,184 +1,211 @@ -import type { GameState } from './state.js'; -import { promises as fsp } from 'fs'; -import path from 'path'; +import type { Game } from "./types"; +import { promises as fsp } from "fs"; +import path from "path"; +import { transientState } from "./sessionState"; -export interface GameDB { - sequelize?: any; - Sequelize?: any; - getGameById(id: string | number): Promise; - saveGameState(id: string | number, state: GameState): Promise; - deleteGame?(id: string | number): Promise; - [k: string]: any; +interface GameDB { + db: any | null; + init(): Promise; + getGameById(id: string): Promise; + saveGame(game: Game): Promise; + deleteGame?(id: string): Promise; } -/** - * Thin game DB initializer / accessor. - * This currently returns the underlying db module (for runtime compatibility) - * and is the single place to add typed helper methods for game persistence. - */ -export async function initGameDB(): Promise { - // dynamic import to preserve original runtime ordering - // path is relative to this file (routes/games) - // Prefer synchronous require at runtime when available to avoid TS module resolution - // issues during type-checking. Declare require to keep TypeScript happy. - let mod: any; - try { - // Use runtime require to load the DB module. This runs under Node (ts-node) - // so a direct require is appropriate and avoids relying on globalThis. - // eslint-disable-next-line @typescript-eslint/no-var-requires - mod = require('../../db/games'); - } catch (e) { - // DB-only mode: fail fast so callers know persistence is required. - throw new Error('Game DB module could not be loaded: ' + String(e)); - } - // If the module uses default export, prefer it - let db: any = (mod && (mod.default || mod)); - // If the required module returned a Promise (the db initializer may), await it. - if (db && typeof db.then === 'function') { +export const gameDB: GameDB = { + db: null, + init: async () => { + let mod: any; try { - db = await db; + // Use runtime require to load the DB module. This runs under Node (ts-node) + // so a direct require is appropriate and avoids relying on globalThis. + // eslint-disable-next-line @typescript-eslint/no-var-requires + mod = require("../../db/games"); } catch (e) { - throw new Error('Game DB initializer promise rejected: ' + String(e)); + // DB-only mode: fail fast so callers know persistence is required. + throw new Error("Game DB module could not be loaded: " + String(e)); + } + // If the module uses default export, prefer it + let db = mod.default || mod; + // If the required module returned a Promise (the db initializer may), await it. + if (db && typeof db.then === "function") { + try { + db = await db; + } catch (e) { + throw new Error("Game DB initializer promise rejected: " + String(e)); + } } - } - // attach typed helper placeholders (will be implemented incrementally) - if (!db.getGameById) { - db.getGameById = async (id: string | number): Promise => { - // fallback: try to query by id using raw SQL if sequelize is available - if (db && db.sequelize) { - try { - const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', { - replacements: { id }, - type: db.Sequelize.QueryTypes.SELECT - }); - if (rows && rows.length) { - const r = rows[0] as any; - // state may be stored as text or JSON - if (typeof r.state === 'string') { - try { - return JSON.parse(r.state) as GameState; - } catch (e) { - return null; - } + gameDB.db = db; + return db; + }, + + getGameById: async (id: string | number): Promise => { + if (!gameDB.db) { + await gameDB.init(); + } + const db = gameDB.db; + // fallback: try to query by id using raw SQL if sequelize is available + if (db && db.sequelize) { + try { + const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", { + replacements: { id }, + type: db.Sequelize.QueryTypes.SELECT, + }); + if (rows && rows.length) { + const r = rows[0] as any; + // state may be stored as text or JSON + if (typeof r.state === "string") { + try { + return JSON.parse(r.state) as Game; + } catch (e) { + return null; } - return r.state as GameState; } - } catch (e) { - // ignore and fallthrough + return r.state as Game; } + } catch (e) { + // ignore and fallthrough } - // If DB didn't have a state or query failed, attempt to read from the - // filesystem copy at db/games/ or .json so the state remains editable. - try { - const gamesDir = path.resolve(__dirname, '../../../db/games'); - const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')]; - for (const filePath of candidates) { - try { - const raw = await fsp.readFile(filePath, 'utf8'); - return JSON.parse(raw) as GameState; - } catch (e) { - // try next candidate - } - } - return null; - } catch (err) { - return null; - } - }; - } - - if (!db.saveGameState) { - db.saveGameState = async (id: string | number, state: GameState): Promise => { - // Always persist a JSON file so game state is inspectable/editable. - try { - const gamesDir = path.resolve(__dirname, '../../../db/games'); - await fsp.mkdir(gamesDir, { recursive: true }); - // Write extensionless filename to match existing files - const filePath = path.join(gamesDir, String(id)); - const tmpPath = `${filePath}.tmp`; - await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8'); - await fsp.rename(tmpPath, filePath); - } catch (err) { - // Log but continue to attempt DB persistence - // eslint-disable-next-line no-console - console.error('Failed to write game JSON file for', id, err); - } - - // Now attempt DB persistence if sequelize is present. - if (db && db.sequelize) { - const payload = JSON.stringify(state); - // Try an UPDATE; if it errors due to missing column, try to add the - // column and retry. If update affects no rows, try INSERT. + } + // If DB didn't have a state or query failed, attempt to read from the + // filesystem copy at db/games/ or .json so the state remains editable. + try { + const gamesDir = path.resolve(__dirname, "../../../db/games"); + const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")]; + for (const filePath of candidates) { try { - try { - await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { - replacements: { id, state: payload } + const raw = await fsp.readFile(filePath, "utf8"); + return JSON.parse(raw) as Game; + } catch (e) { + // try next candidate + } + } + return null; + } catch (err) { + return null; + } + }, + saveGame: async (game: Game): Promise => { + if (!gameDB.db) { + await gameDB.init(); + } + const db = gameDB.db; + /* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then + * delete the player field from them */ + const reducedGame = Object.assign({}, game, { sessions: {} }), + reducedSessions = []; + + for (let id in game.sessions) { + const reduced = Object.assign({}, game.sessions[id]); + + // Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth) + transientState.stripSessionTransients(reduced); + + reducedGame.sessions[id] = reduced; + + /* Do not send session-id as those are secrets */ + reducedSessions.push(reduced); + } + + // Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA) + transientState.stripGameTransients(reducedGame); + + /* Save per turn while debugging... */ + game.step = game.step ? game.step : 0; + + // Always persist a JSON file so game state is inspectable/editable. + try { + const gamesDir = path.resolve(__dirname, "../../../db/games"); + await fsp.mkdir(gamesDir, { recursive: true }); + // Write extensionless filename to match existing files + const filePath = path.join(gamesDir, reducedGame.id); + const tmpPath = `${filePath}.tmp`; + await fsp.writeFile(tmpPath, JSON.stringify(reducedGame, null, 2), "utf8"); + await fsp.rename(tmpPath, filePath); + } catch (err) { + // Log but continue to attempt DB persistence + // eslint-disable-next-line no-console + console.error("Failed to write game JSON file for", reducedGame.id, err); + } + + // Now attempt DB persistence if sequelize is present. + if (db && db.sequelize) { + const payload = JSON.stringify(reducedGame); + // Try an UPDATE; if it errors due to missing column, try to add the + // column and retry. If update affects no rows, try INSERT. + try { + try { + await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", { + replacements: { id: reducedGame.id, state: payload }, + }); + // Some dialects don't return affectedRows consistently; we'll + // still attempt insert if no row exists by checking select. + const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", { + replacements: { id: reducedGame.id }, + type: db.Sequelize.QueryTypes.SELECT, + }); + if (!check || check.length === 0) { + await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { + replacements: { id: reducedGame.id, state: payload }, }); - // Some dialects don't return affectedRows consistently; we'll - // still attempt insert if no row exists by checking select. - const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', { - replacements: { id }, - type: db.Sequelize.QueryTypes.SELECT - }); - if (!check || check.length === 0) { - await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { - replacements: { id, state: payload } + } + } catch (e: any) { + const msg = String(e && e.message ? e.message : e); + // If the column doesn't exist (SQLite: no such column: state), add it. + if ( + /no such column: state/i.test(msg) || + /has no column named state/i.test(msg) || + /unknown column/i.test(msg) + ) { + try { + await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT"); + // retry insert/update after adding column + await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", { + replacements: { id: reducedGame.id, state: payload }, }); + const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", { + replacements: { id: reducedGame.id }, + type: db.Sequelize.QueryTypes.SELECT, + }); + if (!check || check.length === 0) { + await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { + replacements: { id: reducedGame.id, state: payload }, + }); + } + } catch (inner) { + // swallow; callers should handle missing persistence } - } catch (e: any) { - const msg = String(e && e.message ? e.message : e); - // If the column doesn't exist (SQLite: no such column: state), add it. - if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) { - try { - await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT'); - // retry insert/update after adding column - await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { - replacements: { id, state: payload } - }); - const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', { - replacements: { id }, - type: db.Sequelize.QueryTypes.SELECT - }); - if (!check || check.length === 0) { - await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { - replacements: { id, state: payload } - }); - } - } catch (inner) { - // swallow; callers should handle missing persistence - } - } else { - // For other errors, attempt insert as a fallback - try { - await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { - replacements: { id, state: payload } - }); - } catch (err) { - // swallow; callers should handle missing persistence - } + } else { + // For other errors, attempt insert as a fallback + try { + await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { + replacements: { id: reducedGame.id, state: payload }, + }); + } catch (err) { + // swallow; callers should handle missing persistence } } - } catch (finalErr) { - // swallow; we don't want persistence errors to crash the server } + } catch (finalErr) { + // swallow; we don't want persistence errors to crash the server } - }; - } - - if (!db.deleteGame) { - db.deleteGame = async (id: string | number): Promise => { - if (db && db.sequelize) { - try { - await db.sequelize.query('DELETE FROM games WHERE id=:id', { - replacements: { id } - }); - } catch (e) { - // swallow; callers should handle missing persistence - } + } + }, + deleteGame: async (id: string | number): Promise => { + if (!gameDB.db) { + await gameDB.init(); + } + const db = gameDB.db; + if (db && db.sequelize) { + try { + await db.sequelize.query("DELETE FROM games WHERE id=:id", { + replacements: { id }, + }); + } catch (e) { + // swallow; callers should handle missing persistence } - }; - } + } + }, +}; - return db as GameDB; -} +export const games: Record = {}; diff --git a/server/routes/games/types.ts b/server/routes/games/types.ts index 4df5802..fd0f76f 100644 --- a/server/routes/games/types.ts +++ b/server/routes/games/types.ts @@ -38,6 +38,9 @@ export interface Player { [key: string]: any; // allow incremental fields until fully typed } +export type CornerType = "settlement" | "city" | "none"; +export const CORNER_TYPES: CornerType[] = ["settlement", "city", "none"]; + export interface CornerPlacement { color: PlayerColor; type: "settlement" | "city" | "none"; @@ -46,6 +49,9 @@ export interface CornerPlacement { [key: string]: any; } +export type RoadType = "road" | "ship"; +export const ROAD_TYPES: RoadType[] = ["road", "ship"]; + export interface RoadPlacement { color?: PlayerColor; walking?: boolean; @@ -60,7 +66,7 @@ export interface Placements { export interface Turn { name?: string; - color?: PlayerColor; + color: PlayerColor; actions?: string[]; limits?: any; roll?: number; @@ -119,7 +125,7 @@ export interface Offer { } export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert"; -export const RESOURCE_TYPES = ["wood", "brick", "sheep", "wheat", "stone", "desert"] as ResourceType[]; +export const RESOURCE_TYPES: ResourceType[] = ["wood", "brick", "sheep", "wheat", "stone", "desert"]; export interface Tile { robber: boolean; @@ -153,11 +159,11 @@ export interface Game { dice?: number[]; chat?: any[]; activities?: any[]; - playerOrder?: string[]; + playerOrder: PlayerColor[]; state?: string; robber?: number; robberName?: string; - turns?: number; + turns: number; longestRoad?: string | false; longestRoadLength?: number; borderOrder?: number[]; @@ -173,6 +179,10 @@ export interface Game { startTime?: number; direction?: "forward" | "backward"; winner?: string | false; + history?: any[]; + createdAt?: string; } +export type GameId = string; + export type IncomingMessage = { type: string | null; data: any }; diff --git a/server/routes/webrtc-signaling.ts b/server/routes/webrtc-signaling.ts index 67101c8..fc65535 100644 --- a/server/routes/webrtc-signaling.ts +++ b/server/routes/webrtc-signaling.ts @@ -32,7 +32,7 @@ export const join = ( const ws = session.ws; if (!session.name) { - console.error(`${session.id}: <- join - No name set yet. Audio not available.`); + console.error(`${session.short}: <- join - No name set yet. Audio not available.`); send(ws, { type: "join_status", status: "Error", @@ -41,13 +41,13 @@ export const join = ( return; } - console.log(`${session.id}: <- join - ${session.name}`); + console.log(`${session.short}: <- join - ${session.name}`); // Determine media capability - prefer has_media if provided const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio; if (session.name in peers) { - console.log(`${session.id}:${session.name} - Already joined to Audio, updating WebSocket reference.`); + console.log(`${session.short}:${session.name} - Already joined to Audio, updating WebSocket reference.`); try { const prev = peers[session.name] && peers[session.name].ws; if (prev && prev._pingInterval) { @@ -144,9 +144,7 @@ export const join = ( export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => { const ws = session.ws; - const send = safeSend - ? safeSend - : defaultSend; + const send = safeSend ? safeSend : defaultSend; if (!session.name) { console.error(`${session.id}: <- part - No name set yet. Audio not available.`); @@ -154,12 +152,12 @@ export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, } if (!(session.name in peers)) { - console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`); + console.log(`${session.short}: <- ${session.name} - Does not exist in game audio.`); return; } - console.log(`${session.id}: <- ${session.name} - Audio part.`); - console.log(`-> removePeer - ${session.name}`); + console.log(`${session.short}: <- ${session.name} - Audio part.`); + console.log(`${session.short}: -> removePeer - ${session.name}`); delete peers[session.name]; @@ -247,7 +245,11 @@ export const handleRelaySessionDescription = ( send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } }); return; } - if (debug && debug.audio) console.log(`${session.id}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`, session_description); + if (debug && debug.audio) + console.log( + `${session.short}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`, + session_description + ); const message = JSON.stringify({ type: "sessionDescription", data: { diff --git a/server/src/app.ts b/server/src/app.ts index aebf2b9..4a8332b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,11 +1,11 @@ -import type { Request, Response, NextFunction } from 'express'; -import express from 'express'; -import bodyParser from 'body-parser'; -import config from 'config'; -import basePath from '../basepath'; -import cookieParser from 'cookie-parser'; -import http from 'http'; -import expressWs from 'express-ws'; +import type { Request, Response, NextFunction } from "express"; +import express from "express"; +import bodyParser from "body-parser"; +import config from "config"; +import basePath from "../basepath"; +import cookieParser from "cookie-parser"; +import http from "http"; +import expressWs from "express-ws"; process.env.TZ = "Etc/GMT"; @@ -29,7 +29,7 @@ try { const debugRouter = require("../routes/debug").default || require("../routes/debug"); app.use(basePath, debugRouter); } catch (e: any) { - console.error('Failed to mount debug routes (src):', e && e.stack || e); + console.error("Failed to mount debug routes (src):", (e && e.stack) || e); } const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/", @@ -87,34 +87,18 @@ app.use(basePath, index); */ app.set("port", serverConfig.port); -process.on('SIGINT', () => { +process.on("SIGINT", () => { console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds"); setTimeout(() => process.exit(-1), 2000); server.close(() => process.exit(1)); }); -// database initializers -// eslint-disable-next-line @typescript-eslint/no-var-requires -import { initGameDB } from '../routes/games/store'; - -initGameDB().then(function(_db: any) { - // games DB initialized via store facade -}).then(function() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return Promise.resolve((require("../db/users") as any).default || require("../db/users")).then(function(_db: any) { - // users DB initialized - }); -}).then(function() { - console.log("DB connected. Opening server."); - server.listen(serverConfig.port, () => { - console.log(`http/ws server listening on ${serverConfig.port}`); - }); -}).catch(function(error: any) { - console.error(error); - process.exit(-1); +console.log("Opening server."); +server.listen(serverConfig.port, () => { + console.log(`http/ws server listening on ${serverConfig.port}`); }); -server.on("error", function(error: any) { +server.on("error", function (error: any) { if (error.syscall !== "listen") { throw error; } diff --git a/server/tools/import-games-to-db.ts b/server/tools/import-games-to-db.ts index 23f4af7..6fba30c 100644 --- a/server/tools/import-games-to-db.ts +++ b/server/tools/import-games-to-db.ts @@ -1,67 +1,67 @@ #!/usr/bin/env ts-node import path from 'path'; import fs from 'fs/promises'; -import { initGameDB } from '../routes/games/store'; +import { gameDB } from "../routes/games/store"; async function main() { - const gamesDir = path.resolve(__dirname, '../../db/games'); + const gamesDir = path.resolve(__dirname, "../../db/games"); let files: string[] = []; try { files = await fs.readdir(gamesDir); } catch (e) { - console.error('Failed to read games dir', gamesDir, e); + console.error("Failed to read games dir", gamesDir, e); process.exit(2); } - let db: any; - try { - db = await initGameDB(); - } catch (e) { - console.error('Failed to initialize DB', e); - process.exit(3); + if (!gameDB.db) { + await gameDB.init(); } - if (!db || !db.sequelize) { - console.error('DB did not expose sequelize; cannot proceed.'); + let db = gameDB.db; + + if (!db.sequelize) { + console.error("DB did not expose sequelize; cannot proceed."); process.exit(4); } for (const f of files) { - // ignore dotfiles and .bk backup files (we don't want to import backups) - if (f.startsWith('.') || f.endsWith('.bk')) continue; + // ignore dotfiles and .bk backup files (we don't want to import backups) + if (f.startsWith(".") || f.endsWith(".bk")) continue; const full = path.join(gamesDir, f); try { const stat = await fs.stat(full); if (!stat.isFile()) continue; - const raw = await fs.readFile(full, 'utf8'); - const state = JSON.parse(raw); + const raw = await fs.readFile(full, "utf8"); + const game = JSON.parse(raw); // Derive id from filename (strip .json if present) - const idStr = f.endsWith('.json') ? f.slice(0, -5) : f; + const idStr = f.endsWith(".json") ? f.slice(0, -5) : f; const id = isNaN(Number(idStr)) ? idStr : Number(idStr); - // derive a friendly name from the saved state when present - const nameCandidate = (state && (state.name || state.id)) ? String(state.name || state.id) : undefined; + // derive a friendly name from the saved game when present + const nameCandidate = game && (game.name || game.id) ? String(game.name || game.id) : undefined; try { - if (typeof id === 'number') { + if (typeof id === "number") { // numeric filename: use the typed helper - await db.saveGameState(id, state); + await db.saveGame(game); console.log(`Saved game id=${id}`); if (nameCandidate) { try { - await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id, name: nameCandidate } }); + await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", { + replacements: { id, name: nameCandidate }, + }); } catch (_) { // ignore name update failure } } } else { // string filename: try to find an existing row by path and save via id; - // otherwise insert a new row with path and the JSON state. + // otherwise insert a new row with path and the JSON game. let found: any[] = []; try { - found = await db.sequelize.query('SELECT id FROM games WHERE path=:path', { + found = await db.sequelize.query("SELECT id FROM games WHERE path=:path", { replacements: { path: idStr }, - type: db.Sequelize.QueryTypes.SELECT + type: db.Sequelize.QueryTypes.SELECT, }); } catch (qe) { found = []; @@ -69,11 +69,13 @@ async function main() { if (found && found.length) { const foundId = found[0].id; - await db.saveGameState(foundId, state); + await db.saveGame(game); console.log(`Saved game path=${idStr} -> id=${foundId}`); if (nameCandidate) { try { - await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id: foundId, name: nameCandidate } }); + await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", { + replacements: { id: foundId, name: nameCandidate }, + }); } catch (_) { // ignore } @@ -81,28 +83,32 @@ async function main() { } else { // ensure state column exists before inserting a new row try { - await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT'); + await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT"); } catch (_) { // ignore } - const payload = JSON.stringify(state); + const payload = JSON.stringify(game); if (nameCandidate) { - await db.sequelize.query('INSERT INTO games (path, state, name) VALUES(:path, :state, :name)', { replacements: { path: idStr, state: payload, name: nameCandidate } }); + await db.sequelize.query("INSERT INTO games (path, state, name) VALUES(:path, :state, :name)", { + replacements: { path: idStr, state: payload, name: nameCandidate }, + }); } else { - await db.sequelize.query('INSERT INTO games (path, state) VALUES(:path, :state)', { replacements: { path: idStr, state: payload } }); + await db.sequelize.query("INSERT INTO games (path, state) VALUES(:path, :state)", { + replacements: { path: idStr, state: payload }, + }); } console.log(`Inserted game path=${idStr}`); } } } catch (e) { - console.error('Failed to save game', idStr, e); + console.error("Failed to save game", idStr, e); } } catch (e) { - console.error('Failed to read/parse', full, e); + console.error("Failed to read/parse", full, e); } } - console.log('Import complete'); + console.log("Import complete"); process.exit(0); } diff --git a/server/tools/list-games.ts b/server/tools/list-games.ts index fdad034..fa68947 100644 --- a/server/tools/list-games.ts +++ b/server/tools/list-games.ts @@ -1,5 +1,5 @@ #!/usr/bin/env ts-node -import { initGameDB } from '../routes/games/store'; +import { gameDB } from "../routes/games/store"; type Args = { gameId?: string; @@ -10,8 +10,8 @@ function parseArgs(): Args { const res: Args = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; - if ((a === '-g' || a === '--game') && args[i+1]) { - res.gameId = String(args[i+1]); + if ((a === "-g" || a === "--game") && args[i + 1]) { + res.gameId = String(args[i + 1]); i++; } } @@ -21,57 +21,56 @@ function parseArgs(): Args { async function main() { const { gameId } = parseArgs(); - let db: any; - try { - db = await initGameDB(); - } catch (e) { - console.error('Failed to initialize game DB:', e); - process.exit(1); + if (!gameDB.db) { + await gameDB.init(); } + let db = gameDB.db; - if (!db || !db.sequelize) { - console.error('DB does not expose sequelize; cannot run queries.'); + if (!db.sequelize) { + console.error("DB does not expose sequelize; cannot run queries."); process.exit(1); } if (!gameId) { // List all game ids try { - const rows: any[] = await db.sequelize.query('SELECT id, name FROM games', { type: db.Sequelize.QueryTypes.SELECT }); + const rows: any[] = await db.sequelize.query("SELECT id, name FROM games", { + type: db.Sequelize.QueryTypes.SELECT, + }); if (!rows || rows.length === 0) { - console.log('No games found.'); + console.log("No games found."); return; } - console.log('Games:'); - rows.forEach(r => console.log(`${r.id} - ${r.name}`)); + console.log("Games:"); + rows.forEach((r) => console.log(`${r.id} - ${r.name}`)); } catch (e) { - console.error('Failed to list games:', e); + console.error("Failed to list games:", e); process.exit(1); } } else { // For a given game ID, try to print the turns history from the state try { - const rows: any[] = await db.sequelize.query('SELECT state FROM games WHERE id=:id', { + const rows: any[] = await db.sequelize.query("SELECT state FROM games WHERE id=:id", { replacements: { id: gameId }, - type: db.Sequelize.QueryTypes.SELECT + type: db.Sequelize.QueryTypes.SELECT, }); if (!rows || rows.length === 0) { - console.error('Game not found:', gameId); + console.error("Game not found:", gameId); process.exit(2); } const r = rows[0] as any; let state = r.state; - if (typeof state === 'string') { + if (typeof state === "string") { try { state = JSON.parse(state); } catch (e) { - console.error('Failed to parse stored state JSON:', e); + console.error("Failed to parse stored state JSON:", e); process.exit(3); } } if (!state) { - console.error('Empty state for game', gameId); + console.error("Empty state for game", gameId); process.exit(4); } @@ -79,21 +78,21 @@ async function main() { console.log(` - turns: ${state.turns || 0}`); if (state.turnHistory || state.turnsData || state.turns_list) { const turns = state.turnHistory || state.turnsData || state.turns_list; - console.log('Turns:'); + console.log("Turns:"); turns.forEach((t: any, idx: number) => { console.log(`${idx}: ${JSON.stringify(t)}`); }); } else if (state.turns && state.turns > 0) { - console.log('No explicit turn history found inside state; showing snapshot metadata.'); + console.log("No explicit turn history found inside state; showing snapshot metadata."); // Print limited snapshot details per turn if available if (state.turnsData) { state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`)); } } else { - console.log('No turn history recorded in state.'); + console.log("No turn history recorded in state."); } } catch (e) { - console.error('Failed to load game state for', gameId, e); + console.error("Failed to load game state for", gameId, e); process.exit(1); } } diff --git a/server/util/layout.ts b/server/util/layout.ts index 5bfbf54..8068ef2 100644 --- a/server/util/layout.ts +++ b/server/util/layout.ts @@ -1,6 +1,7 @@ "use strict"; import { Tile } from "../routes/games/types"; +import { ResourceType } from "../routes/games/types"; /* Board Tiles: * 0 1 2 diff --git a/server/util/validLocations.ts b/server/util/validLocations.ts index 6504330..6edf034 100644 --- a/server/util/validLocations.ts +++ b/server/util/validLocations.ts @@ -1,4 +1,5 @@ -import { layout } from './layout'; +import { CornerType, Game, PlayerColor } from "../routes/games/types"; +import { layout } from "./layout"; const isRuleEnabled = (game: any, rule: string): boolean => { return rule in game.rules && game.rules[rule].enabled; @@ -62,28 +63,29 @@ const getValidRoads = (game: any, color: string): number[] => { }); return limits; -} +}; -const getValidCorners = (game: any, color: string, type?: string): number[] => { +const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: CornerType): number[] => { const limits: number[] = []; + console.log("getValidCorners", color, type); /* For each corner, if the corner already has a color set, skip it if type - * isn't set. If type is set, if it is a match, and the color is a match, + * isn't set. If type is set and is a match, and the color is a match, * add it to the list. - * + * * If we are limiting based on active player, a corner is only valid * if it connects to a road that is owned by that player. - * + * * If no color is set, walk each road that leaves that corner and * check to see if there is a settlement placed at the end of that road - * - * If so, this location cannot have a settlement. - * + * + * If so, this location cannot have a settlement. + * * If still valid, and we are in initial settlement placement, and if * Volcano is enabled, verify the tile is not the Volcano. */ layout.corners.forEach((corner, cornerIndex) => { - const placement = game.placements.corners[cornerIndex]; + const placement = game.placements.corners[cornerIndex]!; if (type) { if (placement.color === color && placement.type === type) { limits.push(cornerIndex); @@ -93,7 +95,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => { // If the corner has a color set and it's not the explicit sentinel // "unassigned" then it's occupied and should be skipped. - if (placement.color && placement.color !== "unassigned") { + if (placement.color !== "unassigned") { return; } @@ -107,7 +109,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => { if (rr == null) { continue; } - const placementsRoads = (game as any).placements && (game as any).placements.roads; + const placementsRoads = game.placements && game.placements.roads; valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color); } } @@ -116,11 +118,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => { if (!corner.roads) { break; } - const ridx = corner.roads[r] as number; - if (ridx == null || (layout as any).roads[ridx] == null) { + const ridx = corner.roads[r]; + if (ridx == null || layout.roads[ridx] == null) { continue; } - const road = (layout as any).roads[ridx]; + const road = layout.roads[ridx]; for (let c = 0; valid && c < (road.corners || []).length; c++) { /* This side of the road is pointing to the corner being validated. * Skip it. */ @@ -130,13 +132,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => { /* There is a settlement within one segment from this * corner, so it is invalid for settlement placement */ const cc = road.corners[c] as number; - if ( - (game as any).placements && - (game as any).placements.corners && - (game as any).placements.corners[cc] && - (game as any).placements.corners[cc].color && - (game as any).placements.corners[cc].color !== "unassigned" - ) { + if (game.placements.corners[cc]!.color !== "unassigned") { valid = false; } } @@ -149,10 +145,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => { !( game.state === "initial-placement" && isRuleEnabled(game, "volcano") && - (layout as any).tiles && - (layout as any).tiles[(game as any).robber] && - Array.isArray((layout as any).tiles[(game as any).robber].corners) && - (layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1 + game.robber && + layout.tiles && + layout.tiles[game.robber] && + Array.isArray(layout.tiles[game.robber]?.corners) && + layout.tiles[game.robber]?.corners.indexOf(cornerIndex as number) !== -1 ) ) { limits.push(cornerIndex); @@ -161,10 +158,6 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => { }); return limits; -} +}; -export { - getValidCorners, - getValidRoads, - isRuleEnabled -}; \ No newline at end of file +export { getValidCorners, getValidRoads, isRuleEnabled };