import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types"; import { newPlayer } from "./playerFactory"; import { debug, info, MAX_CITIES, MAX_SETTLEMENTS, SEND_THROTTLE_MS } from "./constants"; import { shuffleBoard } from "./gameFactory"; import { getVictoryPointRule } from "./rules"; export const addActivity = (game: Game, session: Session | null, message: string): void => { let date = Date.now(); if (!game.activities) game.activities = []; if (game.activities.length && game.activities[game.activities.length - 1].date === date) { date++; } const actColor = session && session.color && session.color !== "unassigned" ? session.color : ""; game.activities.push({ color: actColor, message, date }); if (game.activities.length > 30) { game.activities.splice(0, game.activities.length - 30); } }; export const addChatMessage = (game: Game, session: Session | null, message: string, isNormalChat?: boolean) => { let now = Date.now(); let lastTime = 0; if (!game.chat) game.chat = []; if (game.chat.length) { lastTime = game.chat[game.chat.length - 1].date; } if (now <= lastTime) { now = lastTime + 1; } const entry: any = { date: now, message: message, }; if (isNormalChat) { entry.normalChat = true; } if (session && session.name) { entry.from = session.name; } if (session && session.color && session.color !== "unassigned") { entry.color = session.color; } game.chat.push(entry); if (game.chat.length > 50) { game.chat.splice(0, game.chat.length - 50); } }; export const getColorFromName = (game: Game, name: string): string => { for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.name === name) { return s.color && s.color !== "unassigned" ? s.color : ""; } } return ""; }; export const getLastPlayerName = (game: Game): string => { const index = (game.playerOrder || []).length - 1; const color = (game.playerOrder || [])[index]; if (!color) return ""; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.color === color) { return s.name || ""; } } return ""; }; export const getFirstPlayerName = (game: Game): string => { const color = (game.playerOrder || [])[0]; if (!color) return ""; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.color === color) { return s.name || ""; } } return ""; }; export const getNextPlayerSession = (game: Game, name: string): Session | undefined => { let color: PlayerColor | undefined; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.name === name) { color = s.color; break; } } if (!color) return undefined; const order = game.playerOrder || []; let index = order.indexOf(color); if (index === -1) return undefined; index = (index + 1) % order.length; const nextColor = order[index]; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.color === nextColor) { return s; } } console.error(`getNextPlayerSession -- no player found!`); console.log(game.players); return undefined; }; export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => { let color: PlayerColor | undefined; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.name === name) { color = s.color; break; } } if (!color) return undefined; const order = game.playerOrder || []; let index = order.indexOf(color); if (index === -1) return undefined; index = (index - 1 + order.length) % order.length; const prevColor = order[index]; for (let id in game.sessions) { const s = game.sessions[id]; if (s && s.color === prevColor) { return s; } } console.error(`getPrevPlayerSession -- no player found!`); console.log(game.players); return undefined; }; export const clearPlayer = (player: Player) => { // Use shared factory to ensure a single source of defaults Object.assign(player, newPlayer(player.color)); }; export const canGiveBuilding = (game: Game): string | undefined => { if (!game.turn.roll) { return `Admin cannot give a building until the dice have been rolled.`; } if (game.turn.actions && game.turn.actions.length !== 0) { return `Admin cannot give a building while other actions in play: ${game.turn.actions.join(", ")}.`; } return undefined; }; export const setForRoadPlacement = (game: Game, limits: any): void => { game.turn.actions = ["place-road"]; game.turn.limits = { roads: limits }; }; export const setForCityPlacement = (game: Game, limits: any): void => { game.turn.actions = ["place-city"]; game.turn.limits = { corners: limits }; }; export const setForSettlementPlacement = (game: Game, limits: number[]): void => { game.turn.actions = ["place-settlement"]; game.turn.limits = { corners: limits }; }; // Adjust a player's resource counts by a deltas map. Deltas may be negative. export const adjustResources = (player: Player, deltas: Partial>): void => { if (!player) return; let total = player.resources || 0; const keys = Object.keys(deltas); keys.forEach((type) => { const v = deltas[type] || 0; // update named resource slot if present try { switch (type) { case "wood": case "brick": case "sheep": case "wheat": case "stone": const current = player[type] || 0; player[type] = current + v; total += v; break; } } catch (e) { // ignore unexpected keys } }); player.resources = total; }; export const startTurnTimer = (game: Game, session: Session) => { const timeout = 90; if (!session.ws) { console.log(`${session.short}: Aborting turn timer as ${session.name} is disconnected.`); } else { console.log(`${session.short}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`); } if (game.turnTimer) { clearTimeout(game.turnTimer); } if (!session.connected) { game.turnTimer = 0; return; } game.turnTimer = setTimeout(() => { console.log(`${session.short}: Turn timer expired for ${session.name}`); if (session.player) { session.player.turnNotice = "It is still your turn."; } sendUpdateToPlayer(game, session, { private: session.player, }); resetTurnTimer(game, session); }, timeout * 1000); }; const calculatePoints = (game: any, update: any): void => { if (game.state === "winner") { return; } /* Calculate points and determine if there is a winner */ for (let key in game.players) { const player = game.players[key]; if (player.status === "Not active") { continue; } const currentPoints = player.points; player.points = 0; if (key === game.longestRoad) { player.points += 2; } if (key === game.largestArmy) { player.points += 2; } if (key === game.mostPorts) { player.points += 2; } if (key === game.mostDeveloped) { player.points += 2; } player.points += MAX_SETTLEMENTS - player.settlements; player.points += 2 * (MAX_CITIES - player.cities); player.unplayed = 0; player.potential = 0; player.development.forEach((card: any) => { if (card.type === "vp") { if (card.played) { player.points++; } else { player.potential++; } } if (!card.played) { player.unplayed++; } }); if (player.points === currentPoints) { continue; } if (player.points < getVictoryPointRule(game)) { update.players = getFilteredPlayers(game); continue; } /* This player has enough points! Check if they are the current * player and if so, declare victory! */ console.log(`${info}: Whoa! ${player.name} has ${player.points}!`); for (let key in game.sessions) { if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") { continue; } const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`; addChatMessage(game, null, message); console.log(`${info}: ${message}`); update.winner = Object.assign({}, player, { state: "winner", stolen: game.stolen, chat: game.chat, turns: game.turns, players: game.players, elapsedTime: Date.now() - game.startTime, }); game.winner = update.winner; game.state = "winner"; game.waiting = []; stopTurnTimer(game); sendUpdateToPlayers(game, { state: game.state, winner: game.winner, players: game.players /* unfiltered */, }); } } /* If the game isn't in a win state, do not share development card information * with other players */ if (game.state !== "winner") { for (let key in game.players) { const player = game.players[key]; if (player.status === "Not active") { continue; } delete player.potential; } } }; export const resetTurnTimer = (game: Game, session: Session): void => { startTurnTimer(game, session); }; export const stopTurnTimer = (game: Game): void => { if (game.turnTimer) { console.log(`${info}: Stopping turn timer.`); try { clearTimeout(game.turnTimer); } catch (e) { /* ignore if not a real timeout */ } game.turnTimer = 0; } return undefined; }; export const sendUpdateToPlayers = async (game: any, update: any): Promise => { /* Ensure clearing of a field actually gets sent by setting * undefined to 'false' */ for (let key in update) { if (update[key] === undefined) { update[key] = false; } } calculatePoints(game, update); if (debug.update) { console.log(`[ all ]: -> sendUpdateToPlayers - `, update); } else { const keys = Object.getOwnPropertyNames(update); console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`); } const message = JSON.stringify({ type: "game-update", update, }); for (let key in game.sessions) { const session = game.sessions[key]; /* Only send player and game data to named players */ if (!session.name) { console.log(`${session.short}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`); if (session.ws) { session.ws.send( JSON.stringify({ type: "game-update", update: { name: "" }, }) ); } continue; } if (!session.ws) { console.log(`${session.short}: -> sendUpdateToPlayers: ` + `Currently no connection.`); } else { queueSend(session, message); } } }; export const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise => { /* If this player does not have a name, *ONLY* send the name, regardless * of what is requested */ if (!session.name) { console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`); update = { name: "" }; } /* Ensure clearing of a field actually gets sent by setting * undefined to 'false' */ for (let key in update) { if (update[key] === undefined) { update[key] = false; } } calculatePoints(game, update); if (debug.update) { console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - `, update); } else { const keys = Object.getOwnPropertyNames(update); console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`); } const message = JSON.stringify({ type: "game-update", update, }); if (!session.ws) { console.log(`${session.short}: -> sendUpdateToPlayer: ` + `Currently no connection.`); } else { queueSend(session, message); } }; export const queueSend = (session: any, message: any): void => { if (!session || !session.ws) return; try { // Ensure we compare a stable serialization: if message is JSON text, // parse it and re-serialize with sorted keys so semantically-equal // objects compare equal even when property order differs. const stableStringify = (msg: any): string => { try { const obj = typeof msg === "string" ? JSON.parse(msg) : msg; const ordered = (v: any): any => { if (v === null || typeof v !== "object") return v; if (Array.isArray(v)) return v.map(ordered); const keys = Object.keys(v).sort(); const out: any = {}; for (const k of keys) out[k] = ordered(v[k]); return out; }; return JSON.stringify(ordered(obj)); } catch (e) { // If parsing fails, fall back to original string representation return typeof msg === "string" ? msg : JSON.stringify(msg); } }; const stableMessage = stableStringify(message); const now = Date.now(); if (!session._lastSent) session._lastSent = 0; const elapsed = now - session._lastSent; // If the exact same message (in stable form) was sent last time and // nothing is pending, skip sending to avoid pointless duplicate // traffic. if (!session._pendingTimeout && session._lastMessage === stableMessage) { return; } // If we haven't sent recently and there's no pending timer, send now if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) { try { session.ws.send(typeof message === "string" ? message : JSON.stringify(message)); session._lastSent = Date.now(); session._lastMessage = stableMessage; } catch (e) { console.warn(`${session.id}: queueSend immediate send failed:`, e); } return; } // Otherwise, store latest message and schedule a send // If the pending message would equal the last-sent message, don't bother // storing/scheduling it. if (session._lastMessage === stableMessage) { return; } session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message); if (session._pendingTimeout) { // already scheduled; newest message will be sent when timer fires return; } const delay = Math.max(1, SEND_THROTTLE_MS - elapsed); session._pendingTimeout = setTimeout(() => { try { if (session.ws && session._pendingMessage) { session.ws.send(session._pendingMessage); session._lastSent = Date.now(); // compute stable form of what we actually sent try { session._lastMessage = stableStringify(session._pendingMessage); } catch (e) { session._lastMessage = session._pendingMessage; } } } catch (e) { console.warn(`${session.id}: queueSend delayed send failed:`, e); } // clear pending fields session._pendingMessage = undefined; clearTimeout(session._pendingTimeout); session._pendingTimeout = undefined; }, delay); } catch (e) { console.warn(`${session.id}: queueSend exception:`, e); } }; export const shuffle = (game: Game, session: Session): string | undefined => { if (game.state !== "lobby") { return `Game no longer in lobby (${game.state}). Can not shuffle board.`; } if (game.turns > 0) { return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`; } shuffleBoard(game); console.log(`${session.short}: Shuffled to new signature: ${game.signature}`); sendUpdateToPlayers(game, { pipOrder: game.pipOrder, tileOrder: game.tileOrder, borderOrder: game.borderOrder, robber: game.robber, robberName: game.robberName, signature: game.signature, animationSeeds: game.animationSeeds, }); return undefined; }; export const getName = (session: Session): string => { return session ? (session.name ? session.name : session.id) : "Admin"; }; export const getFilteredPlayers = (game: Game): Record => { const filtered: Record = {}; for (let color in game.players) { const player = Object.assign({}, game.players[color]); filtered[color] = player; if (player.status === "Not active") { if (game.state !== "lobby") { delete filtered[color]; } continue; } player.resources = 0; RESOURCE_TYPES.forEach((resource) => { switch (resource) { case "wood": case "brick": case "sheep": case "wheat": case "stone": player.resources += player[resource]; player[resource] = 0; break; } }); player.development = []; } return filtered; };