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 { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLocations"; import { Player, Game, Session, CornerPlacement, RoadPlacement, Offer, Turn } from "./games/types"; import { newPlayer } from "./games/playerFactory"; import { normalizeIncoming, shuffleArray } from "./games/utils"; import { audio as audioMap, 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, getNextPlayerSession, clearPlayer, canGiveBuilding, setForRoadPlacement, setForCityPlacement, setForSettlementPlacement, adjustResources, } from "./games/helpers"; import type { GameDB } from "./games/store"; 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; const processTies = (players: Player[]): boolean => { /* Sort the players into buckets based on their * order, and their current roll. If a resulting * roll array has more than one element, then there * is a tie that must be resolved */ let slots: Player[][] = []; players.forEach((player: Player) => { if (!slots[player.order]) { slots[player.order] = []; } slots[player.order]!.push(player); }); let ties = false, position = 1; const irstify = (position: number): string => { switch (position) { case 1: return `1st`; case 2: return `2nd`; case 3: return `3rd`; case 4: return `4th`; default: return position.toString(); } }; /* Reverse from high to low */ const rev = slots.slice().reverse(); for (const slot of rev) { const s = slot || []; if (s.length !== 1) { ties = true; s.forEach((player: Player) => { player.orderRoll = 0; /* Ties have to be re-rolled */ player.position = irstify(position); player.orderStatus = `Tied for ${irstify(position)}`; player.tied = true; }); } else if (s[0]) { s[0].tied = false; s[0].position = irstify(position); s[0].orderStatus = `Placed in ${irstify(position)}.`; } position += s.length; } return ties; }; const processGameOrder = (game: Game, player: Player, dice: number): any => { if (player.orderRoll) { return `You have already rolled for game order and are not in a tie.`; } player.orderRoll = dice; player.order = (player.order || 0) * 6 + dice; const players: Player[] = []; let doneRolling = true; for (const key in game.players) { const p = game.players[key]; if (!p) { doneRolling = false; continue; } if (!p.orderRoll) { doneRolling = false; } players.push(p); } /* If 'doneRolling' is FALSE then there are still players to roll */ if (!doneRolling) { sendUpdateToPlayers(game, { players: getFilteredPlayers(game), chat: game.chat, }); return; } /* sort updated player.order into the array */ players.sort((A, B) => { return B.order - A.order; }); console.log(`Pre process ties: `, players); if (processTies(players)) { console.log(`${info}: There are ties in player rolls:`, players); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), chat: game.chat, }); return; } addChatMessage( game, null, `Player order set to ` + players.map((player) => `${player.position}: ${player.name}`).join(", ") + `.` ); game.playerOrder = players.map((player) => player.color as string); game.state = "initial-placement"; (game as any)["direction"] = "forward"; const first = players[0]; game.turn = { name: first?.name as string, color: first?.color as string, }; 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.`); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), state: game.state, direction: (game as any)["direction"], turn: game.turn, chat: game.chat, activities: game.activities, }); }; const processVolcano = (game: Game, session: Session, dice: number[]): any => { const name = session.name ? session.name : "Unnamed"; void session.player; const volcano = layout.tiles.findIndex((_tile, index) => { const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined; return typeof tileIndex === "number" && !!staticData.tiles && staticData.tiles[tileIndex]?.type === "desert"; }); /* Find the volcano tile */ console.log(`${info}: Processing volcano roll!`, { dice }); addChatMessage(game, session, `${name} rolled ${dice[0]} for the Volcano!`); game.dice = dice; game.state = "normal"; if (volcano !== -1 && layout.tiles?.[volcano] && dice && dice[0] !== undefined) { const corners = layout.tiles[volcano].corners; if (corners && corners[dice[0] % 6] !== undefined) { game.turn.volcano = corners[dice[0] % 6]; } } const volcanoIdx = typeof game.turn.volcano === "number" ? game.turn.volcano : undefined; const corner = volcanoIdx !== undefined ? game.placements.corners[volcanoIdx] : undefined; if (corner && corner.color) { const player = game.players[corner.color]; if (player) { if (corner.type === "city") { if (player.settlements && player.settlements > 0) { addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); player.cities = (player.cities || 0) + 1; player.settlements = (player.settlements || 0) - 1; corner.type = "settlement"; } else { addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`); delete corner.type; delete corner.color; player.cities = (player.cities || 0) + 1; } } else { addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); delete corner.type; delete corner.color; player.settlements = (player.settlements || 0) + 1; } } } sendUpdateToPlayers(game, { turn: game.turn, state: game.state, chat: game.chat, dice: game.dice, placements: game.placements, players: getFilteredPlayers(game), }); }; const roll = (game: Game, session: Session, dice?: number[] | undefined): any => { const player = session.player as Player, name = session.name ? session.name : "Unnamed"; if (!dice) { dice = [Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6)]; } switch (game.state) { case "lobby": /* currently not available as roll is only after color is * set for players */ addChatMessage(game, session, `${name} rolled ${dice[0]}.`); sendUpdateToPlayers(game, { chat: game.chat }); return undefined; case "game-order": (game as any)["startTime"] = Date.now(); addChatMessage(game, session, `${name} rolled ${dice[0]}.`); if (typeof dice[0] !== "number") { return `Invalid roll value.`; } return processGameOrder(game, player, dice[0]); case "normal": if (game.turn.color !== session.color) { return `It is not your turn.`; } if (game.turn.roll) { return `You already rolled this turn.`; } processRoll(game, session, dice); return; case "volcano": if (game.turn.color !== session.color) { return `It is not your turn.`; } if (game.turn.select) { return `You can not roll for the Volcano until all players have mined their resources.`; } /* Only use the first die for the Volcano roll */ if (typeof dice[0] !== "number") { return `Invalid roll value.`; } processVolcano(game, session, [dice[0]]); return; default: return `Invalid game state (${game.state}) in roll.`; } }; const sessionFromColor = (game: Game, color: string): Session | undefined => { for (const key in game.sessions) { const s = game.sessions[key]; if (s && s.color === color) { return s; } } return undefined; }; const distributeResources = (game: Game, roll: number): void => { console.log(`Roll: ${roll}`); /* Find which tiles have this roll */ const matchedTiles: { robber: boolean; index: number }[] = []; const pipOrder = game.pipOrder || []; for (let i = 0; i < pipOrder.length; i++) { const index = pipOrder[i]; if (typeof index === "number" && staticData.pips?.[index] && staticData.pips[index].roll === roll) { matchedTiles.push({ robber: game.robber === i, index: i }); } } const receives: Record> = { O: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, R: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, W: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, B: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, robber: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, }; /* Find which corners are on each tile */ matchedTiles.forEach((tile) => { const tileOrder = game.tileOrder || []; const gameTiles = game.tiles || []; const shuffle = tileOrder[tile.index]; const resource = typeof shuffle === "number" ? gameTiles[shuffle] : undefined; const tileLayout = layout.tiles?.[tile.index]; tileLayout?.corners.forEach((cornerIndex: number) => { const active = game.placements.corners?.[cornerIndex]; if (active && active.color && resource) { const count = active.type === "settlement" ? 1 : 2; if (!tile.robber) { if (!receives[active.color]) receives[active.color] = { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }; if (resource && resource.type) (receives as any)[active.color][resource.type] += count; } else { const victim = game.players[active.color]; if (isRuleEnabled(game, `robin-hood-robber`) && victim && (victim.points || 0) <= 2) { addChatMessage( game, null, `Robber does not steal ${count} ${resource.type} from ${victim?.name} due to Robin Hood Robber house rule.` ); if (resource && resource.type) (receives as any)[active.color][resource.type] += count; } else { trackTheft(game, active.color, "robber", resource.type, count); if (resource && resource.type) (receives as any)["robber"][resource.type] += count; } } } }); }); const robberList: string[] = []; for (const color in receives) { const entry = receives[color]; if (!entry || !(entry["wood"] || entry["brick"] || entry["sheep"] || entry["wheat"] || entry["stone"])) { continue; } const messageParts: string[] = []; let s: Session | undefined; for (const type in entry) { if (entry[type] === 0) continue; if (color !== "robber") { s = sessionFromColor(game, color); if (!s || !s.player) continue; (s.player as any)[type] = ((s.player as any)[type] || 0) + entry[type]; (s.player as any).resources = ((s.player as any).resources || 0) + entry[type]; messageParts.push(`${entry[type]} ${type}`); } else { robberList.push(`${entry[type]} ${type}`); } } if (s) { addChatMessage(game, s, `${s.name} receives ${messageParts.join(", ")} for pip ${roll}.`); } } if (robberList.length) { addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robberList.join(", ")}!`); } }; 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!`); return; } addChatMessage(game, session, `${session.name} rolled ` + `${dice[0]}, ${dice[1]}.`); const sum = dice && dice[0] !== undefined && dice[1] !== undefined ? dice[0] + dice[1] : 0; game.dice = dice; game.turn.roll = sum; if (game.turn.roll !== 7) { let synonym = isRuleEnabled(game, "twelve-and-two-are-synonyms") && (sum === 2 || sum === 12); distributeResources(game, game.turn.roll); if (isRuleEnabled(game, "twelve-and-two-are-synonyms")) { if (sum === 12) { addChatMessage( game, session, `House rule 'Twelve and Two are Synonyms' activated. Twelve was rolled, so two is triggered too!` ); distributeResources(game, 2); } if (sum === 2) { addChatMessage( game, session, `House rule 'Twelve and Two are Synonyms' activated. Two was rolled, so twelve is triggered too!` ); distributeResources(game, 12); } } if (isRuleEnabled(game, "roll-double-roll-again")) { if (dice[0] === dice[1]) { addChatMessage( game, session, `House rule 'Roll Double, Roll Again' activated.` ); game.turn.roll = 0; } } if (isRuleEnabled(game, "volcano")) { if ( sum === parseInt(game.rules["volcano"].number) || (synonym && (game.rules["volcano"].number === 2 || game.rules["volcano"].number === 12)) ) { addChatMessage( game, session, `House rule 'Volcano' activated. The Volcano is erupting!` ); game.state = "volcano"; let count = 0; if (game.rules["volcano"].gold) { game.turn.select = {}; const volcanoIdx = layout.tiles.findIndex((_tile, index) => { const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined; return typeof tileIndex === "number" && !!staticData.tiles && staticData.tiles[tileIndex]?.type === "desert"; }); if (volcanoIdx !== -1 && layout.tiles[volcanoIdx]) { const vCorners = layout.tiles[volcanoIdx].corners || []; vCorners.forEach((index: number) => { const corner = game.placements.corners[index]; if (corner && corner.color) { if (!game.turn.select) { game.turn.select = {} as Record; } if (!game.turn.select) { game.turn.select = {} as Record; } if (!(corner.color in game.turn.select)) { game.turn.select[corner.color] = 0; } game.turn.select[corner.color] = (game.turn.select[corner.color] || 0) + (corner.type === "settlement" ? 1 : 2); count += corner.type === "settlement" ? 1 : 2; } }); } console.log(`Volcano! - `, { mode: "gold", selected: game.turn.select, }); if (count) { /* To gain volcano resources, you need at least 3 settlements, * so Robin Hood Robber does not apply */ if (volcanoIdx === game.robber) { addChatMessage( game, null, `That pesky ${game.robberName} Robber Roberson blocked ${count} volcanic mineral resources!` ); addChatMessage( game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!` ); if (game.turn.select) delete game.turn.select; } else { addChatMessage( game, null, `House rule 'Volcanoes have minerals' activated. Players must select which resources to receive from the Volcano!` ); game.turn.actions = ["select-resources"]; game.turn.active = "volcano"; } } else { addChatMessage( game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!` ); delete game.turn.select; } } } } for (let id in game.sessions) { const _sess = game.sessions[id]; if (_sess && _sess.player) { sendUpdateToPlayer(game, _sess, { private: _sess.player, }); } } sendUpdateToPlayers(game, { turn: game.turn, players: getFilteredPlayers(game), chat: game.chat, dice: game.dice, state: game.state, }); return; } /* ROBBER Robber Robinson! */ game.turn.robberInAction = true; delete game.turn.placedRobber; const mustDiscard = []; for (let id in game.sessions) { const player = game.sessions[id]?.player; if (player) { let discard = (player.stone || 0) + (player.wheat || 0) + (player.brick || 0) + (player.wood || 0) + (player.sheep || 0); if (discard > 7) { discard = Math.floor(discard / 2); player.mustDiscard = discard; mustDiscard.push(player); } else { delete player.mustDiscard; } } } if (mustDiscard.length === 0) { addChatMessage(game, null, `ROBBER! ${game.robberName} Robber Roberson has fled, and no one had to discard!`); 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++) { if (i === game.robber) { continue; } game.turn.limits.pips.push(i); } } else { mustDiscard.forEach((player) => { addChatMessage( game, null, `The robber was rolled and ${player.name} must discard ${player.mustDiscard} resource cards!` ); for (let key in game.sessions) { const _sess = game.sessions[key]; if (_sess && _sess.player === player) { sendUpdateToPlayer(game, _sess, { private: player, }); break; } } }); } sendUpdateToPlayers(game, { turn: game.turn, players: getFilteredPlayers(game), chat: game.chat, dice: game.dice, }); }; // newPlayer is provided by ./games/playerFactory const getSession = (game: Game, id: string) => { if (!game.sessions) { game.sessions = {}; } /* If this session is not yet in the game, add it and set the player's name */ if (!(id in game.sessions)) { game.sessions[id] = { id: `[${id.substring(0, 8)}]`, name: "", color: "", lastActive: Date.now(), live: true, } as unknown as Session; } const session = game.sessions[id]!; session.lastActive = Date.now(); session.live = true; if (session.player) { session.player.live = true; session.player.lastActive = session.lastActive; } /* Expire old unused sessions */ for (let _id in game.sessions) { const _session = game.sessions[_id]; if (!_session) { continue; } if (_session.color || _session.name || _session.player) { continue; } if (_id === id) { continue; } /* 60 minutes */ const age = Date.now() - (_session.lastActive || 0); if (age > 60 * 60 * 1000) { console.log(`${_session.id}: Expiring old session ${_id}: ${age / (60 * 1000)} minutes`); delete game.sessions[_id]; if (_id in game.sessions) { console.log("delete DID NOT WORK!"); } } } return game.sessions[id]; }; const loadGame = async (id: string) => { if (/^\.|\//.exec(id)) { return undefined; } if (id in games) { // If we have a cached game in memory, ensure any ephemeral flags that // control per-session lifecycle (like _initialSnapshotSent) are cleared // so that a newly attached websocket will receive the consolidated // initial snapshot. This is important for long-running dev servers // where the in-memory cache may persist between reconnects. const cached = games[id]!; for (let sid in cached.sessions) { if (cached.sessions[sid] && cached.sessions[sid]._initialSnapshotSent) { delete cached.sessions[sid]._initialSnapshotSent; } } 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; try { game = await gameDB.getGameById(id); } catch (e) { console.error(`${info}: gameDB.getGameById error`, e); game = null; } if (!game) { game = await createGame(id); // Persist the newly-created game immediately try { await gameDB.saveGameState(game.id, game); } catch (e) { console.error(`${info}: Failed to persist newly created game ${game.id}`, e); } } /* 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"; } /* Reconnect session player colors to the player objects */ game.unselected = []; for (let id in game.sessions) { const session = game.sessions[id]; if (session.name && session.color && session.color in game.players) { 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.live = false; // Ensure we treat initial snapshot as unsent on (re)load so new socket // attachments will get a fresh 'initial-game' message. if (session._initialSnapshotSent) { delete session._initialSnapshotSent; } /* Populate the 'unselected' list from the session table */ if (!game.sessions[id].color && game.sessions[id].name) { game.unselected.push(game.sessions[id]); } } games[id] = game; return game; }; const adminCommands = (game: Game, action: string, value: string, query: any): any => { let color: string | undefined, parts: RegExpMatchArray | null, session: Session | any, corners: any, corner: any, error: any; void color; switch (action) { case "rules": const rule = value.replace(/=.*$/, ""); if (rule === "list") { const rules: any = {}; for (let key in supportedRules) { if (game.rules[key]) { rules[key] = game.rules[key]; } else { rules[key] = { enabled: false }; } } return JSON.stringify(rules, null, 2); } let values = value.replace(/^.*=/, "").split(","); const rulesObj: Record = {}; rulesObj[rule] = {}; values.forEach((keypair) => { let [key, val] = keypair.split(":"); let parsed: any = val; if (val === "true") { parsed = true; } else if (val === "false") { parsed = false; } else if (typeof val === "string" && !isNaN(parseInt(val))) { parsed = parseInt(val); } if (rule && key) rulesObj[rule][key] = parsed; }); console.log(`admin - setRules -`, rulesObj); setRules(game, undefined, rulesObj); break; case "debug": if (parseInt(value) === 0 || value === "false") { delete game.debug; } else { game.debug = true; } break; case "give": parts = value.match(/^([^-]+)(-(.*))?$/); if (!parts) { return `Unable to parse give request.`; } const type = parts[1], card = parts[3] || 1; if (game.sessions) { for (let id in game.sessions) { if (game.sessions[id] && game.sessions[id].name === game.turn.name) { session = game.sessions[id]; } } } if (!session) { return `Unable to determine current player turn to give resources.`; } let done = true; switch (type) { case "road": error = canGiveBuilding(game); if (error) { return error; } if (session.player.roads === 0) { return `Player ${game.turn.name} does not have any more roads to give.`; } let roads = getValidRoads(game, session.color); if (roads.length === 0) { return `There are no valid locations for ${game.turn.name} to place a road.`; } game.turn.free = true; setForRoadPlacement(game, roads); addChatMessage(game, null, `Admin gave a road to ${game.turn.name}.` + `They must now place the road.`); break; case "city": error = canGiveBuilding(game); if (error) { return error; } if (session.player.cities === 0) { return `Player ${game.turn.name} does not have any more cities to give.`; } corners = getValidCorners(game, session.color, "settlement"); if (corners.length === 0) { return `There are no valid locations for ${game.turn.name} to place a settlement.`; } game.turn.free = true; setForCityPlacement(game, corners); addChatMessage(game, null, `Admin gave a city to ${game.turn.name}. ` + `They must now place the city.`); break; case "settlement": error = canGiveBuilding(game); if (error) { return error; } if (session.player.settlements === 0) { return `Player ${game.turn.name} does not have any more settlements to give.`; } corners = getValidCorners(game, session.color); if (corners.length === 0) { return `There are no valid locations for ${game.turn.name} to place a settlement.`; } game.turn.free = true; setForSettlementPlacement(game, corners); addChatMessage( game, null, `Admin gave a settlment to ${game.turn.name}. ` + `They must now place the settlement.` ); break; case "wheat": case "sheep": case "wood": case "stone": case "brick": const count = parseInt(String(card)); session.player[type] += count; session.resources += count; addChatMessage(game, null, `Admin gave ${count} ${type} to ${game.turn.name}.`); break; default: done = false; break; } if (done) { break; } const index = game.developmentCards.findIndex((item: any) => item.card.toString() === card && item.type === type); if (index === -1) { console.log({ card, type }, game.developmentCards); return `Unable to find ${type}-${card} in the current deck of development cards.`; } let tmp = game.developmentCards.splice(index, 1)[0]; if (tmp) { (tmp as any)["turn"] = game.turns ? game.turns - 1 : 0; session.player.development.push(tmp); } addChatMessage(game, null, `Admin gave a ${card}-${type} to ${game.turn.name}.`); break; case "cards": let results = game.developmentCards.map((card: any) => `${card.type}-${card.card}`).join(", "); return results; case "roll": let diceRaw = (query.dice || Math.ceil(Math.random() * 6)).toString(); let dice = diceRaw.split(",").map((die: string) => parseInt(die)); console.log({ dice }); if (!value) { return `Unable to parse roll request.`; } switch (value) { case "orange": color = "O"; break; case "red": color = "R"; break; case "blue": color = "B"; break; case "white": color = "W"; break; } if (corner && corner.color) { const player = game.players ? game.players[corner.color] : undefined; if (player) { if (corner.type === "city") { if (player.settlements) { addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); player.cities = (player.cities || 0) + 1; player.settlements = (player.settlements || 1) - 1; corner.type = "settlement"; } else { addChatMessage( game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!` ); delete corner.type; delete corner.color; player.cities = (player.cities || 0) + 1; } } else { addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); delete corner.type; delete corner.color; player.settlements = (player.settlements || 0) + 1; } } } if (!session) { return `Unable to determine current player turn for admin roll.`; } let warning = roll(game, session, dice); if (warning) { sendWarning(session, warning); } break; case "pass": let name = game.turn.name; const next = getNextPlayerSession(game, name || ""); if (!next) { addChatMessage(game, null, `Admin attempted to skip turn but no next player was found.`); break; } game.turn = { name: next.name, color: next.color, } as unknown as Turn; game.turns = (game.turns || 0) + 1; startTurnTimer(game, next); addChatMessage(game, null, `The admin skipped ${name}'s turn.`); addChatMessage(game, null, `It is ${next.name}'s turn.`); break; case "kick": switch (value) { case "orange": color = "O"; break; case "red": color = "R"; break; case "blue": color = "B"; break; case "white": color = "W"; break; } if (corner && corner.color) { const player = game.players[corner.color]; if (player) { if (corner.type === "city") { if (player.settlements && player.settlements > 0) { addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); player.cities = (player.cities || 0) + 1; player.settlements = (player.settlements || 0) - 1; corner.type = "settlement"; } else { addChatMessage( game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!` ); delete corner.type; delete corner.color; player.cities = (player.cities || 0) + 1; } } else { addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); delete corner.type; delete corner.color; player.settlements = (player.settlements || 0) + 1; } } } break; case "state": if (game.state !== "lobby") { return `Game already started.`; } if (!game.active || game.active < 2) { return `Not enough players in game to start.`; } game.state = "game-order"; /* Delete any non-played colors from the player map; reduces all * code that would otherwise have to filter out players by checking * the 'Not active' state of player.status */ for (let key in game.players) { const p = game.players[key]; if (!p || p.status !== "Active") { delete game.players[key]; } } addChatMessage(game, null, `Admin requested to start the game.`); break; default: return `Invalid admin action ${action}.`; } }; const setPlayerName = (game: Game, session: Session, name: string): string | undefined => { if (session.name === name) { return; /* no-op */ } if (session.color) { return `You cannot change your name while you have a color selected.`; } if (!name) { return `You can not set your name to nothing!`; } if (name.toLowerCase() === "the bank") { return `You cannot play as the bank!`; } /* Check to ensure name is not already in use */ let rejoin = false; for (let id in game.sessions) { const tmp = game.sessions[id]; if (!tmp || tmp === session || !tmp.name) { continue; } if (tmp.name.toLowerCase() === name.toLowerCase()) { if (!tmp.player || Date.now() - (tmp.player.lastActive || 0) > 60000) { rejoin = true; /* Update the session object from tmp, but retain websocket * from active session */ Object.assign(session, tmp, { ws: session.ws, id: session.id }); console.log(`${info}: ${name} has been reallocated to a new session.`); delete game.sessions[id]; } else { return `${name} is already taken and has been active in the last minute.`; } } } let message; if (!session.name) { message = `A new player has entered the lobby as ${name}.`; } else { if (rejoin) { if (session.color) { message = `${name} has reconnected to the game.`; } else { message = `${name} has rejoined the lobby.`; } session.name = name; if (session.ws && game.id in audio && session.name in audio[game.id]) { webrtcPart(audio[game.id], session); } } else { message = `${session.name} has changed their name to ${name}.`; if (session.ws && game.id in audio) { webrtcPart(audio[game.id], session); } } } session.name = name; session.live = true; if (session.player) { session.color = session.player.color || ""; session.player.name = session.name; session.player.status = `Active`; session.player.lastActive = Date.now(); session.player.name = name; session.player.live = true; } if (session.ws && session.hasAudio) { webrtcJoin(audio[game.id], session, { hasVideo: session.video ? true : false, hasAudio: session.audio ? true : false, }); } console.log(`${info}: ${message}`); addChatMessage(game, null, message); /* Rebuild the unselected list */ if (!session.color) { console.log(`${info}: Adding ${session.name} to the unselected`); } game.unselected = []; for (let id in game.sessions) { const s = game.sessions[id]; if (!s) continue; if (!s.color && s.name) { game.unselected.push(s); } } sendUpdateToPlayer(game, session, { name: session.name, color: session.color, live: session.live, private: session.player, }); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), participants: getParticipants(game), unselected: getFilteredUnselected(game), chat: game.chat, }); /* Now that a name is set, send the full game to the player */ sendGameToPlayer(game, session); return undefined; }; const colorToWord = (color: string): string => { switch (color) { case "O": return "orange"; case "W": return "white"; case "B": return "blue"; case "R": return "red"; default: return ""; } }; const getActiveCount = (game: Game): number => { let active = 0; for (let color in game.players) { const p = game.players[color]; if (!p || !p.name) { continue; } active++; } return active; }; const setPlayerColor = (game: Game, session: Session, color: string): string | undefined => { /* Selecting the same color is a NO-OP */ if (session.color === color) { return; } /* Verify the player has a name set */ if (!session.name) { return `You may only select a player when you have set your name.`; } if (game.state !== "lobby") { return `You may only select a player when the game is in the lobby.`; } /* Verify selection is valid */ if (color && !(color in game.players)) { return `An invalid player selection was attempted.`; } /* Verify selection is not already taken */ if (color) { const candidate = game.players[color]; if (!candidate) { return `An invalid player selection was attempted.`; } if (candidate.status !== "Not active") { return `${candidate.name} already has ${colorToWord(color)}`; } } let active = getActiveCount(game); if (session.player) { /* Deselect currently active player for this session */ clearPlayer(session.player); // remove the player association delete (session as any).player; const old_color = session.color; session.color = ""; active--; /* If the player is not selecting a color, then return */ if (!color) { const msg = String(session.name || "") + " is no longer " + String(colorToWord(String(old_color))); addChatMessage(game, null, msg); if (!game.unselected) game.unselected = [] as any[]; game.unselected.push(session); game.active = active; if (active === 1) { addChatMessage(game, null, `There are no longer enough players to start a game.`); } sendUpdateToPlayer(game, session, { name: session.name, color: "", live: session.live, private: session.player, }); sendUpdateToPlayers(game, { active: game.active, unselected: getFilteredUnselected(game), players: getFilteredPlayers(game), chat: game.chat, }); return; } } /* All good -- set this player to requested selection */ active++; session.color = color; session.live = true; const picked = game.players[color]; if (picked) { (session as any).player = picked; picked.name = session.name; picked.status = `Active`; picked.lastActive = Date.now(); picked.live = true; } addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`); const update: any = { players: getFilteredPlayers(game), participants: getParticipants(game), chat: game.chat, }; /* Rebuild the unselected list */ const unselected = []; for (let id in game.sessions) { const s = game.sessions[id]; if (!s) continue; if (!s.color && s.name) { unselected.push(s); } } if (!game.unselected) game.unselected = [] as any[]; if (unselected.length !== game.unselected.length) { game.unselected = unselected; update.unselected = getFilteredUnselected(game); } if (!game.active) game.active = 0; if (game.active !== active) { if (game.active < 2 && active >= 2) { addChatMessage(game, null, `There are now enough players to start the game.`); } game.active = active; update.active = game.active; } sendUpdateToPlayer(game, session, { name: session.name, color: session.color, live: session.live, private: session.player, }); sendUpdateToPlayers(game, update); return undefined; }; const processCorner = (game: Game, color: string, cornerIndex: number, placedCorner: CornerPlacement): number => { /* If this corner is allocated and isn't assigned to the walking color, skip it */ if (placedCorner.color && placedCorner.color !== color) { return 0; } /* If this corner is already being walked, skip it */ if (placedCorner.walking) { return 0; } placedCorner.walking = true; /* Calculate the longest road branching from both corners */ let longest = 0; layout.corners?.[cornerIndex]?.roads.forEach((roadIndex: number) => { const placedRoad = game.placements.roads[roadIndex]; if (!placedRoad) return; if (placedRoad.walking) { return; } const tmp = processRoad(game, color, roadIndex, placedRoad); longest = Math.max(tmp, longest); /*if (tmp > longest) { longest = tmp; placedCorner.longestRoad = roadIndex; placedCorner.longest } longest = Math.max( */ }); return longest; }; const buildCornerGraph = ( game: Game, color: string, cornerIndex: number, placedCorner: CornerPlacement, set: any ): void => { /* If this corner is allocated and isn't assigned to the walking color, skip it */ if (placedCorner.color && placedCorner.color !== color) { return; } /* If this corner is already being walked, skip it */ if (placedCorner.walking) { return; } placedCorner.walking = true; /* Calculate the longest road branching from both corners */ layout.corners?.[cornerIndex]?.roads.forEach((roadIndex: number) => { const placedRoad = game.placements.roads[roadIndex]; if (!placedRoad) return; buildRoadGraph(game, color, roadIndex, placedRoad, set); }); }; const processRoad = (game: Game, color: string, roadIndex: number, placedRoad: RoadPlacement): number => { /* If this road isn't assigned to the walking color, skip it */ if (placedRoad.color !== color) { return 0; } /* If this road is already being walked, skip it */ if (placedRoad.walking) { return 0; } placedRoad.walking = true; /* Calculate the longest road branching from both corners */ let roadLength = 1; layout.roads?.[roadIndex]?.corners.forEach((cornerIndex: number) => { const placedCorner = game.placements.corners?.[cornerIndex]; if (!placedCorner) return; if (placedCorner.walking) { return; } roadLength += processCorner(game, color, cornerIndex, placedCorner); }); return roadLength; }; const buildRoadGraph = (game: Game, color: string, roadIndex: number, placedRoad: RoadPlacement, set: number[]) => { /* If this road isn't assigned to the walking color, skip it */ if (placedRoad.color !== color) { return; } /* If this road is already being walked, skip it */ if (placedRoad.walking) { return; } placedRoad.walking = true; set.push(roadIndex); /* Calculate the longest road branching from both corners */ layout.roads?.[roadIndex]?.corners.forEach((cornerIndex: number) => { const placedCorner = game.placements?.corners?.[cornerIndex]; if (!placedCorner) return; buildCornerGraph(game, color, cornerIndex, placedCorner, set); }); }; const clearRoadWalking = (game: Game): void => { /* Clear out walk markers on roads */ layout.roads.forEach((_item, itemIndex) => { if (game.placements?.roads?.[itemIndex]) { delete game.placements.roads[itemIndex].walking; } }); /* Clear out walk markers on corners */ layout.corners.forEach((_item, itemIndex) => { if (game.placements?.corners?.[itemIndex]) { delete game.placements.corners[itemIndex].walking; } }); }; const calculateRoadLengths = (game: Game, session: Session): void => { clearRoadWalking(game); let currentLongest = game.longestRoad, currentLength = currentLongest && typeof currentLongest === "string" && game.players[currentLongest] ? game.players[currentLongest].longestRoad || -1 : -1; /* Clear out player longest road counts */ for (let key in game.players) { if (game.players[key]) { game.players[key].longestRoad = 0; } } /* Build a set of connected road graphs. Once all graphs are * constructed, walk through each graph, starting from each * location in the graph. If the length ever equals the * number of items in the graph, short circuit--longest path. * Otherwise, check all paths from each segment. This is * needed to catch loops where starting from an outside end * point may result in not counting the length of the loop */ let graphs: { color: string; set: number[]; longestRoad?: number; longestStartSegment?: number }[] = []; layout.roads.forEach((_: any, roadIndex: number) => { const placedRoad = game.placements?.roads?.[roadIndex]; if (placedRoad && placedRoad.color && typeof placedRoad.color === "string") { let set: number[] = []; buildRoadGraph(game, placedRoad.color, roadIndex, placedRoad, set); if (set.length) { graphs.push({ color: placedRoad.color, set }); } } }); if (debug.road) console.log("Graphs A:", graphs); clearRoadWalking(game); graphs.forEach((graph: any) => { graph.longestRoad = 0; graph.set.forEach((roadIndex: number) => { const placedRoad = game.placements.roads[roadIndex]; if (!placedRoad) return; clearRoadWalking(game); const length = processRoad(game, placedRoad.color as string, roadIndex, placedRoad); if (length >= graph.longestRoad) { graph.longestStartSegment = roadIndex; graph.longestRoad = length; } }); }); if (debug.road) console.log("Graphs B:", graphs); if (debug.road) console.log( "Pre update:", game.placements.roads.filter((road) => road.color) ); for (let color in game.players) { if (game.players[color]?.status === "Not active") { continue; } if (game.players[color]) { game.players[color].longestRoad = 0; } } graphs.forEach((graph: any) => { graph.set.forEach((roadIndex: number) => { const placedRoad = game.placements.roads[roadIndex]; if (!placedRoad) return; clearRoadWalking(game); const longestRoad = processRoad(game, placedRoad.color as string, roadIndex, placedRoad); placedRoad["longestRoad"] = longestRoad; if (placedRoad.color && typeof placedRoad.color === "string") { const player = game.players[placedRoad.color]; if (player) { const prevVal = player["longestRoad"] || 0; player["longestRoad"] = Math.max(prevVal, longestRoad); } } }); }); game.placements.roads.forEach((road: any) => delete road.walking); if (debug.road) console.log( "Post update:", game.placements.roads.filter((road: any) => road.color) ); let checkForTies = false; if (debug.road) console.log(currentLongest, currentLength); if (currentLongest && typeof currentLongest === "string" && game.players[currentLongest]) { const playerLongest = game.players[currentLongest]["longestRoad"] || 0; if (playerLongest < currentLength) { const prevSession = sessionFromColor(game, currentLongest as string); if (prevSession) { addChatMessage(game, prevSession, `${prevSession.name} had their longest road split!`); } checkForTies = true; } } let longestRoad = 4; let longestPlayers: Player[] = []; for (let key in game.players) { const player = game.players[key]; if (!player || player.status === "Not active") { continue; } const pLen = player.longestRoad || 0; if (pLen > longestRoad) { longestPlayers = [player]; longestRoad = pLen; } else if (pLen === longestRoad) { if (longestRoad >= 5) { longestPlayers.push(player); } } } console.log({ longestPlayers }); if (longestPlayers.length > 0) { if (longestPlayers.length === 1) { game.longestRoadLength = longestRoad; if (longestPlayers[0] && longestPlayers[0].color) { if (game.longestRoad !== longestPlayers[0].color) { game.longestRoad = longestPlayers[0].color; addChatMessage(game, session, `${longestPlayers[0].name} now has the longest ` + `road (${longestRoad})!`); } } } else { if (checkForTies) { game.longestRoadLength = longestRoad; const names = longestPlayers.map((player) => player.name); addChatMessage(game, session, `${names.join(", ")} are tied for longest ` + `road (${longestRoad})!`); } /* Do not reset the longest road! Current Longest is still longest! */ } } else { game.longestRoad = false; game.longestRoadLength = 0; } }; const isCompatibleOffer = (player: Player, offer: Offer): boolean => { const isBank = (offer as any)["name"] === "The bank"; const playerGetsLen = (player as any)["gets"] ? (player as any)["gets"].length : 0; const playerGivesLen = (player as any)["gives"] ? (player as any)["gives"].length : 0; const offerGetsLen = (offer as any)["gets"] ? (offer as any)["gets"].length : 0; const offerGivesLen = (offer as any)["gives"] ? (offer as any)["gives"].length : 0; let valid = playerGetsLen === offerGivesLen && playerGivesLen === offerGetsLen; if (!valid) { console.log(`Gives and gets lengths do not match!`); return false; } console.log( { player: "Submitting player", gets: (player as any)["gets"], gives: (player as any)["gives"], }, { name: (offer as any)["name"], gets: (offer as any)["gets"], gives: (offer as any)["gives"], } ); for (const get of (player as any)["gets"] || []) { if ( !(offer as any)["gives"] || !(offer as any)["gives"].some((item: any) => (item.type === get.type || isBank) && item.count === get.count) ) { valid = false; break; } } if (valid) { for (const give of (player as any)["gives"] || []) { if ( !(offer as any)["gets"] || !(offer as any)["gets"].some((item: any) => (item.type === give.type || isBank) && item.count === give.count) ) { valid = false; break; } } } return valid; }; const isSameOffer = (player: Player, offer: Offer): boolean => { const isBank = (offer as any)["name"] === "The bank"; if (isBank) { return false; } if (!(player as any)["gets"] || !(player as any)["gives"] || !(offer as any)["gets"] || !(offer as any)["gives"]) { return false; } if ( (player as any)["gets"].length !== (offer as any)["gets"].length || (player as any)["gives"].length !== (offer as any)["gives"].length ) { return false; } for (const get of (player as any)["gets"]) { if (!(offer as any)["gets"].find((item: any) => item.type === get.type && item.count === get.count)) { return false; } } for (const give of (player as any)["gives"]) { if (!(offer as any)["gives"].find((item: any) => item.type === give.type && item.count === give.count)) { return false; } } return true; }; /* Verifies player can meet the offer */ const checkPlayerOffer = (_game: Game, player: Player, offer: Offer): string | undefined => { let error: string | undefined = undefined; const name = player.name || "Unknown"; console.log({ checkPlayerOffer: { name, player, gets: offer.gets, gives: offer.gives, sheep: player.sheep, wheat: player.wheat, brick: player.brick, stone: player.stone, wood: player.wood, description: offerToString(offer), }, }); for (const give of (offer as any)["gives"] || []) { if (error) break; if (!(give.type in (player as any))) { error = `${give.type} is not a valid resource!`; break; } if (give.count <= 0) { error = `${give.count} must be more than 0!`; break; } if ((player as any)[give.type] < give.count) { error = `${name} does do not have ${give.count} ${give.type}!`; break; } if (((offer as any)["gets"] || []).find((get: any) => give.type === get.type)) { error = `${name} can not give and get the same resource type!`; break; } } if (!error) { for (const get of (offer as any)["gets"] || []) { if (error) break; if (get.count <= 0) { error = `${get.count} must be more than 0!`; break; } if (((offer as any)["gives"] || []).find((give: any) => get.type === give.type)) { error = `${name} can not give and get the same resource type!`; break; } } } return error; }; const canMeetOffer = (player: Player, offer: Offer): boolean => { for (const get of (offer as any)["gets"] || []) { if (get.type === "bank") { const giveType = (player as any)["gives"] && (player as any)["gives"][0] ? (player as any)["gives"][0].type : undefined; if (!giveType) return false; if ((player as any)[giveType] < get.count || get.count <= 0) { return false; } } else if ((player as any)[get.type] < get.count || get.count <= 0) { return false; } } 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 = [], pips = [], tiles = []; for (let i = 0; i < 6; i++) { const parsed = parseInt(border.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(parsed)) return false; borders[i] = parsed ^ salt; if (borders[i]! > 6) { return false; } } for (let i = 0; i < 19; 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) { return false; } } for (let i = 0; i < 19; 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) { return false; } } game.borderOrder = borders; game.pipOrder = pips; game.tileOrder = tiles; return true; }; const offerToString = (offer: Offer): string => { return ( (offer.gives || []).map((item) => `${item.count} ${item.type}`).join(", ") + " in exchange for " + (offer.gets || []).map((item) => `${item.count} ${item.type}`).join(", ") ); }; router.put("/:id/:action/:value?", async (req, res) => { const { action, id } = req.params, value = req.params.value ? req.params.value : ""; console.log(`PUT games/${id}/${action}/${value}`); const game = await loadGame(id); if (!game) { const error = `Game not found and cannot be created: ${id}`; return res.status(404).send(error); } let error = "Invalid request"; if ("private-token" in req.headers) { if (req.headers["private-token"] !== req.app.get("admin")) { error = `Invalid admin credentials.`; } else { error = adminCommands(game, action, value, req.query); } if (!error) { sendGameToPlayers(game); } else { console.log(`admin-action error: ${error}`); } } return res.status(400).send(error); }); const startTrade = (game: Game, session: Session): string | undefined => { /* Only the active player can begin trading */ if (game.turn.name !== session.name) { return `You cannot start trading negotiations when it is not your turn.`; } /* Clear any free gives if the player begins trading */ if (game.turn.free) { delete game.turn.free; } game.turn.actions = ["trade"]; game.turn.limits = {}; for (let key in game.players) { const p = game.players[key]; if (!p) continue; (p as any)["gives"] = []; (p as any)["gets"] = []; delete (p as any)["offerRejected"]; } addActivity(game, session, `${session.name} requested to begin trading negotiations.`); return undefined; }; const cancelTrade = (game: Game, session: Session): string | undefined => { /* TODO: Perhaps 'cancel' is how a player can remove an offer... */ if (game.turn.name !== session.name) { return `Only the active player can cancel trading negotiations.`; } game.turn.actions = []; game.turn.limits = {}; addActivity(game, session, `${session.name} has cancelled trading negotiations.`); return undefined; }; const processOffer = (game: Game, session: Session, offer: Offer): string | undefined => { const player = session.player as Player; let warning = checkPlayerOffer(game, player, offer); if (warning) { return warning; } if (isSameOffer(player, offer)) { console.log(player); return `You already have a pending offer submitted for ${offerToString(offer)}.`; } (player as any)["gives"] = (offer as any)["gives"]; (player as any)["gets"] = (offer as any)["gets"]; (player as any)["offerRejected"] = {}; if ((game.turn as any)["color"] === session.color) { (game.turn as any)["offer"] = offer; } /* If this offer matches what another player wants, clear rejection on that other player's offer */ for (const color in game.players) { if (color === session.color) continue; const other = game.players[color]; if (!other) continue; if ((other as any)["status"] !== "Active") continue; /* Comparison reverses give/get order */ if (isSameOffer(other, { gives: (offer as any)["gets"], gets: (offer as any)["gives"] } as Offer)) { if ((other as any)["offerRejected"]) { delete (other as any)["offerRejected"][session.color as string]; } } } addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`); return undefined; }; const rejectOffer = (game: Game, session: Session, offer: Offer): void => { /* If the active player rejected an offer, they rejected another player */ const other = game.players[(offer as any)["color"] as string]; if (!other) return; if (!(other as any)["offerRejected"]) { (other as any)["offerRejected"] = {}; } (other as any)["offerRejected"][session.color as string] = true; if (!session.player) session.player = {} as Player; if (!(session.player as any)["offerRejected"]) { (session.player as any)["offerRejected"] = {}; } (session.player as any)["offerRejected"][(offer as any)["color"] as string] = true; addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`); }; const acceptOffer = (game: Game, session: Session, offer: Offer): string | undefined => { const name = session.name, player = session.player as Player; if (game.turn.name !== name) { return `Only the active player can accept an offer.`; } let target: any = undefined; console.log({ description: offerToString(offer) }); let warning = checkPlayerOffer(game, player, offer); if (warning) { return warning; } if ( !isCompatibleOffer(player, { name: (offer as any)["name"], gives: (offer as any)["gets"], gets: (offer as any)["gives"], } as Offer) ) { return `Unfortunately, trades were re-negotiated in transit and 1 ` + `the deal is invalid!`; } /* Verify that the offer sent by the active player matches what * the latest offer was that was received by the requesting player */ if (!(offer as any)["name"] || (offer as any)["name"] !== "The bank") { target = game.players[(offer as any)["color"] as string]; if (!target) return `Invalid trade target.`; if ((target as any)["offerRejected"] && (offer as any)["color"] in (target as any)["offerRejected"]) { return `${target.name} rejected this offer.`; } if (!isCompatibleOffer(target as Player, offer)) { return `Unfortunately, trades were re-negotiated in transit and ` + `the deal is invalid!`; } warning = checkPlayerOffer( game, target as Player, { gives: offer.gets, gets: offer.gives, } as Offer ); if (warning) { return warning; } if (!isSameOffer(target as Player, { gives: (offer as any)["gets"], gets: (offer as any)["gives"] } as Offer)) { console.log({ target, offer }); return `These terms were not agreed to by ${target.name}!`; } if (!canMeetOffer(target as Player, player as any)) { return `${target.name} cannot meet the terms.`; } } else { target = offer; } debugChat(game, "Before trade"); /* Transfer goods */ for (const item of (offer as any)["gets"] || []) { if ((target as any)["name"] !== "The bank") { (target as any)[item.type] -= item.count; (target as any).resources -= item.count; } (player as any)[item.type] += item.count; (player as any).resources += item.count; } for (const item of (offer as any)["gives"] || []) { if ((target as any)["name"] !== "The bank") { (target as any)[item.type] += item.count; (target as any).resources += item.count; } (player as any)[item.type] -= item.count; (player as any).resources -= item.count; } const from = (offer as any)["name"] === "The bank" ? "the bank" : (offer as any)["name"]; addChatMessage(game, session, `${session.name} traded ` + ` ${offerToString(offer)} ` + `from ${from}.`); addActivity(game, session, `${session.name} accepted a trade from ${from}.`); delete (game.turn as any)["offer"]; if (target) { delete (target as any).gives; delete (target as any).gets; } if (session.player) { delete (session.player as any)["gives"]; delete (session.player as any)["gets"]; } delete (game.turn as any)["offer"]; debugChat(game, "After trade"); /* Debug!!! */ for (const key in game.players) { const p = game.players[key]; if (!p) continue; if ((p as any)["state"] !== "Active") { continue; } types.forEach((type) => { if ((p as any)[type] < 0) { throw new Error(`Player resources are below zero! BUG BUG BUG!`); } }); } game.turn.actions = []; return undefined; }; const trade = (game: Game, session: Session, action: string, offer?: Offer): string | undefined => { if (game.state !== "normal") { return `Game not in correct state to begin trading.`; } if (!game.turn.actions || game.turn.actions.indexOf("trade") === -1) { return startTrade(game, session); } /* Only the active player can cancel trading */ if (action === "cancel") { return cancelTrade(game, session); } /* Any player can make an offer */ if (action === "offer") { return processOffer(game, session, offer as Offer); } /* Any player can reject an offer */ if (action === "reject") { rejectOffer(game, session, offer as Offer); return undefined; } /* Only the active player can accept an offer */ if (action === "accept") { if (offer && (offer as any)["name"] === "The bank") { if (!session.player) session.player = {} as Player; (session.player as any)["gets"] = (offer as any)["gets"]; (session.player as any)["gives"] = (offer as any)["gives"]; } return acceptOffer(game, session, offer as Offer); } return undefined; }; const clearTimeNotice = (game: Game, session: Session): string | undefined => { if (!session.player || !session.player.turnNotice) { /* benign state; don't alert the user */ //return `You have not been idle.`; } if (session.player) session.player.turnNotice = ""; sendUpdateToPlayer(game, session, { private: session.player, }); return undefined; }; const startTurnTimer = (game: Game, session: Session) => { const timeout = 90; if (!session.ws) { console.log(`${session.id}: Aborting turn timer as ${session.name} is disconnected.`); } else { console.log(`${session.id}: (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.id}: 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.id}: 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) { return `You cannot pass when it isn't your turn.`; } /* If the current turn is a robber placement, and everyone has * discarded, set the limits for where the robber can be placed */ if (game.turn && game.turn.robberInAction) { return `Robber is in action. Turn can not stop until all Robber tasks are resolved.`; } if (game.state === "volcano") { return `You cannot not stop turn until you have finished the Volcano tasks.`; } const next = getNextPlayerSession(game, session.name); if (!next) { return `Unable to find the next player to pass to.`; } session.player.totalTime += Date.now() - session.player.turnStart; session.player.turnNotice = ""; game.turn = { name: next.name, color: next.color, }; if (next.player) { next.player.turnStart = Date.now(); } startTurnTimer(game, next); game.turns++; addActivity(game, session, `${name} passed their turn.`); addChatMessage(game, null, `It is ${next.name}'s turn.`); sendUpdateToPlayer(game, next, { private: next.player, }); sendUpdateToPlayer(game, session, { private: session.player, }); delete game.dice; sendUpdateToPlayers(game, { turns: game.turns, turn: game.turn, chat: game.chat, activities: game.activities, dice: game.dice, }); return undefined; }; const placeRobber = (game: Game, session: Session, robber: number | string): string | undefined => { const name = session.name; let robberIdx = typeof robber === "string" ? parseInt(robber) : robber; if (game.state !== "normal" && game.turn.roll !== 7) { return `You cannot place robber unless 7 was rolled!`; } if (game.turn.name !== name) { return `You cannot place the robber when it isn't your turn.`; } for (const color in game.players) { const p = game.players[color]; if (!p) continue; if (p.status === "Not active") continue; if ((p.mustDiscard || 0) > 0) { return `You cannot place the robber until everyone has discarded!`; } } if (game.robber === robberIdx) { return `You must move the robber to a new location!`; } game.robber = robberIdx as number; (game.turn as any).placedRobber = true; pickRobber(game); addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); const targets: Array<{ color: string; name: string }> = []; layout.tiles?.[robberIdx as number]?.corners?.forEach((cornerIndex: number) => { const active = game.placements?.corners?.[cornerIndex]; if ( active && active.color && active.color !== game.turn.color && targets.findIndex((item) => item.color === active.color) === -1 ) { targets.push({ color: active.color, name: game.players?.[active.color]?.name || "", }); } }); if (targets.length) { game.turn.actions = ["steal-resource"]; game.turn.limits = { players: targets } as any; } else { game.turn.actions = []; game.turn.robberInAction = false; delete game.turn.limits; addChatMessage( game, null, `The dread robber ${game.robberName} was placed on a terrain ` + `with no other players, ` + `so ${game.turn.name} does not steal resources from anyone.` ); } sendUpdateToPlayers(game, { placements: game.placements, turn: game.turn, chat: game.chat, robber: game.robber, robberName: game.robberName, activities: game.activities, }); sendUpdateToPlayer(game, session, { private: session.player, }); return undefined; }; const stealResource = (game: Game, session: Session, color: string): string | undefined => { if (!game.turn.actions || game.turn.actions.indexOf("steal-resource") === -1) { return `You can only steal a resource when it is valid to do so!`; } const playersLimit = (game.turn.limits as any)?.players || []; if (playersLimit.findIndex((item: any) => item.color === color) === -1) { return `You can only steal a resource from a player on this terrain!`; } const victimSession = sessionFromColor(game, color); if (!victimSession || !victimSession.player) { return `You sent a weird color for the target to steal from.`; } const victimPlayer = victimSession.player as Player; const sessionPlayer = session.player as Player; const cards: string[] = []; ["wheat", "brick", "sheep", "stone", "wood"].forEach((field: string) => { for (let i = 0; i < ((victimPlayer as any)[field] || 0); i++) { cards.push(field); } }); debugChat(game, "Before steal"); if (cards.length === 0) { addChatMessage(game, session, `${victimSession.name} did not have any cards for ${session.name} to steal.`); game.turn.actions = []; game.turn.limits = {} as any; } else { const idx = Math.floor(Math.random() * cards.length); const type = cards[idx]; if (!type) { game.turn.actions = []; game.turn.limits = {} as any; return undefined; } const t = String(type); // adjust typed resource counts via helper adjustResources(victimPlayer as Player, { [t]: -1 }); adjustResources(sessionPlayer as Player, { [t]: 1 }); game.turn.actions = []; game.turn.limits = {} as any; trackTheft(game, (victimSession as any).color || "", session.color, type, 1); addChatMessage(game, session, `${session.name} randomly stole 1 ${type} from ${victimSession.name}.`); sendUpdateToPlayer(game, victimSession, { private: victimSession.player, }); } debugChat(game, "After steal"); game.turn.robberInAction = false; sendUpdateToPlayer(game, session, { private: session.player, }); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities, players: getFilteredPlayers(game), }); return undefined; }; const buyDevelopment = (game: Game, session: Session): string | undefined => { const player = session.player as Player; if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } if (!game.turn.roll) { return `You cannot build until you have rolled.`; } if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not purchase until all Robber tasks are resolved.`; } if (!game.developmentCards || game.developmentCards.length < 1) { return `There are no more development cards!`; } if ((player.stone || 0) < 1 || (player.wheat || 0) < 1 || (player.sheep || 0) < 1) { return `You have insufficient resources to purchase a development card.`; } if ((game.turn as any).developmentPurchased) { return `You have already purchased a development card this turn.`; } debugChat(game, "Before development purchase"); addActivity(game, session, `${session.name} purchased a development card.`); addChatMessage(game, session, `${session.name} spent 1 stone, 1 wheat, 1 sheep to purchase a development card.`); player.stone = (player.stone || 0) - 1; player.wheat = (player.wheat || 0) - 1; player.sheep = (player.sheep || 0) - 1; player.resources = 0; player.developmentCards = (player.developmentCards || 0) + 1; ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => { player.resources = (player.resources || 0) + ((player as any)[resource] || 0); }); debugChat(game, "After development purchase"); const card = (game.developmentCards || []).pop(); if (card) { (card as any).turn = game.turns ? game.turns - 1 : 0; if (!player.development) player.development = [] as any; (player.development as any).push(card as any); } if (isRuleEnabled(game, "most-developed")) { if ( (player.development?.length || 0) >= 5 && (!(game as any)["mostDeveloped"] || (player.developmentCards || 0) > (game.players[(game as any)["mostDeveloped"] as string]?.developmentCards || 0)) ) { if ((game as any)["mostDeveloped"] !== session.color) { (game as any)["mostDeveloped"] = session.color; (game as any)["mostPortCount"] = player.developmentCards; addChatMessage( game, session, `${session.name} now has the most development cards (${player.developmentCards})!` ); } } } sendUpdateToPlayer(game, session, { private: session.player, }); sendUpdateToPlayers(game, { chat: game.chat, activities: game.activities, mostDeveloped: (game as any)["mostDeveloped"], players: getFilteredPlayers(game), }); return undefined; }; const playCard = (game: Game, session: Session, card: any): string | undefined => { const name = session.name; const player = session.player as Player; if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } if (!game.turn.roll) { return `You cannot play a card until you have rolled.`; } if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not play a card until all Robber tasks are resolved.`; } card = (player.development || []).find( (item: any) => item.type == card.type && item.card == card.card && !(item.card as any).played ); if (!card) { return `The card you want to play was not found in your hand!`; } if ((player as any)["playedCard"] === game.turns && card.type !== "vp") { return `You can only play one development card per turn!`; } /* Check if this is a victory point */ if (card.type === "vp") { let points = player.points || 0; (player.development || []).forEach((item: any) => { if (item.type === "vp") { points++; } }); if (points < getVictoryPointRule(game)) { return `You can not play victory point cards until you can reach ${getVictoryPointRule(game)}!`; } addChatMessage(game, session, `${name} played a Victory Point card.`); } if (card.type === "progress") { switch (card.card) { case "road-1": case "road-2": { const allowed = Math.min(player.roads || 0, 2); if (!allowed) { addChatMessage(game, session, `${session.name} played a Road Building card, but has no roads to build.`); break; } const roads = getValidRoads(game, session.color as string); if (roads.length === 0) { addChatMessage( game, session, `${session.name} played a Road Building card, but they do not have any valid locations to place them.` ); break; } game.turn.active = "road-building" as any; (game.turn as any).free = true; (game.turn as any).freeRoads = allowed; addChatMessage( game, session, `${session.name} played a Road Building card. They now place ${allowed} roads for free.` ); setForRoadPlacement(game, roads); break; } case "monopoly": game.turn.actions = ["select-resources"]; game.turn.active = "monopoly" as any; addActivity( game, session, `${session.name} played the Monopoly card, and is selecting their resource type to claim.` ); break; case "year-of-plenty": game.turn.actions = ["select-resources"]; game.turn.active = "year-of-plenty" as any; addActivity(game, session, `${session.name} played the Year of Plenty card.`); break; default: addActivity(game, session, `Oh no! ${card.card} isn't impmented yet!`); break; } } (card as any).played = true; (player as any)["playedCard"] = game.turns; if (card.type === "army") { (player as any)["army"] = ((player as any)["army"] || 0) + 1; addChatMessage(game, session, `${session.name} played a Knight and must move the robber!`); if ( (player as any)["army"] > 2 && (!(game as any)["largestArmy"] || ((game.players as any)[(game as any)["largestArmy"]]?.army || 0) < (player as any)["army"]) ) { if ((game as any)["largestArmy"] !== session.color) { (game as any)["largestArmy"] = session.color; (game as any)["largestArmySize"] = (player as any)["army"]; addChatMessage(game, session, `${session.name} now has the largest army (${(player as any)["army"]})!`); } } game.turn.robberInAction = true; delete (game.turn as any).placedRobber; addChatMessage( game, null, `The robber ${game.robberName} has fled before the power of the Knight, ` + `but a new robber has returned and ${session.name} must now place them.` ); game.turn.actions = ["place-robber", "playing-knight"]; game.turn.limits = { pips: [] } as any; for (let i = 0; i < 19; i++) { if (i === game.robber) continue; (game.turn.limits as any).pips.push(i); } } sendUpdateToPlayer(game, session, { private: session.player, }); sendUpdateToPlayers(game, { chat: game.chat, activities: game.activities, largestArmy: (game as any)["largestArmy"], largestArmySize: (game as any)["largestArmySize"], turn: game.turn, players: getFilteredPlayers(game), }); return undefined; }; const placeSettlement = (game: Game, session: Session, index: number | string): string | undefined => { if (!session.player) return `You are not playing a player.`; const player: any = session.player; const anyGame: any = game as any; if (typeof index === "string") index = parseInt(index); if (game.state !== "initial-placement" && game.state !== "normal") { return `You cannot place a settlement unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } /* index out of range... */ if ( !anyGame.placements || anyGame.placements.corners === undefined || anyGame.placements.corners[index] === undefined ) { return `You have requested to place a settlement illegally!`; } /* If this is not a valid road in the turn limits, discard it */ if (!game.turn || !game.turn.limits || !game.turn.limits.corners || game.turn.limits.corners.indexOf(index) === -1) { return `You tried to cheat! You should not try to break the rules.`; } const corner = anyGame.placements.corners[index]; if (corner.color) { const owner = game.players && game.players[corner.color]; const ownerName = owner ? owner.name : "unknown"; return `This location already has a settlement belonging to ${ownerName}!`; } if (!player.banks) { player.banks = []; } if (game.state === "normal") { if (!game.turn.free) { if ((player.brick || 0) < 1 || (player.wood || 0) < 1 || (player.wheat || 0) < 1 || (player.sheep || 0) < 1) { return `You have insufficient resources to build a settlement.`; } } if ((player.settlements || 0) < 1) { return `You have already built all of your settlements.`; } player.settlements = (player.settlements || 0) - 1; if (!game.turn.free) { addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood, 1 sheep, 1 wheat to purchase a settlement.`); player.brick = (player.brick || 0) - 1; player.wood = (player.wood || 0) - 1; player.wheat = (player.wheat || 0) - 1; player.sheep = (player.sheep || 0) - 1; player.resources = 0; ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => { player.resources += player[resource] || 0; }); } delete game.turn.free; corner.color = session.color; corner.type = "settlement"; let bankType = undefined; const banks = layout.corners?.[index]?.banks; if (banks && banks.length) { banks.forEach((bank: any) => { const border = anyGame.borderOrder[Math.floor(bank / 3)], type = anyGame.borders?.[border]?.[bank % 3]; console.log(`${session.id}: Bank ${bank} = ${type}`); if (!type) { console.log(`${session.id}: Bank ${bank}`); return; } bankType = type === "bank" ? "3 of anything for 1 resource" : `2 ${type} for 1 resource`; if (player.banks.indexOf(type) === -1) { player.banks.push(type); } player.ports++; if (isRuleEnabled(game, "port-of-call")) { console.log(`Checking port-of-call`, player.ports, anyGame.mostPorts); if (player.ports >= 3 && (!anyGame.mostPorts || player.ports > anyGame.mostPortCount)) { if (anyGame.mostPorts !== session.color) { anyGame.mostPorts = session.color; anyGame.mostPortCount = player.ports; addChatMessage(game, session, `${session.name} now has the most ports (${player.ports})!`); } } } }); } game.turn.actions = []; game.turn.limits = {}; if (bankType) { addActivity(game, session, `${session.name} placed a settlement by a maritime bank that trades ${bankType}.`); } else { addActivity(game, session, `${session.name} placed a settlement.`); } calculateRoadLengths(game, session); } else if (game.state === "initial-placement") { if (anyGame.direction && anyGame.direction === "backward") { (session as any).initialSettlement = index; } corner.color = session.color || ""; corner.type = "settlement"; let bankType = undefined; const banks2 = layout.corners?.[index]?.banks; if (banks2 && banks2.length) { banks2.forEach((bank: any) => { const border = anyGame.borderOrder[Math.floor(bank / 3)], type = anyGame.borders?.[border]?.[bank % 3]; console.log(`${session.id}: Bank ${bank} = ${type}`); if (!type) { return; } bankType = type === "bank" ? "3 of anything for 1 resource" : `2 ${type} for 1 resource`; if (player.banks.indexOf(type) === -1) { player.banks.push(type); } player.ports++; }); } player.settlements = (player.settlements || 0) - 1; if (bankType) { addActivity( game, session, `${session.name} placed a settlement by a maritime bank that trades ${bankType}. ` + `Next, they need to place a road.` ); } else { addActivity(game, session, `${session.name} placed a settlement. ` + `Next, they need to place a road.`); } setForRoadPlacement(game, layout.corners?.[index]?.roads || []); } sendUpdateToPlayer(game, session, { private: session.player, }); sendUpdateToPlayers(game, { placements: game.placements, turn: game.turn, chat: game.chat, state: game.state, longestRoad: game.longestRoad, longestRoadLength: game.longestRoadLength, players: getFilteredPlayers(game), }); return undefined; }; const placeRoad = (game: any, session: any, index: any): string | undefined => { const player = session.player; if (typeof index === "string") index = parseInt(index); if (!game || !game.turn) { return `Invalid game state.`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } if (game.placements.roads[index] === undefined) { return `You have requested to place a road illegally!`; } if (!game.turn.limits || !game.turn.limits.roads || game.turn.limits.roads.indexOf(index) === -1) { return `You tried to cheat! You should not try to break the rules.`; } const road = game.placements.roads[index]; if (road.color) { return `This location already has a road belonging to ${game.players[road.color].name}!`; } if (game.state === "normal") { if (!game.turn.free) { if (player.brick < 1 || player.wood < 1) { return `You have insufficient resources to build a road.`; } } if (player.roads < 1) { return `You have already built all of your roads.`; } if (!game.turn.free) { addChatMessage(game, session, `${session.name} spent 1 brick and 1 wood to build a road.`); player.brick--; player.wood--; player.resources = 0; ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => { player.resources += player[resource]; }); } delete game.turn.free; } road.color = session.color; road.type = "road"; player.roads--; game.turn.actions = []; game.turn.limits = {}; calculateRoadLengths(game, session); sendUpdateToPlayer(game, session, { private: session.player, }); sendUpdateToPlayers(game, { placements: game.placements, turn: game.turn, chat: game.chat, activities: game.activities, players: getFilteredPlayers(game), }); 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; if (game.turn.roll !== 7) { return `You can only discard due to the Robber!`; } let sum = 0; for (let type in discards) { const val = discards[type]; const parsed = typeof val === "string" ? parseInt(val) : Number(val); if (player[type] < parsed) { return `You have requested to discard more ${type} than you have.`; } sum += parsed; } if (sum > player.mustDiscard) { return `You can not discard that many cards! You can only discard ${player.mustDiscard}.`; } if (sum === 0) { return `You must discard at least one card.`; } for (let type in discards) { const count = parseInt(discards[type]); player[type] -= count; player.mustDiscard -= count; player.resources -= count; } addChatMessage(game, null, `${session.name} discarded ${sum} resource cards.`); if (player.mustDiscard > 0) { addChatMessage( game, null, `${session.name} did not discard enough and must discard ${player.mustDiscard} more cards.` ); } let move = true; for (let color in game.players) { const discard = game.players[color].mustDiscard > 0; if (discard) { move = false; } } if (move) { 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++) { if (i === game.robber) { continue; } game.turn.limits.pips.push(i); } } sendUpdateToPlayer(game, session, { private: player, }); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), chat: game.chat, turn: game.turn, }); return undefined; }; const buyRoad = (game: any, session: any): string | undefined => { const player = session.player; if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } if (!game.turn.roll) { return `You cannot build until you have rolled.`; } if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not purchase until all Robber tasks are resolved.`; } if (player.brick < 1 || player.wood < 1) { return `You have insufficient resources to build a road.`; } if (player.roads < 1) { return `You have already built all of your roads.`; } const roads = getValidRoads(game, session.color); if (roads.length === 0) { return `There are no valid locations for you to place a road.`; } setForRoadPlacement(game, roads); addActivity(game, session, `${game.turn.name} is considering building a road.`); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities, }); return undefined; }; const selectResources = (game: any, session: any, cards: string[]): string | undefined => { const player = session.player; void player; if (!game || !game.turn || !game.turn.actions || game.turn.actions.indexOf("select-resources") === -1) { return `Please, let's not cheat. Ok?`; } if (session.color !== game.turn.color && (!game.turn.select || !(session.color in game.turn.select))) { console.log(session.color, game.turn.color, game.turn.select); return `It is not your turn! It is ${game.turn.name}'s turn.`; } let count = 2; if (game.turn && game.turn.active === "monopoly") { count = 1; } if (game.state === "volcano") { console.log({ cards, turn: game.turn }); if (!game.turn.select) { count = 0; } else if (session.color in game.turn.select) { count = game.turn.select[session.color]; delete game.turn.select[session.color]; if (Object.getOwnPropertyNames(game.turn.select).length === 0) { addChatMessage( game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!` ); delete game.turn.select; } } else { count = 0; } } if (!cards || cards.length > count || cards.length === 0) { return `You have chosen the wrong number of cards!`; } const isValidCard = (type: string): boolean => { switch (type.trim()) { case "wheat": case "brick": case "sheep": case "stone": case "wood": return true; default: return false; } }; const selected: Record = {}; for (const card of cards) { if (!isValidCard(card)) { return `Invalid resource type!`; } selected[card] = (selected[card] || 0) + 1; } const display: string[] = []; for (let card in selected) { display.push(`${selected[card]} ${card}`); } switch (game.turn.active) { case "monopoly": const gave: string[] = [], type = String(cards[0]); let total = 0; for (let color in game.players) { const player = game.players[color]; if (player.status === "Not active") { continue; } if (color === session.color) { continue; } if ((player as any)[type]) { gave.push(`${player.name} gave ${(player as any)[type]} ${type}`); (session.player as any)[type] += (player as any)[type]; session.resources += (player as any)[type]; total += (player as any)[type]; (player as any)[type] = 0; for (let key in game.sessions) { if (game.sessions[key].player === player) { sendUpdateToPlayer(game, game.sessions[key], { private: game.sessions[key].player, }); break; } } } } if (gave.length) { addChatMessage( game, session, `${session.name} played Monopoly and selected ${display.join(", ")}. ` + `Players ${gave.join(", ")}. In total, they received ${total} ${type}.` ); } else { addActivity( game, session, `${session.name} has chosen ${display.join(", ")}! Unfortunately, no players had that resource. Wa-waaaa.` ); } delete game.turn.active; game.turn.actions = []; break; case "year-of-plenty": cards.forEach((type) => { session.player[type]++; session.player.resources++; }); addChatMessage( game, session, `${session.name} player Year of Plenty.` + `They chose to receive ${display.join(", ")} from the bank.` ); delete game.turn.active; game.turn.actions = []; break; case "volcano": cards.forEach((type) => { session.player[type]++; session.player.resources++; }); addChatMessage(game, session, `${session.name} player mined ${display.join(", ")} from the Volcano!`); if (!game.turn.select) { delete game.turn.active; game.turn.actions = []; } break; } sendUpdateToPlayer(game, session, { private: session.player, }); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities, players: getFilteredPlayers(game), }); return undefined; }; const buySettlement = (game: any, session: any): string | undefined => { const player = session.player; if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } if (!game.turn.roll) { return `You cannot build until you have rolled.`; } if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not purchase until all Robber tasks are resolved.`; } if (player.brick < 1 || player.wood < 1 || player.wheat < 1 || player.sheep < 1) { return `You have insufficient resources to build a settlement.`; } if (player.settlements < 1) { return `You have already built all of your settlements.`; } const corners = getValidCorners(game, session.color); if (corners.length === 0) { return `There are no valid locations for you to place a settlement.`; } setForSettlementPlacement(game, corners); addActivity(game, session, `${game.turn.name} is considering placing a settlement.`); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities, }); return undefined; }; const buyCity = (game: any, session: any): string | undefined => { const player = session.player; if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } if (!game.turn.roll) { return `You cannot build until you have rolled.`; } if (player.wheat < 2 || player.stone < 3) { return `You have insufficient resources to build a city.`; } if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not purchase until all Robber tasks are resolved.`; } if (player.city < 1) { return `You have already built all of your cities.`; } const corners = getValidCorners(game, session.color, "settlement"); if (corners.length === 0) { return `There are no valid locations for you to place a city.`; } setForCityPlacement(game, corners); addActivity(game, session, `${game.turn.name} is considering upgrading a settlement to a city.`); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities, }); return undefined; }; const placeCity = (game: any, session: any, index: any): string | undefined => { const player = session.player; if (typeof index === "string") index = parseInt(index); if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } /* Valid index check */ if (game.placements.corners[index] === undefined) { return `You have requested to place a city illegally!`; } /* If this is not a placement the turn limits, discard it */ if (!game.turn || !game.turn.limits || !game.turn.limits.corners || game.turn.limits.corners.indexOf(index) === -1) { return `You tried to cheat! You should not try to break the rules.`; } const corner = game.placements.corners[index]; if (corner.color !== session.color) { return `This location already has a settlement belonging to ${game.players[corner.color].name}!`; } if (corner.type !== "settlement") { return `This location already has a city!`; } if (game.turn.free) { delete game.turn.free; } debugChat(game, "Before city placement"); corner.color = session.color; corner.type = "city"; player.cities--; player.settlements++; if (!game.turn.free) { addChatMessage(game, session, `${session.name} spent 2 wheat, 3 stone to upgrade to a city.`); player.wheat -= 2; player.stone -= 3; player.resources = 0; ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => { player.resources += player[resource]; }); } delete game.turn.free; debugChat(game, "After city placement"); game.turn.actions = []; game.turn.limits = {}; addActivity(game, session, `${session.name} upgraded a settlement to a city!`); sendUpdateToPlayer(game, session, { private: session.player, }); sendUpdateToPlayers(game, { placements: game.placements, turn: game.turn, chat: game.chat, activities: game.activities, players: getFilteredPlayers(game), }); return undefined; }; const ping = (session: Session) => { if (!session.ws) { console.log(`[no socket] Not sending ping to ${session.name} -- connection does not exist.`); return; } (session as any)["ping"] = Date.now(); // console.log(`Sending ping to ${session.name}`); try { session.ws.send(JSON.stringify({ type: "ping", ping: (session as any)["ping"] })); } catch (e) { // ignore send errors } if (session.keepAlive) { clearTimeout(session.keepAlive); } session.keepAlive = setTimeout(() => { // mark the session as inactive if the keepAlive fires try { if (session.ws) { session.ws.close?.(); } } catch (e) { /* ignore */ } session.ws = undefined; }, 20000); }; // wsInactive not present in this refactor; no-op placeholder removed const setGameState = (game: any, session: any, state: any): string | undefined => { if (!state) { return `Invalid state.`; } if (!session.color) { return `You must have an active player to start the game.`; } if (state === game.state) { return; } switch (state) { case "game-order": if (game.state !== "lobby") { return `You can only start the game from the lobby.`; } const active = getActiveCount(game); if (active < 2) { return `You need at least two players to start the game.`; } /* Delete any non-played colors from the player map; reduces all * code that would otherwise have to filter out players by checking * the 'Not active' state of player.status */ for (let key in game.players) { if (game.players[key].status !== "Active") { delete game.players[key]; } } addChatMessage(game, null, `${session.name} requested to start the game.`); game.state = state; sendUpdateToPlayers(game, { state: game.state, chat: game.chat, }); break; } return undefined; }; const resetDisconnectCheck = (_game: any, req: any): void => { void _game; if (req.disconnectCheck) { clearTimeout(req.disconnectCheck); } //req.disconnectCheck = setTimeout(() => { wsInactive(game, req) }, 20000); }; // 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]); // Remove private or non-serializable fields from the session copy if (reduced.player) delete reduced.player; if (reduced.ws) delete reduced.ws; if (reduced.keepAlive) delete reduced.keepAlive; // Remove any internal helper fields (prefixed with '_') and any // non-primitive values such as functions or timers which may cause // JSON.stringify to throw due to circular structures. Object.keys(reduced).forEach((k) => { if (k.startsWith("_")) { delete reduced[k]; } else if (typeof reduced[k] === "function") { delete reduced[k]; } }); // Do not persist ephemeral test/runtime-only flags if (reduced._initialSnapshotSent) { delete reduced._initialSnapshotSent; } reducedGame.sessions[id] = reduced; /* Do not send session-id as those are secrets */ reducedSessions.push(reduced); } delete reducedGame.turnTimer; delete reducedGame.unselected; /* 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); if (session.player) { session.player.live = false; update.players = game.players; } if (session.name) { if (session.color) { addChatMessage(game, null, `${session.name} has disconnected ` + `from the game.`); } else { addChatMessage(game, null, `${session.name} has left the lobby.`); } update.chat = game.chat; } else { console.log(`${session.id}: departLobby - ${getName(session)} is ` + `being removed from ${game.id}'s sessions.`); for (let id in game.sessions) { if (game.sessions[id] === session) { delete game.sessions[id]; break; } } } 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.id}: -> sendGamePlayer:${getName(session)} - full game`); if (!session.ws) { console.log(`${session.id}: -> sendGamePlayer:: Currently no connection`); return; } let update: any; /* Only send empty name data to unnamed players */ if (!session.name) { console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - only sending empty name`); update = { name: "" }; } else { update = getFilteredGameForPlayer(game, session); } const message = JSON.stringify({ type: "game-update", update: update, }); queueSend(session, message); }; const sendGameToPlayers = (game: any): void => { console.log(`${all}: -> sendGamePlayers - full game`); for (let key in game.sessions) { sendGameToPlayer(game, game.sessions[key]); } }; 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.id}: -> 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.id}: -> 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.id}: -> 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.id}: -> sendUpdateToPlayer:${getName(session)} - `, update); } else { const keys = Object.getOwnPropertyNames(update); console.log(`${session.id}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`); } const message = JSON.stringify({ type: "game-update", update, }); if (!session.ws) { console.log(`${session.id}: -> sendUpdateToPlayer: ` + `Currently no connection.`); } else { queueSend(session, message); } }; const getFilteredUnselected = (game: any): string[] => { if (!game.unselected) { return []; } return game.unselected.filter((session: any) => session.live).map((session: any) => session.name); }; const parseChatCommands = (game: any, message: string): void => { /* Chat messages can set game flags and fields */ const partsRaw = message.match(/^set +([^ ]*) +(.*)$/i) as RegExpMatchArray | null; if (!partsRaw || partsRaw.length !== 3) { return; } const parts = partsRaw as RegExpMatchArray; const key = parts[1] || ""; switch (key.toLowerCase()) { case "game": if (parts[2] && parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) { setBeginnerGame(game); addChatMessage(game, null, `Game board set to the Beginner's Layout.`); break; } const signature = parts[2] ? parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i) : null; if (signature) { if (setGameFromSignature(game, signature[1] || "", signature[2] || "", signature[3] || "")) { game.signature = parts[2]; addChatMessage(game, null, `Game board set to ${parts[2]}.`); } else { addChatMessage(game, null, `Requested an invalid game board.`); } } break; } }; const sendError = (session: any, error: string): void => { try { session?.ws?.send(JSON.stringify({ type: "error", error })); } catch (e) { /* ignore */ } }; const sendWarning = (session: any, warning: string): void => { try { session?.ws?.send(JSON.stringify({ type: "warning", warning })); } catch (e) { /* ignore */ } }; 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) * * This demonstrates how to extend the base participant list with app-specific data */ const getParticipants = (game: any): any[] => { // Use the reusable room helper for base participant data // If you were using the new architecture, this would be: // import { getParticipants as getBaseParticipants } from './room/helpers'; // const baseParticipants = getBaseParticipants(game.sessions); const participants: any[] = []; for (let id in game.sessions) { const session = game.sessions[id]; if (!session) continue; // Base participant data (reusable across any application) const baseParticipant = { name: session.name || null, session_id: session.id, live: session.live || false, protected: session.protected || false, has_media: session.has_media !== false, bot_run_id: session.bot_run_id || null, bot_provider_id: session.bot_provider_id || null, bot_instance_id: session.bot_instance_id || null, muted: session.muted || false, video_on: session.video_on !== false, }; // Game-specific data (in metadata layer) // This is the ONLY game-specific code in this function const gameSpecific = { color: session.color || null, // Game-specific: player color // In the new architecture, this would be: session.metadata?.color }; participants.push({ ...baseParticipant, ...gameSpecific }); } 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); addChatMessage( game, null, `The game has been reset. You can play again with this board, or ` + `click 'New Table' to mix things up a bit.` ); sendGameToPlayers(game); return undefined; }; const gotoLobby = (game: any, session: any): string | undefined => { if (!game.waiting) { game.waiting = []; } const already = game.waiting.indexOf(session.name) !== -1; const waitingFor = []; for (let key in game.sessions) { if (game.sessions[key] === session) { continue; } if (game.sessions[key].player && game.waiting.indexOf(game.sessions[key].name) == -1) { waitingFor.push(game.sessions[key].name); } } if (!already) { game.waiting.push(session.name); addChatMessage(game, null, `${session.name} has gone to the lobby.`); } else if (waitingFor.length !== 0) { return `You are already waiting in the lobby. ` + `${waitingFor.join(",")} still needs to go to the lobby.`; } if (waitingFor.length === 0) { resetGame(game); addChatMessage(game, null, `All players are back to the lobby.`); addChatMessage( game, null, `The game has been reset. You can play again with this board, or ` + `click 'New Table' to mix things up a bit.` ); sendGameToPlayers(game); return; } addChatMessage(game, null, `Waiting for ${waitingFor.join(",")} to go to lobby.`); sendUpdateToPlayers(game, { chat: game.chat, }); return undefined; }; router.ws("/ws/:id", async (ws, req) => { console.log("New WebSocket connection"); if (!req.cookies || !(req.cookies as any)["player"]) { // If the client hasn't established a session cookie, they cannot // participate in a websocket-backed game session. Log the request // headers to aid debugging (e.g. missing Cookie header due to // cross-site requests or proxy configuration) and close the socket // with a sensible code so the client sees a deterministic close. try { const remote = req.ip || (req.headers && (req.headers["x-forwarded-for"] || (req.connection && req.connection.remoteAddress))) || "unknown"; console.warn( `[ws] Rejecting connection from ${remote} - missing session cookie. headers=${JSON.stringify(req.headers || {})}` ); } catch (e) { console.warn("[ws] Rejecting connection - missing session cookie (unable to serialize headers)"); } try { // Inform the client why we are closing, then close the socket. ws.send(JSON.stringify({ type: "error", error: `Unable to find session cookie` })); } catch (e) { /* ignore send errors */ } try { // 1008 = Policy Violation - appropriate for missing auth cookie ws.close && ws.close(1008, "Missing session cookie"); } catch (e) { /* ignore close errors */ } return; } const { id } = req.params; const gameId = id; if (!gameId) { console.log("Missing game id"); try { ws.send(JSON.stringify({ type: "error", error: "Missing game id" })); } catch (e) {} try { console.log("Missing game id"); ws.close && ws.close(1008, "Missing game id"); } catch (e) {} return; } const playerCookie = req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : ""; const short = playerCookie ? `[${playerCookie.substring(0, 8)}]` : "[unknown]"; (ws as any).id = short; console.log(`${short}: Game ${gameId} - New connection from client.`); try { console.log(`${short}: WS handshake headers: origin=${req.headers.origin} cookie=${req.headers.cookie}`); } catch (e) { /* ignore logging errors */ } if (!(gameId in audio)) { audio[gameId] = {}; /* List of peer sockets using session.name as index. */ console.log(`${short}: Game ${gameId} - New Game Audio`); } else { console.log(`${short}: Game ${gameId} - Already has Audio`); } /* Setup WebSocket event handlers prior to performing any async calls or * we may miss the first messages from clients */ ws.on("error", async (event) => { console.error(`WebSocket error: `, event && event.message ? event.message : event); const game = await loadGame(gameId); if (!game) { return; } const _session = getSession( game, req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" ); if (!_session) return; const session = _session; session.live = false; try { console.log(`${short}: ws.on('error') - session.ws === ws? ${session.ws === ws}`); console.log(`${short}: ws.on('error') - session.id=${session && session.id}`); console.log(`${short}: ws.on('error') - stack:`, new Error().stack); // Only close the session.ws if it is the same socket that errored. if (session.ws && session.ws === ws) { try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws:`, e); } session.ws = undefined; } } catch (e) { console.warn(`${short}: exception in ws.on('error') handler:`, e); } departLobby(game, session); }); ws.on("close", async (event) => { console.log( `${short} - closed connection (event: ${event && typeof event === "object" ? JSON.stringify(event) : event})` ); const game = await loadGame(gameId); if (!game) { return; } const _session = getSession( game, req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" ); if (!_session) return; const session = _session; if (session.player) { session.player.live = false; } session.live = false; // Only cleanup the session.ws if it references the same socket object try { console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`); console.log( `${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}` ); if (session.ws && session.ws === ws) { /* Cleanup any voice channels */ if (gameId in audio) { try { webrtcPart(audio[gameId], session); } catch (e) { console.warn(`${short}: Error during part():`, e); } } try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws in on('close'):`, e); } session.ws = undefined; console.log(`${short}:WebSocket closed for ${getName(session)}`); } } catch (e) { console.warn(`${short}: exception in ws.on('close') handler:`, e); } departLobby(game, session); /* Check for a game in the Winner state with no more connections * and remove it */ if (game.state === "winner") { let dead = true; for (let id in game.sessions) { if (game.sessions[id].live && game.sessions[id].name) { dead = false; } } if (dead) { console.log(`${session.id}: No more players in ${game.id}. ` + `Removing.`); addChatMessage(game, null, `No more active players in game. ` + `It is being removed from the server.`); sendUpdateToPlayers(game, { chat: game.chat, }); for (let id in game.sessions) { if (game.sessions[id].ws) { try { console.log(`${short}: Removing game - closing session ${id} socket (game removal cleanup)`); console.log(`${short}: Closing socket stack:`, new Error().stack); game.sessions[id].ws.close(); } catch (e) { console.warn(`${short}: error closing session socket during game removal:`, e); } delete game.sessions[id]; } } delete audio[gameId]; delete games[gameId]; try { if (!gameDB || !gameDB.deleteGame) { console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`); } else { await gameDB.deleteGame(gameId); } } catch (error) { console.error(`${session.id}: Unable to remove game ${id} via gameDB.deleteGame`, error); } } } }); ws.on("message", async (message) => { // Normalize the incoming message to { type, data } so handlers can // reliably access the payload without repeated defensive checks. const incoming = normalizeIncoming(message); if (!incoming.type) { // If we couldn't parse or determine the type, log and ignore the // message to preserve previous behavior. try { console.error(`${all}: parse/normalize error`, message); } catch (e) { console.error("parse/normalize error"); } return; } const data = (incoming.data as any) || {}; const game = await loadGame(gameId); const _session = getSession( game, req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" ); if (!_session) return; const session = _session; // Keep track of any previously attached websocket so we can detect // first-time attaches and websocket replacements (reconnects). const previousWs = session.ws; // If there was a previous websocket and it's a different object, try to // close it to avoid stale sockets lingering in memory. if (previousWs && previousWs !== ws) { // Clean up peer from audio registry before replacing WebSocket if (gameId in audio) { try { webrtcPart(audio[gameId], session); console.log(`${short}: Cleaned up peer ${session.name} from audio registry during reconnection`); } catch (e) { console.warn(`${short}: Error cleaning up peer during reconnection:`, e); } } try { previousWs.close(); } catch (e) { /* ignore close errors */ } } // Attach the current websocket for this session. session.ws = ws; if (session.player) { session.player.live = true; } session.live = true; session.lastActive = Date.now(); let error: string | undefined; let warning: string | void | undefined; let processed = true; // If this is the first time the session attached a WebSocket, or if the // websocket was just replaced (reconnect), send an initial consolidated // snapshot so clients can render deterministically without needing to // wait for a flurry of incremental game-update events. if (!session._initialSnapshotSent) { try { sendInitialGameSnapshot(game, session); session._initialSnapshotSent = true; } catch (e) { console.error(`${session.id}: error sending initial snapshot`, e); } } switch (incoming.type) { case "join": // Accept either legacy `config` or newer `data` field from clients webrtcJoin(audio[gameId], session, data.config || data.data || {}); break; case "part": webrtcPart(audio[gameId], session); break; case "relayICECandidate": { // Delegate to the webrtc signaling helper (it performs its own checks) const cfg = data.config || data.data || {}; handleRelayICECandidate(gameId, cfg, session, undefined, debug); } break; case "relaySessionDescription": { const cfg = data.config || data.data || {}; handleRelaySessionDescription(gameId, cfg, session, undefined, debug); } break; case "pong": resetDisconnectCheck(game, req); break; case "game-update": console.log(`${short}: <- game-update ${getName(session)} - full game update.`); sendGameToPlayer(game, session); break; case "peer_state_update": { const cfg = data.config || data.data || {}; broadcastPeerStateUpdate(gameId, cfg, session, undefined); } break; case "player-name": // Support both legacy { type: 'player-name', name: 'Foo' } // and normalized { type: 'player-name', data: { name: 'Foo' } } const _pname = (data && data.name) || (data && data.data && data.data.name); console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${_pname}`); error = setPlayerName(game, session, _pname); if (error) { sendError(session, error); } else { saveGame(game); } break; case "set": console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`); switch (data.field) { case "state": warning = setGameState(game, session, data.value); if (warning) { sendWarning(session, warning); } else { saveGame(game); } break; case "color": warning = setPlayerColor(game, session, data.value); if (warning) { sendWarning(session, warning); } else { saveGame(game); } break; default: console.warn(`WARNING: Requested SET unsupported field: ${data.field}`); break; } break; case "get": // Batch 'get' requests per-session for a short window so multiple // near-simultaneous requests are merged into one response. This // reduces CPU and network churn during client startup. const requestedFields: string[] = Array.isArray(data.fields) ? (data.fields as string[]) : data.data && Array.isArray(data.data.fields) ? (data.data.fields as string[]) : []; console.log( `${short}: <- get:${getName(session)} ${requestedFields.length ? requestedFields.join(",") : ""}` ); // Ensure a batch structure exists on the session if (!session._getBatch) { session._getBatch = { fields: new Set(), timer: undefined }; } // Merge requested fields into the batch set requestedFields.forEach((f: string) => session._getBatch && session._getBatch.fields.add(f)); // If a timer is already scheduled, we will respond when it fires. if (session._getBatch.timer) { break; } // Schedule a single reply after the batching window session._getBatch.timer = setTimeout(() => { try { if (!session._getBatch) return; const fieldsArray: string[] = Array.from(session._getBatch.fields) as string[]; const batchedUpdate: any = {}; fieldsArray.forEach((field: string) => { switch (field) { case "player": sendWarning(session, `'player' is not a valid item. use 'private' instead`); batchedUpdate.player = undefined; break; case "id": case "chat": case "startTime": case "state": case "turn": case "turns": case "winner": case "placements": case "longestRoadLength": case "robber": case "robberName": case "pips": case "pipsOrder": case "borders": case "tileOrder": case "active": case "largestArmy": case "mostDeveloped": case "mostPorts": case "longestRoad": case "tiles": case "pipOrder": case "signature": case "borderOrder": case "dice": case "activities": batchedUpdate[field] = game[field]; break; case "rules": batchedUpdate[field] = game.rules ? game.rules : {}; break; case "name": batchedUpdate.name = session.name; break; case "unselected": batchedUpdate.unselected = getFilteredUnselected(game); break; case "private": batchedUpdate.private = session.player; break; case "players": batchedUpdate.players = getFilteredPlayers(game); break; case "participants": batchedUpdate.participants = getParticipants(game); break; case "color": console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`); batchedUpdate.color = session.color; break; case "timestamp": batchedUpdate.timestamp = Date.now(); break; default: if (field in game) { console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`); batchedUpdate[String(field)] = (game as any)[String(field)]; } else if (field in session) { console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`); batchedUpdate[String(field)] = (session as any)[String(field)]; } else { console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`); } break; } }); sendUpdateToPlayer(game, session, batchedUpdate); } catch (e) { console.warn(`${session.id}: get batch handler failed:`, e); } // clear batch if (session._getBatch) { session._getBatch.fields.clear(); clearTimeout(session._getBatch.timer as any); session._getBatch.timer = undefined; } }, INCOMING_GET_BATCH_MS); break; case "chat": /* If the chat message is empty, do not add it to the chat */ if (data.message.trim() == "") { break; } console.log(`${short}:${id} - ${data.type} - "${data.message}"`); addChatMessage(game, session, `${session.name}: ${data.message}`, true); parseChatCommands(game, data.message); sendUpdateToPlayers(game, { chat: game.chat }); saveGame(game); break; case "media-status": console.log(`${short}: <- media-status - `, data.audio, data.video); session["video"] = data.video; session["audio"] = data.audio; break; default: processed = false; break; } if (processed) { /* saveGame(game); -- do not save here; only save on changes */ return; } /* The rest of the actions and commands require an active game * participant */ if (!session.player) { error = `Player must have an active color.`; sendError(session, error); return; } processed = true; switch (incoming.type) { case "roll": console.log(`${short}: <- roll:${getName(session)}`); warning = roll(game, session); if (warning) { sendWarning(session, warning); } break; case "shuffle": console.log(`${short}: <- shuffle:${getName(session)}`); warning = shuffle(game, session); if (warning) { sendWarning(session, warning); } break; case "place-settlement": console.log(`${short}: <- place-settlement:${getName(session)} ${data.index}`); warning = placeSettlement(game, session, data.index); if (warning) { sendWarning(session, warning); } break; case "place-city": console.log(`${short}: <- place-city:${getName(session)} ${data.index}`); warning = placeCity(game, session, data.index); if (warning) { sendWarning(session, warning); } break; case "place-road": console.log(`${short}: <- place-road:${getName(session)} ${data.index}`); warning = placeRoad(game, session, data.index); if (warning) { sendWarning(session, warning); } break; case "place-robber": console.log(`${short}: <- place-robber:${getName(session)} ${data.index}`); warning = placeRobber(game, session, data.index); if (warning) { sendWarning(session, warning); } break; case "steal-resource": console.log(`${short}: <- steal-resource:${getName(session)} ${data.color}`); warning = stealResource(game, session, data.color); if (warning) { sendWarning(session, warning); } break; case "discard": console.log(`${short}: <- discard:${getName(session)}`); warning = discard(game, session, data.discards); if (warning) { sendWarning(session, warning); } break; case "pass": console.log(`${short}: <- pass:${getName(session)}`); warning = pass(game, session); if (warning) { sendWarning(session, warning); } break; case "select-resources": console.log(`${short}: <- select-resources:${getName(session)} - `, data.cards); warning = selectResources(game, session, data.cards); if (warning) { sendWarning(session, warning); } break; case "buy-city": console.log(`${short}: <- buy-city:${getName(session)}`); warning = buyCity(game, session); if (warning) { sendWarning(session, warning); } break; case "buy-road": console.log(`${short}: <- buy-road:${getName(session)}`); warning = buyRoad(game, session); if (warning) { sendWarning(session, warning); } break; case "buy-settlement": console.log(`${short}: <- buy-settlement:${getName(session)}`); warning = buySettlement(game, session); if (warning) { sendWarning(session, warning); } break; case "buy-development": console.log(`${short}: <- buy-development:${getName(session)}`); warning = buyDevelopment(game, session); if (warning) { sendWarning(session, warning); } break; case "play-card": console.log(`${short}: <- play-card:${getName(session)}`); warning = playCard(game, session, data.card); if (warning) { sendWarning(session, warning); } break; case "trade": console.log( `${short}: <- trade:${getName(session)} - ` + (data.action ? data.action : "start") + ` -`, data.offer ? data.offer : "no trade yet" ); warning = trade(game, session, data.action, data.offer); if (warning) { sendWarning(session, warning); } else { for (let key in game.sessions) { const tmp = game.sessions[key]; if (tmp.player) { sendUpdateToPlayer(game, tmp, { private: tmp.player, }); } } sendUpdateToPlayers(game, { turn: game.turn, activities: game.activities, chat: game.chat, players: getFilteredPlayers(game), }); } break; case "turn-notice": console.log(`${short}: <- turn-notice:${getName(session)}`); warning = clearTimeNotice(game, session); if (warning) { sendWarning(session, warning); } break; case "clear-game": console.log(`${short}: <- clear-game:${getName(session)}`); warning = clearGame(game, session); if (warning) { sendWarning(session, warning); } break; case "goto-lobby": console.log(`${short}: <- goto-lobby:${getName(session)}`); warning = gotoLobby(game, session); if (warning) { sendWarning(session, warning); } break; case "rules": console.log(`${short} - <- rules:${getName(session)} - `, data.rules); warning = setRules(game, session, data.rules); if (warning) { sendWarning(session, warning); } break; default: console.warn(`Unsupported request: ${data.type}`); processed = false; break; } /* If action was taken, persist the game */ if (processed) { saveGame(game); } /* If the current player took an action, reset the session timer */ if (processed && session.color === game.turn.color && game.state !== "winner") { resetTurnTimer(game, session); } }); /* This will result in the node tick moving forward; if we haven't already * setup the event handlers, a 'message' could come through prior to this * completing */ const game = await loadGame(gameId); if (!game) { console.error(`Unable to load/create new game for WS request.`); return; } const _session2 = getSession( game, req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" ); if (!_session2) return; const session = _session2; session.ws = ws; if (session.player) { session.player.live = true; } session.live = true; session.lastActive = Date.now(); // Ensure we only attempt to send the consolidated initial snapshot once // per session lifecycle. Tests and clients expect a single 'initial-game' // message when a socket first attaches. if (!session._initialSnapshotSent) { try { sendInitialGameSnapshot(game, session); session._initialSnapshotSent = true; } catch (e) { console.error(`${session.id}: error sending initial snapshot on connect`, e); } } if (session.name) { sendUpdateToPlayers(game, { players: getFilteredPlayers(game), participants: getParticipants(game), unselected: getFilteredUnselected(game), }); } /* If the current turn player just rejoined, set their turn timer */ if (game.turn && game.turn.color === session.color && game.state !== "winner") { resetTurnTimer(game, session); } if (session.name) { if (session.color) { addChatMessage(game, null, `${session.name} has reconnected to the game.`); } else { addChatMessage(game, null, `${session.name} has rejoined the lobby.`); } sendUpdateToPlayers(game, { chat: game.chat }); } resetDisconnectCheck(game, req); console.log(`${short}: Game ${id} - WebSocket connect from ${getName(session)}`); /* Send initial ping to initiate communication with client */ if (!session.keepAlive) { console.log(`${short}: Sending initial ping`); ping(session); } else { clearTimeout(session.keepAlive); session.keepAlive = setTimeout(() => { ping(session); }, 2500); } }); const debugChat = (game: any, preamble: any) => { preamble = `Degug ${preamble.trim()}`; let playerInventory = preamble; for (let key in game.players) { const player = game.players[key]; if (player.status === "Not active") { continue; } if (playerInventory !== "") { playerInventory += " player"; } else { playerInventory += " Player"; } playerInventory += ` ${player.name} has `; const has = ["wheat", "brick", "sheep", "stone", "wood"] .map((resource) => { const count = player[resource] ? player[resource] : 0; return `${count} ${resource}`; }) .filter((item) => item !== "") .join(", "); if (has) { playerInventory += `${has}, `; } else { playerInventory += `nothing, `; } } if (game.debug) { addChatMessage(game, null, playerInventory.replace(/, $/, "").trim()); } else { console.log(playerInventory.replace(/, $/, "").trim()); } }; const getFilteredGameForPlayer = (game: any, session: any) => { /* 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) { // Make a shallow copy and then scrub any fields that are private, // non-serializable (timers, sockets), or internal (prefixed with '_'). const original = game.sessions[id]; const reduced = Object.assign({}, original); // Remove obvious non-serializable fields if ("player" in reduced) delete reduced.player; if ("ws" in reduced) delete reduced.ws; if ("keepAlive" in reduced) delete reduced.keepAlive; // Remove internal helper fields (e.g. _pendingTimeout) and functions Object.keys(reduced).forEach((k) => { try { if (k.startsWith("_")) { delete reduced[k]; } else if (typeof reduced[k] === "function") { delete reduced[k]; } else { // Remove values that are likely to be non-serializable objects // such as Timers that may appear on some runtime fields. const v = reduced[k]; if (typeof v === "object" && v !== null) { // A quick heuristic: if the object has constructor name 'Timeout' or // properties typical of timer internals, drop it to avoid circular refs. const ctor = v.constructor && v.constructor.name ? v.constructor.name : ""; if (ctor === "Timeout" || ctor === "TimersList") { delete reduced[k]; } } } } catch (e) { // Defensive: if introspection fails, delete the key to be safe try { delete reduced[k]; } catch (err) { /* ignore */ } } }); reducedGame.sessions[id] = reduced; /* Do not send session-id as those are secrets */ reducedSessions.push(reduced); } const player = session.player ? session.player : undefined; /* Strip out data that should not be shared with players */ delete reducedGame.developmentCards; /* Delete the game timer */ delete reducedGame.turnTimer; reducedGame.unselected = getFilteredUnselected(game); return Object.assign(reducedGame, { live: true, status: session.error ? session.error : "success", name: session.name, color: session.color, order: session.color in game.players ? game.players[session.color].order : 0, private: player, sessions: reducedSessions, layout: layout, players: getFilteredPlayers(game), }); }; /** * Send a consolidated initial snapshot to a single session. * This is used to allow clients (and tests) to render the full * game state deterministically on first attach instead of having * to wait for a flurry of incremental game-update events. */ const sendInitialGameSnapshot = (game: any, session: any) => { try { const snapshot = getFilteredGameForPlayer(game, session); const message = JSON.stringify({ type: "initial-game", snapshot }); // Small debug log to help test harnesses detect that the server sent // the consolidated snapshot. Keep output small to avoid noisy logs. try { const topKeys = Object.keys(snapshot || {}) .slice(0, 10) .join(","); console.log(`${session.id}: sending initial-game snapshot keys: ${topKeys}`); } catch (e) { /* ignore logging errors */ } if (session && session.ws && session.ws.send) { session.ws.send(message); } else { console.warn(`${session.id}: Unable to send initial snapshot - no websocket available`); } } catch (err) { console.error(`${session.id}: error in sendInitialGameSnapshot`, err); } }; /* Example: "stolen": { "robber": { "stole": { "total": 5, "wheat": 2, "wood": 1, "sheep": 2 } }, "O": { "stolen": { "total": 2, "wheat": 2 }, "stole": { "total": 2, "brick": 2 } }, "W": { "stolen": { "total": 4, "brick": 2, "wood": 1, "sheep": 2 }, "stole": { "total": 3, "brick": 2, "wheat": 1 } } } */ const trackTheft = (game: any, from: any, to: any, type: any, count: any) => { const stats = game.stolen; /* Initialize the stole / stolen structures */ [to, from].forEach((player) => { if (!(player in stats)) { stats[player] = { stole: { /* the resources this player stole */ total: 0, }, stolen: { /* the resources stolen from this player */ total: 0, player: 0 /* by players */, robber: 0 /* by robber */, }, }; } }); /* Initialize 'type' field in structures */ if (!(type in stats[from].stolen)) { stats[from].stolen[type] = 0; } if (!(type in stats[to].stole)) { stats[to].stole[type] = 0; } /* Update counts */ stats[from].stolen.total += count; if (to === "robber") { stats[from].stolen.robber += count; } else { stats[from].stolen.player += count; } stats[from].stolen[type] += count; stats[to].stole.total += count; 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 */ ["R", "B", "W", "O"].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) { 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 = { id: id, developmentCards: [], players: { O: newPlayer("O"), R: newPlayer("R"), B: newPlayer("B"), W: newPlayer("W"), }, sessions: {}, unselected: [], placements: { corners: [], roads: [], }, turn: { name: "", color: "", actions: [], limits: {}, roll: 0, }, rules: { "victory-points": { points: 10, }, }, step: 0 /* used for the suffix # in game backups */, }; ["pips", "borders", "tiles"].forEach((field) => { (game as any)[field] = (staticData as any)[field]; }); 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 = sequence[i]; if (typeof target !== "number") { continue; } /* 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 = typeof game.tileOrder?.[target] === "number" ? game.tileOrder[target] : undefined; const tileType = typeof tileIdx === "number" && game.tiles?.[tileIdx] ? game.tiles[tileIdx].type : undefined; 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*/) => { let playerId; if (!req.cookies.player) { playerId = crypto.randomBytes(16).toString("hex"); // Determine whether this request is secure so we can set cookie flags // appropriately. In production behind TLS we want SameSite=None and // Secure so the cookie is sent on cross-site websocket connects. const secure = req.secure || (req.headers && req.headers["x-forwarded-proto"] === "https") || process.env["NODE_ENV"] === "production"; const cookieOpts: any = { httpOnly: false, sameSite: secure ? "none" : "lax", secure: !!secure, }; // Ensure cookie is scoped to the application basePath so it will be // included on requests under the same prefix (and on the websocket // handshake which uses the same path prefix). cookieOpts.path = basePath || "/"; res.cookie("player", playerId, cookieOpts as any); console.log(`[${playerId.substring(0, 8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`); } else { playerId = req.cookies.player; } console.log(`[${playerId.substring(0, 8)}]: Browser hand-shake achieved.`); // Mark this response as coming from the backend API to aid debugging res.setHeader("X-Backend", "games"); return res.status(200).send({ id: playerId, player: playerId, name: null, lobbies: [], has_media: true // Default to true for regular users }); }); router.post("/:id?", async (req, res /*, next*/) => { const { id } = req.params; let playerId; if (!req.cookies.player) { playerId = crypto.randomBytes(16).toString("hex"); const secure = req.secure || (req.headers && req.headers["x-forwarded-proto"] === "https") || process.env["NODE_ENV"] === "production"; const cookieOpts: any = { httpOnly: false, sameSite: secure ? "none" : "lax", secure: !!secure, }; cookieOpts.path = basePath || "/"; res.cookie("player", playerId, cookieOpts as any); console.log(`[${playerId.substring(0, 8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`); } else { playerId = req.cookies.player; } if (id) { console.log(`[${playerId.substring(0, 8)}]: Attempting load of ${id}`); } else { console.log(`[${playerId.substring(0, 8)}]: Creating new game.`); } const game = await loadGame(String(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 }); }); export default router;