"use strict"; const express = require("express"), router = express.Router(), crypto = require("crypto") const { readFile, writeFile, mkdir } = require("fs").promises, fs = require("fs"), accessSync = fs.accessSync, randomWords = require("random-words"), equal = require("fast-deep-equal"); const { layout, staticData } = require('../util/layout.js'); const { getValidRoads, getValidCorners, isRuleEnabled } = require('../util/validLocations.js'); const MAX_SETTLEMENTS = 5; const MAX_CITIES = 4; const MAX_ROADS = 15; const types = [ 'wheat', 'stone', 'sheep', 'wood', 'brick' ]; const debug = { audio: false, get: true, set: true, update: false }; // Normalize incoming websocket messages to a canonical { type, data } // shape. Some clients historically sent the payload as { type, data } while // others used a flatter shape. This helper accepts either a string or an // already-parsed object and returns a stable object so handlers don't need // to defensively check multiple nested locations. function normalizeIncoming(msg) { if (!msg) return { type: null, data: null }; let parsed = null; try { if (typeof msg === 'string') { parsed = JSON.parse(msg); } else { parsed = msg; } } catch (e) { // if parsing failed, return nulls so the caller can log/ignore return { type: null, data: null }; } if (!parsed) return { type: null, data: null }; const type = parsed.type || parsed.action || null; // Prefer parsed.data when present, but allow flattened payloads where // properties like `name` live at the root. const data = parsed.data || (Object.keys(parsed).length ? Object.assign({}, parsed) : null); return { type, data }; } let gameDB; require("../db/games").then(function(db) { gameDB = db; }); function shuffleArray(array) { var currentIndex = array.length, temporaryValue, randomIndex; // While there remain elements to shuffle... while (0 !== currentIndex) { // Pick a remaining element... randomIndex = Math.floor(Math.random() * currentIndex); currentIndex -= 1; // And swap it with the current element. temporaryValue = array[currentIndex]; array[currentIndex] = array[randomIndex]; array[randomIndex] = temporaryValue; } return array; } const games = {}; const audio = {}; const processTies = (players) => { /* 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 = []; players.forEach(player => { if (!slots[player.order]) { slots[player.order] = []; } slots[player.order].push(player); }); let ties = false, position = 1; const irstify = (position) => { switch (position) { case 1: return `1st`; case 2: return `2nd`; case 3: return `3rd`; case 4: return `4th`; default: return position; } } /* Reverse from high to low */ slots.reverse().forEach((slot) => { if (slot.length !== 1) { ties = true; slot.forEach(player => { player.orderRoll = 0; /* Ties have to be re-rolled */ player.position = irstify(position); player.orderStatus = `Tied for ${irstify(position)}`; player.tied = true; }); } else { slot[0].tied = false; slot[0].position = irstify(position); slot[0].orderStatus = `Placed in ${irstify(position)}.`; } position += slot.length }); return ties; } const processGameOrder = (game, player, dice) => { if (player.orderRoll) { return `You have already rolled for game order and are not in a tie.`; } player.orderRoll = dice; player.order = player.order * 6 + dice; const players = []; let doneRolling = true; for (let key in game.players) { if (!game.players[key].orderRoll) { doneRolling = false; } players.push(game.players[key]); } /* 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); game.state = 'initial-placement'; game.direction = 'forward'; game.turn = { name: players[0].name, color: players[0].color }; 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.direction, turn: game.turn, chat: game.chat, activities: game.activities }); } const processVolcano = (game, session, dice) => { const player = session.player, name = session.name ? session.name : "Unnamed"; const volcano = layout.tiles.findIndex((tile, index) => staticData.tiles[game.tileOrder[index]].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'; game.turn.volcano = layout.tiles[volcano].corners[dice[0] % 6]; const corner = game.placements.corners[game.turn.volcano]; if (corner.color) { const player = game.players[corner.color]; if (corner.type === 'city') { if (player.settlements) { addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); player.cities++; player.settlements--; 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++; } } else { addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); delete corner.type; delete corner.color; player.settlements++; } } sendUpdateToPlayers(game, { turn: game.turn, state: game.state, chat: game.chat, dice: game.dice, placements: game.placements, players: getFilteredPlayers(game) }); } const roll = (game, session, dice) => { const player = session.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; case "game-order": game.startTime = Date.now(); addChatMessage(game, session, `${name} rolled ${dice[0]}.`); 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); sendUpdateToPlayers(game, { chat: game.chat }); 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 */ processVolcano(game, session, [ dice[0] ]); return; default: return `Invalid game state (${game.state}) in roll.`; } } const sessionFromColor = (game, color) => { for (let key in game.sessions) { if (game.sessions[key].color === color) { return game.sessions[key]; } } } const distributeResources = (game, roll) => { console.log(`Roll: ${roll}`); /* Find which tiles have this roll */ let tiles = []; for (let i = 0; i < game.pipOrder.length; i++) { let index = game.pipOrder[i]; if (staticData.pips[index].roll === roll) { if (game.robber === i) { tiles.push({ robber: true, index: i }); } else { tiles.push({ robber: false, index: i }); } } } const receives = { "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 */ tiles.forEach(tile => { let shuffle = game.tileOrder[tile.index]; const resource = game.tiles[shuffle]; layout.tiles[tile.index].corners.forEach(cornerIndex => { const active = game.placements.corners[cornerIndex]; if (active && active.color) { const count = active.type === 'settlement' ? 1 : 2; if (!tile.robber) { receives[active.color][resource.type] += count; } else { if (isRuleEnabled(game, `robin-hood-robber`) && game.players[active.color].points <= 2) { addChatMessage(game, null, `Robber does not steal ${count} ${resource.type} from ${game.players[active.color].name} ` + `due to Robin Hood Robber house rule.`); console.log(`robin-hood-robber`, game.players[active.color], active.color); receives[active.color][resource.type] += count; } else { trackTheft(game, active.color, 'robber', resource.type, count); receives.robber[resource.type] += count; } } } }) }); const robber = []; for (let color in receives) { const entry = receives[color]; if (!entry.wood && !entry.brick && !entry.sheep && !entry.wheat && !entry.stone) { continue; } let message = [], session; for (let type in entry) { if (entry[type] === 0) { continue; } if (color !== 'robber') { session = sessionFromColor(game, color); session.player[type] += entry[type]; session.player.resources += entry[type]; message.push(`${entry[type]} ${type}`); } else { robber.push(`${entry[type]} ${type}`); } } if (session) { addChatMessage(game, session, `${session.name} receives ${message.join(', ')} for pip ${roll}.`); } } if (robber.length) { addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robber.join(', ')}!`); } } const pickRobber = (game) => { 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, session, dice) => { if (!dice[1]) { console.error(`Invalid roll sequence!`); return; } addChatMessage(game, session, `${session.name} rolled ` + `${dice[0]}, ${dice[1]}.`); const sum = dice[0] + dice[1]; 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 (dice[0] + dice[1] === 12) { addChatMessage(game, session, `House rule 'Twelve and Two are Synonyms' activated. Twelve was rolled, so two is triggered too!`); distributeResources(game, 2); } if (dice[0] + dice[1] === 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 volcano = layout.tiles.find((tile, index) => staticData.tiles[game.tileOrder[index]].type === 'desert'); volcano.corners.forEach(index => { const corner = game.placements.corners[index]; if (corner.color) { if (!(corner.color in game.turn.select)) { game.turn.select[corner.color] = 0; } game.turn.select[corner.color] += 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 (volcano === layout.tiles[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!`); 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) { if (game.sessions[id].player) { sendUpdateToPlayer(game, game.sessions[id], { private: game.sessions[id].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 + player.wheat + player.brick + player.wood + player.sheep; 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) { if (game.sessions[key].player === player) { sendUpdateToPlayer(game, game.sessions[key], { private: player }); break; } } }); } sendUpdateToPlayers(game, { turn: game.turn, players: getFilteredPlayers(game), chat: game.chat, dice: game.dice }); } const newPlayer = (color) => { return { roads: MAX_ROADS, cities: MAX_CITIES, settlements: MAX_SETTLEMENTS, points: 0, status: "Not active", lastActive: 0, resources: 0, order: 0, stone: 0, wheat: 0, sheep: 0, wood: 0, brick: 0, army: 0, development: [], color: color, name: "", totalTime: 0, turnStart: 0, ports: 0, developmentCards: 0 }; } const getSession = (game, id) => { 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: '', player: undefined, lastActive: Date.now(), live: true }; } 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.color || _session.name || _session.player) { continue; } if (_id === id) { continue; } /* 60 minutes */ const age = Date.now() - _session.lastActive; 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) => { 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; } let game = await readFile(`/db/games/${id}`) .catch(() => { return; }); if (game) { try { game = JSON.parse(game); console.log(`${info}: Creating backup of /db/games/${id}`); await writeFile(`/db/games/${id}.bk`, JSON.stringify(game)); } catch (error) { console.log(`Load or parse error from /db/games/${id}:`, error); console.log(`Attempting to load backup from /db/games/${id}.bk`); game = await readFile(`/db/games/${id}.bk`) .catch(() => { console.error(error, game); }); if (game) { try { game = JSON.parse(game); console.log(`Saving backup to /db/games/${id}`); await writeFile(`/db/games/${id}`, JSON.stringify(game, null, 2)); } catch (error) { console.error(error); game = null; } } } } if (!game) { game = createGame(id); } /* 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 clearPlayer = (player) => { const color = player.color; for (let key in player) { delete player[key]; } Object.assign(player, newPlayer(color)); } const canGiveBuilding = (game) => { 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(', ')}.` } } const adminCommands = (game, action, value, query) => { let color, player, parts, session, corners, error; switch (action) { case 'rules': const rule = value.replace(/=.*$/, ''); if (rule === 'list') { const rules = {}; 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 rules = {}; rules[rule] = {}; values.forEach(keypair => { let [ key, value ] = keypair.split(':'); if (value === 'true') { value = true; } else if (value === 'false') { value = false; } else if (parseInt(value) === value) { value = parseInt(value); } rules[rule][key] = value; }); console.log(`admin - setRules -`, rules); setRules(game, undefined, rules); 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; for (let id in game.sessions) { if (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(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 => 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]; tmp.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 => `${card.type}-${card.card}`) .join(', '); return results; case "roll": let dice = (query.dice || Math.ceil(Math.random() * 6)).split(','); dice = dice.map(die => 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 (!color) { return `Unable to find player ${value}` } const rollingPlayer = (color) => { for (let id in game.sessions) { if ((color && game.sessions[id].player && game.sessions[id].player.color === color) || (game.sessions[id].name === game.turn.name)) { return game.sessions[id]; } } return undefined; }; addChatMessage(game, null, `Admin rolling ${dice.join(', ')} for ${value}.`); if (game.state === 'game-order') { session = rollingPlayer(color); } else { session = rollingPlayer(); } 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); game.turn = { name: next.player, color: next.color }; game.turns++; 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 (!color) { return `Unable to find player ${value}` } player = game.players[color]; for (let id in game.sessions) { const session = game.sessions[id]; if (session.player !== player) { continue; } console.log(`Kicking ${value} from ${game.id}.`); const preamble = session.name ? `${session.name}, playing as ${colorToWord(color)},` : colorToWord(color); addChatMessage(game, null, `${preamble} was kicked from game by the Admin.`); if (player) { clearPlayer(player); session.player = undefined; } session.color = ""; return; } return `Unable to find active session for ${colorToWord(color)} (${value})`; case "state": if (game.state !== 'lobby') { return `Game already started.`; } if (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) { if (game.players[key].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, session, name) => { 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 === session || !tmp.name) { continue; } if (tmp.name.toLowerCase() === name.toLowerCase()) { if (!tmp.player || (Date.now() - tmp.player.lastActive) > 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]) { part(audio[game.id], session); } } else { message = `${session.name} has changed their name to ${name}.`; if (session.ws && game.id in audio) { part(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) { join(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) { if (!game.sessions[id].color && game.sessions[id].name) { game.unselected.push(game.sessions[id]); } } sendUpdateToPlayer(game, session, { name: session.name, color: session.color, live: session.live, private: session.player }); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), unselected: getFilteredUnselected(game), chat: game.chat }); /* Now that a name is set, send the full game to the player */ sendGameToPlayer(game, session); } const colorToWord = (color) => { switch (color) { case 'O': return 'orange'; case 'W': return 'white'; case 'B': return 'blue'; case 'R': return 'red'; default: return ''; } } const getActiveCount = (game) => { let active = 0; for (let color in game.players) { if (!game.players[color].name) { continue; } active++; } return active; } const setPlayerColor = (game, session, color) => { /* 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 && game.players[color].status !== 'Not active') { return `${game.players[color].name} already has ${colorToWord(color)}`; } let active = getActiveCount(game); if (session.player) { /* Deselect currently active player for this session */ clearPlayer(session.player); session.player = undefined; session.color = ''; active--; /* If the player is not selecting a color, then return */ if (!color) { addChatMessage(game, null, `${session.name} is no longer ${colorToWord(session.color)}.`); 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; session.player = game.players[color]; session.player.name = session.name; session.player.status = `Active`; session.player.lastActive = Date.now(); session.player.live = true; addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`); const update = { players: getFilteredPlayers(game), chat: game.chat }; /* Rebuild the unselected list */ const unselected = []; for (let id in game.sessions) { if (!game.sessions[id].color && game.sessions[id].name) { unselected.push(game.sessions[id]); } } if (unselected.length !== game.unselected.length) { game.unselected = unselected; update.unselected = getFilteredUnselected(game); } 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); }; const addActivity = (game, session, message) => { let date = Date.now(); if (game.activities.length && game.activities[game.activities.length - 1].date === date) { date++; } game.activities.push({ color: session ? session.color : '', message, date }); if (game.activities.length > 30) { game.activities.splice(0, game.activities.length - 30); } } const addChatMessage = (game, session, message, isNormalChat) => { let now = Date.now(); let lastTime = 0; if (game.chat.length) { lastTime = game.chat[game.chat.length - 1].date; } if (now <= lastTime) { now = lastTime + 1; } const entry = { date: now, message: message }; if (isNormalChat) { entry.normalChat = true; } if (session && session.name) { entry.from = session.name; } if (session && session.color) { entry.color = session.color; } game.chat.push(entry); if (game.chat.length > 50) { game.chat.splice(0, game.chat.length - 50); } }; const getColorFromName = (game, name) => { for (let id in game.sessions) { if (game.sessions[id].name === name) { return game.sessions[id].color; } } return ''; }; const getLastPlayerName = (game) => { let index = game.playerOrder.length - 1; for (let id in game.sessions) { if (game.sessions[id].color === game.playerOrder[index]) { return game.sessions[id].name; } } return ''; } const getFirstPlayerName = (game) => { let index = 0; for (let id in game.sessions) { if (game.sessions[id].color === game.playerOrder[index]) { return game.sessions[id].name; } } return ''; } const getNextPlayerSession = (game, name) => { let color; for (let id in game.sessions) { if (game.sessions[id].name === name) { color = game.sessions[id].color; break; } } let index = game.playerOrder.indexOf(color); index = (index + 1) % game.playerOrder.length; color = game.playerOrder[index]; for (let id in game.sessions) { if (game.sessions[id].color === color) { return game.sessions[id]; } } console.error(`getNextPlayerSession -- no player found!`); console.log(game.players); } const getPrevPlayerSession = (game, name) => { let color; for (let id in game.sessions) { if (game.sessions[id].name === name) { color = game.sessions[id].color; break; } } let index = game.playerOrder.indexOf(color); index = (index - 1) % game.playerOrder.length; for (let id in game.sessions) { if (game.sessions[id].color === game.playerOrder[index]) { return game.sessions[id]; } } console.error(`getNextPlayerSession -- no player found!`); console.log(game.players); } const processCorner = (game, color, cornerIndex, placedCorner) => { /* 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 => { const placedRoad = game.placements.roads[roadIndex]; 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, color, cornerIndex, placedCorner, set) => { /* 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 => { const placedRoad = game.placements.roads[roadIndex]; buildRoadGraph(game, color, roadIndex, placedRoad, set); }); }; const processRoad = (game, color, roadIndex, placedRoad) => { /* 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 => { const placedCorner = game.placements.corners[cornerIndex]; if (placedCorner.walking) { return; } roadLength += processCorner(game, color, cornerIndex, placedCorner); }); return roadLength; }; const buildRoadGraph = (game, color, roadIndex, placedRoad, set) => { /* 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 => { const placedCorner = game.placements.corners[cornerIndex]; buildCornerGraph(game, color, cornerIndex, placedCorner, set) }); }; const clearRoadWalking = (game) => { /* Clear out walk markers on roads */ layout.roads.forEach((item, itemIndex) => { delete game.placements.roads[itemIndex].walking; }); /* Clear out walk markers on corners */ layout.corners.forEach((item, itemIndex) => { delete game.placements.corners[itemIndex].walking; }); } const calculateRoadLengths = (game, session) => { clearRoadWalking(game); let currentLongest = game.longestRoad, currentLength = currentLongest ? game.players[currentLongest].longestRoad : -1; /* Clear out player longest road counts */ for (let key in game.players) { 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 = []; layout.roads.forEach((road, roadIndex) => { const placedRoad = game.placements.roads[roadIndex]; if (placedRoad.color) { let set = []; 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 => { graph.longestRoad = 0; graph.set.forEach(roadIndex => { const placedRoad = game.placements.roads[roadIndex]; clearRoadWalking(game); const length = processRoad(game, placedRoad.color, 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] === 'Not active') { continue; } game.players[color].longestRoad = 0; } graphs.forEach(graph => { graph.set.forEach(roadIndex => { const placedRoad = game.placements.roads[roadIndex]; clearRoadWalking(game); const longestRoad = processRoad(game, placedRoad.color, roadIndex, placedRoad); placedRoad.longestRoad = longestRoad; game.players[placedRoad.color].longestRoad = Math.max(game.players[placedRoad.color].longestRoad, longestRoad); }); }); game.placements.roads.forEach(road => delete road.walking); if (debug.road) console.log('Post update:', game.placements.roads.filter(road => road.color)); let checkForTies = false; if (debug.road) console.log(currentLongest, currentLength); if (currentLongest && game.players[currentLongest].longestRoad < currentLength) { const _session = sessionFromColor(game, currentLongest); addChatMessage(game, session, `${session.name} had their longest road split!`); checkForTies = true; } let longestRoad = 4, longestPlayers = []; for (let key in game.players) { const player = game.players[key]; if (player.status === 'Not active') { continue; } if (player.longestRoad > longestRoad) { longestPlayers = [ player ]; longestRoad = player.longestRoad; } else if (game.players[key].longestRoad === longestRoad) { if (longestRoad >= 5) { longestPlayers.push(player); } } } console.log({ longestPlayers }); if (longestPlayers.length > 0) { if (longestPlayers.length === 1) { game.longestRoadLength = longestRoad; 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, offer) => { const isBank = offer.name === 'The bank'; let valid = player.gets.length === offer.gives.length && player.gives.length === offer.gets.length; if (!valid) { console.log(`Gives and gets lengths do not match!`); return false; } console.log({ player: 'Submitting player', gets: player.gets, gives: player.gives }, { name: offer.name, gets: offer.gets, gives: offer.gives }); player.gets.forEach(get => { if (!valid) { return; } valid = offer.gives.find(item => (item.type === get.type || isBank) && item.count === get.count) !== undefined; }); if (valid) player.gives.forEach(give => { if (!valid) { return; } valid = offer.gets.find(item => (item.type === give.type || isBank) && item.count === give.count) !== undefined; }); return valid; }; const isSameOffer = (player, offer) => { const isBank = offer.name === 'The bank'; if (isBank) { return false; } let same = player.gets && player.gives && player.gets.length === offer.gets.length && player.gives.length === offer.gives.length; if (!same) { return false; } player.gets.forEach(get => { if (!same) { return; } same = offer.gets.find(item => item.type === get.type && item.count === get.count) !== undefined; }); if (same) player.gives.forEach(give => { if (!same) { return; } same = offer.gives.find(item => item.type === give.type && item.count === give.count) !== undefined; }); return same; }; /* Verifies player can meet the offer */ const checkPlayerOffer = (game, player, offer) => { let error = undefined; const name = player.name; console.log({ checkPlayerOffer: { name: name, player: 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) } }); offer.gives.forEach(give => { if (!error) { return; } if (!(give.type in player)) { error = `${give.type} is not a valid resource!`; return; } if (give.count <= 0) { error = `${give.count} must be more than 0!` return; } if (player[give.type] < give.count) { error = `${name} does do not have ${give.count} ${give.type}!`; return; } if (offer.gets.find(get => give.type === get.type)) { error = `${name} can not give and get the same resource type!`; return; } }); if (!error) offer.gets.forEach(get => { if (error) { return; } if (get.count <= 0) { error = `${get.count} must be more than 0!`; return; } if (offer.gives.find(give => get.type === give.type)) { error = `${name} can not give and get the same resource type!`; }; }) return error; }; const canMeetOffer = (player, offer) => { for (let i = 0; i < offer.gets.length; i++) { const get = offer.gets[i]; if (get.type === 'bank') { if (player[player.gives[0].type] < get.count || get.count <= 0) { return false; } } else if (player[get.type] < get.count || get.count <= 0) { return false; } } return true; }; const gameSignature = (game) => { if (!game) { return ""; } const salt = 251; const signature = game.borderOrder.map(border => `00${(Number(border)^salt).toString(16)}`.slice(-2)).join('') + '-' + game.pipOrder.map((pip, index) => `00${(Number(pip)^salt^(salt*index)).toString(16)}`.slice(-2)).join('') + '-' + game.tileOrder.map((tile, index) => `00${(Number(tile)^salt^(salt*index)).toString(16)}`.slice(-2)).join(''); return signature; }; const setGameFromSignature = (game, border, pip, tile) => { const salt = 251; const borders = [], pips = [], tiles = []; for (let i = 0; i < 6; i++) { borders[i] = parseInt(border.slice(i * 2, (i * 2) + 2), 16)^salt; if (borders[i] > 6) { return false; } } for (let i = 0; i < 19; i++) { pips[i] = parseInt(pip.slice(i * 2, (i * 2) + 2), 16)^salt^(salt*i) % 256; if (pips[i] > 18) { return false; } } for (let i = 0; i < 19; i++) { tiles[i] = parseInt(tile.slice(i * 2, (i * 2) + 2), 16)^salt^(salt*i) % 256; if (tiles[i] > 18) { return false; } } game.borderOrder = borders; game.pipOrder = pips; game.tileOrder = tiles; return true; } const offerToString = (offer) => { return offer.gives.map(item => `${item.count} ${item.type}`).join(', ') + ' in exchange for ' + offer.gets.map(item => `${item.count} ${item.type}`).join(', '); } const setForRoadPlacement = (game, limits) => { game.turn.actions = [ 'place-road' ]; game.turn.limits = { roads: limits }; } const setForCityPlacement = (game, limits) => { game.turn.actions = [ 'place-city' ]; game.turn.limits = { corners: limits }; } const setForSettlementPlacement = (game, limits) => { game.turn.actions = [ 'place-settlement' ]; game.turn.limits = { corners: limits }; } 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, session) => { /* 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) { game.players[key].gives = []; game.players[key].gets = []; delete game.players[key].offerRejected; } addActivity(game, session, `${session.name} requested to begin trading negotiations.`); }; const cancelTrade = (game, session) => { /* 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.`); }; const processOffer = (game, session, offer) => { let warning = checkPlayerOffer(game, session.player, offer); if (warning) { return warning; } if (isSameOffer(session.player, offer)) { console.log(session.player); return `You already have a pending offer submitted for ${offerToString(offer)}.`; } session.player.gives = offer.gives; session.player.gets = offer.gets; session.player.offerRejected = {}; if (game.turn.color === session.color) { game.turn.offer = offer; } /* If this offer matches what another player wants, clear rejection * on of that other player's offer */ for (let color in game.players) { if (color === session.color) { continue; } const other = game.players[color]; if (other.status !== 'Active') { continue; } /* Comparison reverses give/get order */ if (isSameOffer(other, { gives: offer.gets, gets: offer.gives })) { if (other.offerRejected) { delete other.offerRejected[session.color]; } } } addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`); }; const rejectOffer = (game, session, offer) => { /* If the active player rejected an offer, they rejected another player */ const other = game.players[offer.color]; if (!other.offerRejected) { other.offerRejected = {}; } other.offerRejected[session.color] = true; if (!session.player.offerRejected) { session.player.offerRejected = {}; } session.player.offerRejected[offer.color] = true; addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`); }; const acceptOffer = (game, session, offer) => { const name = session.name, player = session.player; if (game.turn.name !== name) { return `Only the active player can accept an offer.`; } let target; console.log({ description: offerToString(offer) }); let warning = checkPlayerOffer(game, session.player, offer); if (warning) { return warning; } if (!isCompatibleOffer(session.player, { name: offer.name, gives: offer.gets, gets: offer.gives })) { 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.name || offer.name !== 'The bank') { target = game.players[offer.color]; if (offer.color in target.offerRejected) { return `${target.name} rejected this offer.`; } if (!isCompatibleOffer(target, offer)) { return `Unfortunately, trades were re-negotiated in transit and ` + `the deal is invalid!`; } warning = checkPlayerOffer(game, target, { gives: offer.gets, gets: offer.gives }); if (warning) { return warning; } if (!isSameOffer(target, { gives: offer.gets, gets: offer.gives })) { console.log({ target, offer }); return `These terms were not agreed to by ${target.name}!`; } if (!canMeetOffer(target, player)) { return `${target.name} cannot meet the terms.`; } } else { target = offer; } debugChat(game, 'Before trade'); /* Transfer goods */ offer.gets.forEach(item => { if (target.name !== 'The bank') { target[item.type] -= item.count; target.resources -= item.count; } player[item.type] += item.count; player.resources += item.count; }); offer.gives.forEach(item => { if (target.name !== 'The bank') { target[item.type] += item.count; target.resources += item.count; } player[item.type] -= item.count; player.resources -= item.count; }); const from = (offer.name === 'The bank') ? 'the bank' : offer.name; addChatMessage(game, session, `${session.name} traded ` + ` ${offerToString(offer)} ` + `from ${from}.`); addActivity(game, session, `${session.name} accepted a trade from ${from}.`) delete game.turn.offer; if (target) { delete target.gives; delete target.gets; } delete session.player.gives; delete session.player.gets; delete game.turn.offer; debugChat(game, 'After trade'); /* Debug!!! */ for (let key in game.players) { if (!game.players[key].state === 'Active') { continue; } types.forEach(type => { if (game.players[key][type] < 0) { throw new Error(`Player resources are below zero! BUG BUG BUG!`); } }); } game.turn.actions = []; }; const trade = (game, session, action, offer) => { 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); } /* Any player can reject an offer */ if (action === 'reject') { return rejectOffer(game, session, offer); } /* Only the active player can accept an offer */ if (action === 'accept') { if (offer.name === 'The bank') { session.player.gets = offer.gets; session.player.gives = offer.gives; } return acceptOffer(game, session, offer); } } const clearTimeNotice= (game, session) => { if (!session.player.turnNotice) { /* benign state; don't alert the user */ //return `You have not been idle.`; } session.player.turnNotice = ""; sendUpdateToPlayer(game, session, { private: session.player }); }; const startTurnTimer = (game, 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}`); session.player.turnNotice = 'It is still your turn.'; sendUpdateToPlayer(game, session, { private: session.player }); resetTurnTimer(game, session); }, timeout * 1000); } const resetTurnTimer = (game, session) => { startTurnTimer(game, session); } const stopTurnTimer = (game) => { if (game.turnTimer) { console.log(`${info}: Stopping turn timer.`); clearTimeout(game.turnTimer); game.turnTimer = 0; } } const shuffle = (game, session) => { 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 }); } const pass = (game, session) => { 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); session.player.totalTime += Date.now() - session.player.turnStart; session.player.turnNotice = ""; game.turn = { name: next.name, color: next.color }; 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 }); saveGame(game); } const placeRobber = (game, session, robber) => { const name = session.name; robber = parseInt(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 (let color in game.players) { if (game.players[color].status === 'Not active') { continue; } if (game.players[color].mustDiscard > 0) { return `You cannot place the robber until everyone has discarded!`; } } if (game.robber === robber) { return `You must move the robber to a new location!`; } game.robber = robber; game.turn.placedRobber = true; pickRobber(game); addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); let targets = []; layout.tiles[robber].corners.forEach(cornerIndex => { 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 }; } 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 }); } const stealResource = (game, session, color) => { if (game.turn.actions.indexOf('steal-resource') === -1) { return `You can only steal a resource when it is valid to do so!`; } if (game.turn.limits.players.findIndex(item => item.color === color) === -1) { return `You can only steal a resource from a player on this terrain!`; } let victim; for (let key in game.sessions) { if (game.sessions[key].color === color) { victim = game.sessions[key]; break; } } if (!victim) { return `You sent a wierd color for the target to steal from.`; } const cards = []; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(field => { for (let i = 0; i < victim.player[field]; i++) { cards.push(field); } }); debugChat(game, 'Before steal'); if (cards.length === 0) { addChatMessage(game, session, `${victim.name} ` + `did not have any cards for ${session.name} to steal.`); game.turn.actions = []; game.turn.limits = {}; } else { let index = Math.floor(Math.random() * cards.length), type = cards[index]; victim.player[type]--; victim.player.resources--; session.player[type]++; session.player.resources++; game.turn.actions = []; game.turn.limits = {}; trackTheft(game, victim.color, session.color, type, 1); addChatMessage(game, session, `${session.name} randomly stole 1 ${type} from ` + `${victim.name}.`); sendUpdateToPlayer(game, victim, { private: victim.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) }); } const buyDevelopment = (game, session) => { 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 (game.developmentCards.length < 1) { return `There are no more development cards!`; } if (player.stone < 1 || player.wheat < 1 || player.sheep < 1) { return `You have insufficient resources to purchase a development card.`; } if (game.turn.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.wheat--; player.sheep--; player.resources = 0; player.developmentCards++; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { player.resources += player[resource]; }); debugChat(game, 'After development purchase'); const card = game.developmentCards.pop(); card.turn = game.turns; player.development.push(card); if (isRuleEnabled(game, 'most-developed')) { if (player.development.length >= 5 && (!game.mostDeveloped || player.developmentCards > game.players[game.mostDeveloped].developmentCards)) { if (game.mostDeveloped !== session.color) { game.mostDeveloped = session.color; game.mostDevelopmentCards = 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.mostDeveloped, players: getFilteredPlayers(game) }); } const playCard = (game, session, card) => { const name = session.name, 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 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 => item.type == card.type && item.card == card.card && !item.card.played); if (!card) { return `The card you want to play was not found in your hand!`; } if (player.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; player.development.forEach(item => { 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, 2); if (!allowed) { addChatMessage(game, session, `${session.name} played a Road Building card, but has no roads to build.`); break; } let roads = getValidRoads(game, session.color); 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'; game.turn.free = true; game.turn.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'; 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'; 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.played = true; player.playedCard = game.turns; if (card.type === 'army') { player.army++; addChatMessage(game, session, `${session.name} played a Knight and must move the robber!`); if (player.army > 2 && (!game.largestArmy || game.players[game.largestArmy].army < player.army)) { if (game.largestArmy !== session.color) { game.largestArmy = session.color; game.largestArmySize = player.army; addChatMessage(game, session, `${session.name} now has the largest army (${player.army})!`) } } game.turn.robberInAction = true; delete game.turn.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: [] }; for (let i = 0; i < 19; i++) { if (i === game.robber) { continue; } game.turn.limits.pips.push(i); } } sendUpdateToPlayer(game, session, { private: session.player }); sendUpdateToPlayers(game, { chat: game.chat, activities: game.activities, largestArmy: game.largestArmy, largestArmySize: game.largestArmySize, turn: game.turn, players: getFilteredPlayers(game) }); } const placeSettlement = (game, session, index) => { const player = session.player; 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 (game.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 = game.placements.corners[index]; if (corner.color) { return `This location already has a settlement belonging to ${game.players[corner.color].name}!`; } if (!player.banks) { player.banks = []; } if (game.state === 'normal') { if (!game.turn.free) { 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.`; } player.settlements--; 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.wood--; player.wheat--; player.sheep--; player.resources = 0; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { player.resources += player[resource]; }); } delete game.turn.free; corner.color = session.color; corner.type = 'settlement'; let bankType = undefined; if (layout.corners[index].banks.length) { layout.corners[index].banks.forEach(bank => { const border = game.borderOrder[Math.floor(bank / 3)], type = game.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, game.mostPorts); if (player.ports >= 3 && (!game.mostPorts || player.ports > game.mostPortCount)) { if (game.mostPorts !== session.color) { game.mostPorts = session.color; game.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 (game.direction && game.direction === 'backward') { session.initialSettlement = index; } corner.color = session.color; corner.type = 'settlement'; let bankType = undefined; if (layout.corners[index].banks.length) { layout.corners[index].banks.forEach(bank => { const border = game.borderOrder[Math.floor(bank / 3)], type = game.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--; 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, activities: game.activities, mostPorts: game.mostPorts, turn: game.turn, chat: game.chat, players: getFilteredPlayers(game) }); } const placeRoad = (game, session, index) => { const player = session.player; index = parseInt(index); if (game.state !== 'initial-placement' && game.state !== 'normal') { return `You cannot purchase a place a road 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 location */ if (game.placements.roads[index] === undefined) { return `You have requested to place a road illegally!`; } /* If this is not a valid road in the turn limits, discard it */ if (!game.turn || !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.`; } debugChat(game, 'Before road purchase'); player.roads--; if (!game.turn.free) { addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood to purchase a road.`) player.brick--; player.wood--; player.resources = 0; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { player.resources += player[resource]; }); } debugChat(game, 'After road purchase'); road.color = session.color; addActivity(game, session, `${session.name} placed a road.`); calculateRoadLengths(game, session); let resetLimits = true; if (game.turn.active === 'road-building') { game.turn.freeRoads--; if (game.turn.freeRoads === 0) { delete game.turn.free; delete game.turn.active; delete game.turn.freeRaods; } let roads = getValidRoads(game, session.color); if (roads.length === 0) { delete game.turn.active; delete game.turn.freeRaods; addActivity(game, session, `${session.name} has another road to play, but there are no more valid locations.`); } else if (game.turn.freeRoads !== 0) { game.turn.free = true; setForRoadPlacement(game, roads); resetLimits = false; } } if (resetLimits) { delete game.turn.free; game.turn.actions = []; game.turn.limits = {}; } } else if (game.state === 'initial-placement') { road.color = session.color; addActivity(game, session, `${session.name} placed a road.`); calculateRoadLengths(game, session); let next; if (game.direction === 'forward' && getLastPlayerName(game) === session.name) { game.direction = 'backward'; next = session.player; } else if (game.direction === 'backward' && getFirstPlayerName(game) === session.name) { /* Done! */ delete game.direction; } else { if (game.direction === 'forward') { next = getNextPlayerSession(game, session.name); } else { next = getPrevPlayerSession(game, session.name); } } if (next) { game.turn = { name: next.name, color: next.color }; startTurnTimer(game, next); setForSettlementPlacement(game, getValidCorners(game)); calculateRoadLengths(game, session); addChatMessage(game, null, `It is ${next.name}'s turn to place a settlement.`); } else { game.turn = { actions: [], limits: { }, name: session.name, color: getColorFromName(game, session.name) }; session.player.turnStart = Date.now(); addChatMessage(game, null, `Everyone has placed their two settlements!`); /* Figure out which players received which resources */ for (let id in game.sessions) { const session = game.sessions[id], player = session.player, receives = {}; if (!player) { continue; } if (session.initialSettlement) { layout.tiles.forEach((tile, index) => { if (tile.corners.indexOf(session.initialSettlement) !== -1) { const resource = staticData.tiles[game.tileOrder[index]].type; if (!(resource in receives)) { receives[resource] = 0; } receives[resource]++; } }); let message = []; for (let type in receives) { player[type] += receives[type]; player.resources += receives[type]; sendUpdateToPlayer(game, session, { private: player }); message.push(`${receives[type]} ${type}`); } addChatMessage(game, session, `${session.name} receives ${message.join(', ')} for initial settlement placement.`); } } addChatMessage(game, null, `It is ${session.name}'s turn.`); game.state = 'normal'; } } 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) }); } const getVictoryPointRule = (game) => { const minVP = 10; if (!isRuleEnabled(game, 'victory-points') || !('points' in game.rules['victory-points'])) { return minVP; } return game.rules['victory-points'].points; } const supportedRules = { 'victory-points': (game, session, rule, rules) => { 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}`); } }, 'roll-double-roll-again': (game, session, rule, rules) => { addChatMessage(game, null, `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Roll Double, Roll Again house rule.`); }, 'volcano': (game, session, rule, rules) => { 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, session, rule, rules) => { 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, session, rule, rules) => { addChatMessage(game, null, `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Most Developed house rule.`); }, 'port-of-call': (game, session, rule, rules) => { addChatMessage(game, null, `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Another Round of Port house rule.`); }, 'slowest-turn': (game, session, rule, rules) => { addChatMessage(game, null, `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Slowest Turn house rule.`); }, 'tiles-start-facing-down': (game, session, rule, rules) => { 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, session, rule, rules) => { addChatMessage(game, null, `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Robin Hood Robber house rule.`); } }; const setRules = (game, session, rules) => { 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 warning = supportedRules[rule](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 }); }; const discard = (game, session, discards) => { 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) { if (player[type] < parseInt(discards[type])) { return `You have requested to discard more ${type} than you have.` } sum += parseInt(discards[type]); } 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 }); } const buyRoad = (game, session) => { 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 }); } const selectResources = (game, session, cards) => { const player = session.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) => { switch (type.trim()) { case 'wheat': case 'brick': case 'sheep': case 'stone': case 'wood': return true; default: return false; }; } const selected = {}; cards.forEach(card => { if (!isValidCard(card)) { return `Invalid resource type!`; } if (card in selected) { selected[card]++; } else { selected[card] = 1; } }); const display = []; for (let card in selected) { display.push(`${selected[card]} ${card}`); } switch (game.turn.active) { case 'monopoly': const gave = [], type = 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[type]) { gave.push(`${player.name} gave ${player[type]} ${type}`); session.player[type] += player[type]; session.resources += player[type]; total += player[type]; player[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) }); } const buySettlement = (game, session) => { 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 }); } const buyCity = (game, session) => { 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 }); } const placeCity = (game, session, index) => { const player = session.player; 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) { if (player.wheat < 2 || player.stone < 3) { return `You have insufficient resources to build a city.`; } } if (player.city < 1) { return `You have already built all of your cities.`; } corner.color = session.color; corner.type = 'city'; debugChat(game, 'Before city purchase'); 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 purchase'); 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) }); } const ping = (session) => { if (!session.ws) { console.log(`[no socket] Not sending ping to ${session.name} -- connection does not exist.`); return; } session.ping = Date.now(); // console.log(`Sending ping to ${session.name}`); session.ws.send(JSON.stringify({ type: 'ping', ping: session.ping })); if (session.keepAlive) { clearTimeout(session.keepAlive); } session.keepAlive = setTimeout(() => { ping(session); }, 2500); } const wsInactive = (game, req) => { const session = getSession(game, req.cookies.player); if (session && session.ws) { console.log(`Closing WebSocket to ${session.name} due to inactivity.`); try { // Defensive: close only if a socket exists; swallow any errors from closing if (session.ws) { try { session.ws.close(); } catch (e) { /* ignore close errors */ } } } catch (e) { /* ignore */ } session.ws = undefined; } /* Prevent future pings */ if (req.keepAlive) { clearTimeout(req.keepAlive); } } const setGameState = (game, session, state) => { 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; } } const resetDisconnectCheck = (game, req) => { if (req.disconnectCheck) { clearTimeout(req.disconnectCheck); } //req.disconnectCheck = setTimeout(() => { wsInactive(game, req) }, 20000); } const join = (peers, session, { hasVideo, hasAudio }) => { const ws = session.ws; if (!session.name) { console.error(`${session.id}: <- join - No name set yet. Audio not available.`); return; } console.log(`${session.id}: <- join - ${session.name}`); console.log(`${all}: -> addPeer - ${session.name}`); if (session.name in peers) { console.log(`${session.id}:${session.name} - Already joined to Audio.`); return; } for (let peer in peers) { /* Add this caller to all peers */ peers[peer].ws.send(JSON.stringify({ type: 'addPeer', data: { peer_id: session.name, should_create_offer: false, hasAudio, hasVideo } })); /* Add each other peer to the caller */ ws.send(JSON.stringify({ type: 'addPeer', data: { peer_id: peer, should_create_offer: true, hasAudio: peers[peer].hasAudio, hasVideo: peers[peer].hasVideo } })); } /* Add this user as a peer connected to this WebSocket */ peers[session.name] = { ws, hasAudio, hasVideo }; }; const part = (peers, session) => { const ws = session.ws; if (!session.name) { console.error(`${session.id}: <- part - No name set yet. Audio not available.`); return; } if (!(session.name in peers)) { console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`); return; } console.log(`${session.id}: <- ${session.name} - Audio part.`); console.log(`${all}: -> removePeer - ${session.name}`); delete peers[session.name]; /* Remove this peer from all other peers, and remove each * peer from this peer */ for (let peer in peers) { peers[peer].ws.send(JSON.stringify({ type: 'removePeer', data: {'peer_id': session.name} })); ws.send(JSON.stringify({ type: 'removePeer', data: {'peer_id': session.name} })); } }; const getName = (session) => { return session ? (session.name ? session.name : session.id) : 'Admin'; } const saveGame = async (game) => { /* 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]); if (reduced.player) { delete reduced.player; } if (reduced.ws) { delete reduced.ws; } if (reduced.keepAlive) { delete reduced.keepAlive; } // 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); }); */ await mkdir('/db/games', { recursive: true }); await writeFile(`/db/games/${game.id}`, JSON.stringify(reducedGame, null, 2)) .catch((error) => { console.error(`Unable to write to /db/games/${game.id}`); console.error(error); }); } const departLobby = (game, session, color) => { const update = {}; 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 all = `[ all ]`; const info = `[ info ]`; const todo = `[ todo ]`; /* Per-session send throttle (milliseconds). Coalesce rapid updates to avoid * tight send loops that can overwhelm clients. If multiple updates are * enqueued within the throttle window, the latest one replaces prior pending * updates so the client receives a single consolidated message. */ const SEND_THROTTLE_MS = 50; const queueSend = (session, message) => { if (!session || !session.ws) return; try { const now = Date.now(); if (!session._lastSent) session._lastSent = 0; const elapsed = now - session._lastSent; // If the exact same message was sent last time and nothing is pending, // skip sending to avoid pointless duplicate traffic. if (!session._pendingTimeout && session._lastMessage === message) { 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(message); session._lastSent = Date.now(); session._lastMessage = message; } 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 === message) { return; } session._pendingMessage = 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(); 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, session) => { console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`); if (!session.ws) { console.log(`${session.id}: -> sendGamePlayer:: Currently no connection`); return; } let update; /* 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) => { console.log(`${all}: -> sendGamePlayers - full game`); for (let key in game.sessions) { sendGameToPlayer(game, game.sessions[key]); } }; const sendUpdateToPlayers = async (game, update) => { /* 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, session, update) => { /* 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) => { if (!game.unselected) { return []; } return game.unselected .filter(session => session.live) .map(session => session.name); } const parseChatCommands = (game, message) => { /* Chat messages can set game flags and fields */ const parts = message.match(/^set +([^ ]*) +(.*)$/i); if (!parts || parts.length !== 3) { return; } switch (parts[1].toLowerCase()) { case 'game': if (parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) { setBeginnerGame(game); addChatMessage(game, session, `${session.name} set game board to the Beginner's Layout.`); break; } const signature = parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i); if (signature) { if (setGameFromSignature(game, signature[1], signature[2], signature[3])) { game.signature = parts[2]; addChatMessage(game, session, `${session.name} set game board to ${parts[2]}.`); } else { addChatMessage(game, session, `${session.name} requested an invalid game board.`); } } break; } }; const sendError = (session, error) => { session.ws.send(JSON.stringify({ type: 'error', error })); } const sendWarning = (session, warning) => { session.ws.send(JSON.stringify({ type: 'warning', warning })); } const getFilteredPlayers = (game) => { const filtered = {}; 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 => { player.resources += player[resource]; delete player[resource]; }); delete player.development; } return filtered; }; const calculatePoints = (game, update) => { 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 => { 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, session) => { resetGame(game); addChatMessage(game, null, `The game has been reset. You can play again with this board, or ` + `click 'New board' to mix things up a bit.`); sendGameToPlayers(game); }; const gotoLobby = (game, session) => { 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 board' 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 }); } router.ws("/ws/:id", async (ws, req) => { if (!req.cookies || !req.cookies.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; const short = `[${req.cookies.player.substring(0, 8)}]`; ws.id = short; console.log(`${short}: Game ${gameId} - New connection from client.`); if (!(id in audio)) { audio[id] = {}; /* List of peer sockets using session.name as index. */ console.log(`${short}: Game ${id} - New Game Audio`); } else { console.log(`${short}: Game ${id} - 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.player); 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.player); 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 (id in audio) { try { part(audio[id], 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[id]; delete games[id]; try { fs.unlinkSync(`/db/games/${id}`); } catch (error) { console.error(`${session.id}: Unable to remove /db/games/${id}`); } } } }); 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; const game = await loadGame(gameId); const session = getSession(game, req.cookies.player); // Keep track of any previously attached websocket so we can detect // first-time attaches and websocket replacements (reconnects). const previousWs = session.ws; const wasAttached = !!previousWs; // 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) { 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, warning, update, 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 join(audio[id], session, data.config || data.data || {}); break; case 'part': part(audio[id], session); break; case 'relayICECandidate': { if (!(id in audio)) { console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`); return; } // Support both { config: {...} } and { data: {...} } client payloads const cfg = data.config || data.data || {}; const { peer_id, candidate } = cfg; if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate); message = JSON.stringify({ type: 'iceCandidate', data: {'peer_id': getName(session), 'candidate': candidate } }); if (peer_id in audio[id]) { audio[id][peer_id].ws.send(message); } } break; case 'relaySessionDescription': { if (!(id in audio)) { console.error(`${id} - relaySessionDescription - Does not have Audio`); return; } // Support both { config: {...} } and { data: {...} } client payloads const cfg = data.config || data.data || {}; const { peer_id, session_description } = cfg; if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, session_description); message = JSON.stringify({ type: 'sessionDescription', data: {'peer_id': getName(session), 'session_description': session_description } }); if (peer_id in audio[id]) { audio[id][peer_id].ws.send(message); } } 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': { // Broadcast a peer state update (muted/video_on) to other peers in the game audio map if (!(id in audio)) { console.error(`${session.id}:${id} <- peer_state_update - Does not have Audio`); return; } const cfg = data.config || data.data || {}; const { peer_id, muted, video_on } = cfg; if (!session.name) { console.error(`${session.id}: peer_state_update - unnamed session`); return; } const messagePayload = JSON.stringify({ type: 'peer_state_update', data: { peer_id: session.name, muted, video_on }, }); // Send to all other peers for (const other in audio[id]) { if (other === session.name) continue; try { audio[id][other].ws.send(messagePayload); } catch (e) { console.warn(`Failed sending peer_state_update to ${other}:`, e); } } } 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': // Guard against clients that send a 'get' without fields. // Support both legacy shape: { type: 'get', fields: [...] } // and normalized shape: { type: 'get', data: { fields: [...] } } const requestedFields = Array.isArray(data.fields) ? data.fields : (data.data && Array.isArray(data.data.fields)) ? data.data.fields : []; console.log(`${short}: <- get:${getName(session)} ${requestedFields.length ? requestedFields.join(',') : ''}`); update = {}; requestedFields.forEach((field) => { switch (field) { case 'player': sendWarning(session, `'player' is not a valid item. use 'private' instead`); update.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': update[field] = game[field]; break; case 'rules': update[field] = game.rules ? game.rules : {}; break; case 'name': update.name = session.name; break; case 'unselected': update.unselected = getFilteredUnselected(game); break; case 'private': update.private = session.player; break; case 'players': update.players = getFilteredPlayers(game); break; case 'color': console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`); update.color = session.color; break; case 'timestamp': update.timestamp = Date.now(); break; default: if (field in game) { console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`); update[field] = game[field]; } else { if (field in session) { console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`); update[field] = session[field]; } else { console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`); } } break; } }); sendUpdateToPlayer(game, session, update); 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; const priorSession = session; 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) { warning(session, error); } 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 session = getSession(game, req.cookies.player); 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), 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, preamble) => { 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, session) => { /* 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]); if (reduced.player) { delete reduced.player; } if (reduced.ws) { delete reduced.ws; } if (reduced.keepAlive) { delete reduced.keepAlive; } 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 many incremental `game-update` messages. */ const sendInitialGameSnapshot = (game, session) => { 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, from, to, type, count) => { 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) => { 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, p = 0; i < game.tileOrder.length; i++) { game.animationSeeds.push(Math.random()); } } const createGame = (id) => { /* Look for a new game with random words that does not already exist */ while (!id) { id = randomWords(4).join('-'); try { /* If file can be read, it already exists so look for a new name */ accessSync(`/db/games/${id}`, fs.F_OK); 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: [], active: 0, rules: { 'victory-points': { points: 10 } }, step: 0 /* used for the suffix # in game backups */ }; [ "pips", "borders", "tiles" ].forEach((field) => { game[field] = staticData[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) => { 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, p = 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) => { 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)]; game.pipOrder = []; game.animationSeeds = []; for (let i = 0, p = 0; i < sequence.length; i++) { const target = sequence[i]; /* If the target tile is the desert (18), then set the * pip value to the robber (18) otherwise set * the target pip value to the currently incremeneting * pip value. */ if (game.tiles[game.tileOrder[target]].type === '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 = { httpOnly: false, sameSite: secure ? 'none' : 'lax', secure: !!secure }; res.cookie('player', playerId, 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({ player: playerId }); }); 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 = { httpOnly: false, sameSite: secure ? 'none' : 'lax', secure: !!secure }; res.cookie('player', playerId, 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(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 }); }); module.exports = router;