"use strict"; const express = require("express"), router = express.Router(), crypto = require("crypto"), { readFile, writeFile } = require("fs").promises, fs = require("fs"), accessSync = fs.accessSync, randomWords = require("random-words"); const layout = require('./layout.js'); const MAX_SETTLEMENTS = 5; const MAX_CITIES = 4; const MAX_ROADS = 15; const debug = { audio: false, get: true, set: true, update: false//true }; 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 staticData = { tiles: [ { type: "desert", card: 0 }, { type: "wood", card: 0 }, { type: "wood", card: 1 }, { type: "wood", card: 2 }, { type: "wood", card: 3 }, { type: "wheat", card: 0 }, { type: "wheat", card: 1 }, { type: "wheat", card: 2 }, { type: "wheat", card: 3 }, { type: "stone", card: 0 }, { type: "stone", card: 1 }, { type: "stone", card: 2 }, { type: "sheep", card: 0 }, { type: "sheep", card: 1 }, { type: "sheep", card: 2 }, { type: "sheep", card: 3 }, { type: "brick", card: 0 }, { type: "brick", card: 1 }, { type: "brick", card: 2 } ], pips: [ { roll: 5, pips: 4 }, { roll: 2, pips: 1 }, { roll: 6, pips: 5 }, { roll: 3, pips: 2 }, { roll: 8, pips: 5 }, { roll: 10, pips: 3 }, { roll: 9, pips: 4 }, { roll: 12, pips: 1 }, { roll: 11, pips: 2 }, { roll: 4, pips: 3 }, { roll: 8, pips: 5 }, { roll: 10, pips: 3 }, { roll: 9, pips: 4 }, { roll: 4, pips: 3 }, { roll: 5, pips: 4 }, { roll: 6, pips: 6 }, { roll: 3, pips: 2 }, { roll: 11, pips: 2 }, { roll: 7, pips: 0 }, /* Robber is at the end or indexing gets off */ ], borders: [ [ "bank", undefined, "sheep" ], [ undefined, "bank", undefined ], [ "bank", undefined, "brick" ], [ undefined, "wood", undefined ], [ "bank", undefined, "wheat" ], [ undefined, "stone", undefined ] ] }; const games = {}; const audio = {}; const processTies = (players) => { players.sort((A, B) => { if (A.order === B.order) { return B.orderRoll - A.orderRoll; } return B.order - A.order; }); /* 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] = []; } if (!(player.orderRoll in slots[player.order])) { slots[player.order][player.orderRoll] = []; } slots[player.order][player.orderRoll].push(player); }); let ties = false, order = 0; /* Reverse from high to low */ slots.reverse().forEach((slot) => { slot.forEach(dice => { if (dice.length !== 1) { ties = true; dice.forEach(player => { player.orderRoll = 0; player.order = order; player.orderStatus = `Tied.`; player.tied = true; }); } else { dice[0].order = order; dice[0].tied = false; dice[0].orderStatus = `Placed in ${order+1}.`; } order += dice.length }) }); return !ties; } const playerFromName = (game, name) => { for (let color in game.players) { if (game.players[color].name === name) { return game.players[color]; } } return undefined; }; 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; const players = []; let doneRolling = true; for (let key in game.players) { const tmp = game.players[key]; if (tmp.status === 'Not active') { continue; } if (!tmp.orderRoll) { doneRolling = false; } players.push(tmp); } /* If 'doneRolling' is FALSE then there are still players to roll */ if (!doneRolling || !processTies(players)) { sendUpdateToPlayers(game, { players: game.players, chat: game.chat }) return; } addChatMessage(game, null, `Player order set to ${players .map((player, index) => { return `${index+1}. ${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.`);4 sendUpdateToPlayers(game, { players: game.players, state: game.state, direction: game.direction, turn: game.turn, chat: game.chat, activities: game.activities }); } const roll = (game, session) => { const player = session.player, name = session.name ? session.name : "Unnamed"; switch (game.state) { case "lobby": addChatMessage(game, session, `${name} rolled ${Math.ceil(Math.random() * 6)}.`); sendUpdateToPlayers(game, { chat: game.chat }); return; case "game-order": if (!player) { return `This player is not active!`; } if (player.order && player.orderRoll) { return `Player ${name} has already rolled for player order.`; } const dice = Math.ceil(Math.random() * 6); addChatMessage(game, session, `${name} rolled ${dice}.`); return processGameOrder(game, player, dice); 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, [ Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6) ]); sendUpdateToPlayers(game, { chat: game.chat }); 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 }); } } } console.log(`Matched tiles: ${tiles.join(',')}.`); 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]; console.log(tile.index, resource); layout.tiles[tile.index].corners.forEach(cornerIndex => { const active = game.placements.corners[cornerIndex]; if (active && active.color) { if (!tile.robber) { receives[active.color][resource.type] += active.type === 'settlement' ? 1 : 2; } else { receives.robber[resource.type] += active.type === 'settlement' ? 1 : 2; } } }) }); 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 { robberSteal(game, color, type); robber.push(`${entry[type]} ${type}`); } } if (session) { addChatMessage(game, session, `${session.name} receives ${message.join(', ')}.`); } } 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) => { addChatMessage(game, session, `${session.name} rolled ${dice[0]}, ${dice[1]}.`); game.turn.roll = dice[0] + dice[1]; if (game.turn.roll !== 7) { distributeResources(game, game.turn.roll); sendUpdateToPlayers(game, { turn: game.turn, players: game.players, chat: game.chat }); 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!`); sendUpdateToPlayer(game, { private: game.player }); }); } sendUpdateToPlayers(game, { turn: game.turn, players: game.players, chat: game.chat }); } 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 }; } const getSession = (game, reqSession) => { if (!game.sessions) { game.sessions = {}; } if (!reqSession.player_id) { throw Error(`No session id for ${game.id}`); } const id = reqSession.player_id; /* 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: undefined, color: undefined, player: undefined, lastActive: Date.now(), live: true }; } const session = game.sessions[id]; /* 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!'); } } } session.lastActive = Date.now(); session.live = true; if (session.player) { session.player.live = true; session.player.lastActive = session.lastActive; } return game.sessions[id]; }; const loadGame = async (id) => { if (/^\.|\//.exec(id)) { return undefined; } if (id in games) { return games[id]; } let game = await readFile(`games/${id}`) .catch(() => { return; }); if (game) { try { game = JSON.parse(game); console.log(`${info}: Creating backup of games/${id}`); await writeFile(`games/${id}.bk`, JSON.stringify(game)); } catch (error) { console.log(`Attempting to load backup from games/${id}.bk`); game = await readFile(`games/${id}.bk`) .catch(() => { console.error(error, game); }); if (game) { try { game = JSON.parse(game); console.log(`Restoring backup to games/${id}`); await writeFile(`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 = undefined; session.player = undefined; } session.live = false; /* 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; 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(', ')}.` } return undefined; } const adminActions = (game, action, value) => { let color, player, parts, session, corners, error; switch (action) { 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]; 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); if (corners.length === 0) { return `There are no valid locations for ${game.turn.name} to place a settlement.`; } corners = getValidCorners(game, session.color, '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": parts = value.match(/^([1-6])(-([1-6]))?$/); if (!parts) { return `Unable to parse roll request.`; } let dice = [ parseInt(parts[1]) ]; if (parts[3]) { dice.push(parseInt(parts[3])); } 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 for admin roll.`; } console.log(dice, parts); addChatMessage(game, null, `Admin rolling ${dice.join(', ')} for ${game.turn.name}.`); switch (game.state) { case 'game-order': message = `${game.turn.name} rolled ${dice[0]}.`; addActivity(game, session, message); message = undefined; processGameOrder(game, session.player, dice[0]); break; case 'normal': processRoll(game, session, dice); break; } break; case "pass": let name = game.turn.name; const next = getNextPlayer(game, name); game.turn = { name: next, color: getColorFromName(game, next) }; game.turns++; addChatMessage(game, null, `The admin skipped ${name}'s turn.`); addChatMessage(game, null, `It is ${next}'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 = undefined; return; } return `Unable to find active session for ${colorToWord(color)} (${value})`; 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 }); 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, hasAudio = false; 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]) { hasAudio = true; part(audio[game.id], session, game.id); } } else { message = `${session.name} has changed their name to ${name}.`; if (session.ws && game.id in audio) { part(audio[game.id], session, game.id); } } } 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 && hasAudio) { join(audio[game.id], session, game.id); } 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(session, { name: session.name, color: session.color, live: session.live, player: session.player }); sendUpdateToPlayers(game, { players: game.players, unselected: getFilteredUnselected(game), chat: game.chat }); } const colorToWord = (color) => { switch (color) { case 'O': return 'orange'; case 'W': return 'white'; case 'B': return 'blue'; case 'R': return 'red'; default: return undefined; } } 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.sessions[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 = undefined; 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(session, { name: session.name, color: undefined, live: session.live, player: session.player }); sendUpdateToPlayers(game, { active: game.active, unselected: getFilteredUnselected(game), players: game.players, 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: game.players, 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(session, { name: session.name, color: session.color, live: session.live, player: 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 : undefined, message, date }); } const addChatMessage = (game, session, message) => { 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 (session && session.name) { entry.from = session.name; } if (session && session.color) { entry.color = session.color; } game.chat.push(entry); }; 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 getNextPlayer = (game, name) => { let color; for (let id in game.sessions) { if (game.sessions[id].name === name) { color = game.sessions[id].color; break; } } if (!color) { return name; } 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].name; } } return name; } const getPrevPlayer = (game, name) => { let color; for (let id in game.sessions) { if (game.sessions[id].name === name) { color = game.sessions[id].color; break; } } if (!color) { return name; } 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].name; } } return name; } 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 }); } } }); 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; } }); }); console.log('Graphs B:', graphs); 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); console.log('Post update:', game.placements.roads.filter(road => road.color)); let checkForTies = false; 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 = [ key ]; 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, `${player.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 = null; game.longestRoadLength = 0; } }; const getValidCorners = (game, color, type) => { const limits = []; /* For each corner, if the corner already has a color set, skip it if type * isn't set. If type is set, if it is a match, and the color is a match, * add it to the list. * * If we are limiting based on active player, a corner is only valid * if it connects to a road that is owned by that player. * If no color is set, walk each road that leaves that corner and * check to see if there is a settlement placed at the end of that road * If so, this location cannot have a settlement. */ layout.corners.forEach((corner, cornerIndex) => { const placement = game.placements.corners[cornerIndex]; if (type) { if (placement.color === color && placement.type === type) { limits.push(cornerIndex); } return; } if (placement.color) { return; } let valid; if (!color) { valid = true; /* Not filtering based on current player */ } else { valid = false; for (let r = 0; !valid && r < corner.roads.length; r++) { valid = game.placements.roads[corner.roads[r]].color === color; } } for (let r = 0; valid && r < corner.roads.length; r++) { const road = layout.roads[corner.roads[r]]; for (let c = 0; valid && c < road.corners.length; c++) { /* This side of the road is pointing to the corner being validated. Skip it. */ if (road.corners[c] === cornerIndex) { continue; } /* There is a settlement within one segment from this * corner, so it is invalid for settlement placement */ if (game.placements.corners[road.corners[c]].color) { valid = false; } } } if (valid) { limits.push(cornerIndex); } }); return limits; } const getValidRoads = (game, color) => { const limits = []; /* For each road, if the road is set, skip it. * If no color is set, check the two corners. If the corner * has a matching color, add this to the set. Otherwise skip. */ layout.roads.forEach((road, roadIndex) => { if (game.placements.roads[roadIndex].color) { return; } let valid = false; for (let c = 0; !valid && c < road.corners.length; c++) { const corner = layout.corners[road.corners[c]], cornerColor = game.placements.corners[road.corners[c]].color; /* Roads do not pass through other player's settlements */ if (cornerColor && cornerColor !== color) { continue; } for (let r = 0; !valid && r < corner.roads.length; r++) { /* This side of the corner is pointing to the road being validated. Skip it. */ if (corner.roads[r] === roadIndex) { continue; } if (game.placements.roads[corner.roads[r]].color === color) { valid = true; } } } if (valid) { limits.push(roadIndex); } }); return limits; } 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 || item.type === '*') && item.count === get.count) !== undefined; }); if (valid) player.gives.forEach(give => { if (!valid) { return; } valid = offer.gets.find(item => (item.type === give.type || item.type === 'bank') && 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 (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 (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) { return false; } } else if (player[get.type] < get.count) { 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 = adminActions(game, action, value); } if (!error) { sendGameToPlayers(game); } } return res.status(400).send(error); }); const trade = (game, session, { offer, value }) => { const name = session.name; if (game.state !== "normal") { return `Game not in correct state to begin trading.`; } if (!game.turn.actions || game.turn.actions.indexOf('trade') === -1) { /* Only the active player can begin trading */ if (game.turn.name !== name) { return `You cannot start trading negotiations when it is not your turn.` } 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, `${name} requested to begin trading negotiations.`); return; } /* Only the active player can cancel trading */ if (value === 'cancel') { /* TODO: Perhaps 'cancel' is how a player can remove an offer... */ if (game.turn.name !== name) { return `Only the active player can cancel trading negotiations.`; } game.turn.actions = []; game.turn.limits = {}; addActivity(game, session, `${name} has cancelled trading negotiations.`); return; } /* Any player can make an offer */ if (value === 'offer') { error = checkPlayerOffer(game, session.player, offer); if (error) { return error; } 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 }) && other.offerRejected) { console.log('clear rejection', other, offer); delete other.offerRejected[session.color]; } else { console.log('do not clear rejection', other, offer); } } addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`); return; } /* Any player can reject an offer */ if (value === 'reject') { /* 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; addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`); return; } /* Only the active player can accept an offer */ if (value === 'accept') { if (game.turn.name !== name) { return `Only the active player can accept an offer.`; } const offer = req.body; let target; console.log({ offer, description: offerToString(offer) }); error = checkPlayerOffer(game, session.player, offer); if (error) { return error; } /* 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!`; } error = checkPlayerOffer(game, target, { gives: offer.gets, gets: offer.gives }); if (error) { return error; } 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; debugChat(game, 'After trade'); game.turn.actions = []; } } const shuffle = (game, session) => { const name = session.name; 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); const message = `${name} requested a new board. New board signature: ${game.signature}.`; addChatMessage(game, null, message); console.log(message); } 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.`; } const next = getNextPlayer(game, name); game.turn = { name: next, color: getColorFromName(game, next) }; game.turns++; addActivity(game, session, `${name} passed their turn.`); addChatMessage(game, null, `It is ${next}'s turn.`); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activites: game.activities }); } 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(session, { private: game.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!`; } const victim = game.players[color]; const cards = []; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(field => { for (let i = 0; i < victim[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[type]--; victim.resources--; session.player[type]++; session.player.resources++; game.turn.actions = []; game.turn.limits = {}; addChatMessage(game, session, `${session.name} randomly stole 1 ${type} from ` + `${victim.name}.`); } debugChat(game, 'After steal'); game.turn.robberInAction = false; sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities }); sendUpdateToPlayer(session, { private: game.player }); } const buyDevelopment = (game, session, value) => { if (game.state !== 'normal') { return `You cannot purchase a development card unless the game is active.`; } 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; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { player.resources += player[resource]; }); debugChat(game, 'After development purchase'); card = game.developmentCards.pop(); card.turn = game.turns; player.development.push(card); sendUpdateToPlayers(game, { chat: game.chat, activities: game.activities, players: getFilteredPlayers(game) }); sendUpdateToPlayer(session, { private: game.player }); } const playCard = (game, session, { card }) => { const name = session.name; if (game.state !== 'normal') { return `You cannot play a development card unless the game is active.`; } 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); 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!`; } if (card.played) { return `You have already played this card.`; } /* 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 < 10) { return `You can not play victory point cards until you can reach 10!`; } 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' ]; game.turn.limits = { pips: [] }; for (let i = 0; i < 19; i++) { if (i === game.robber) { continue; } game.turn.limits.pips.push(i); } } sendUpdateToPlayers(game, { chat: game.chat, activities: game.activities, largestArmy: game.largestArmy, largestArmySize: game.largestArmySize, turn: game.turn }); sendUpdateToPlayer(session, { private: game.player }); } const placeSettlement = (game, session, index) => { const player = session.player; index = parseInt(index); if (game.state !== 'initial-placement' && game.state !== 'normal') { return `You cannot place an item unless the game is active.`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } 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); } }); } 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.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); } sendUpdateToPlayers(game, { placements: game.placements, turn: game.turn, chat: game.chat }); sendUpdateToPlayer(session, { private: game.player }); } const placeRoad = (game, session, index) => { const player = session.player; index = parseInt(index); if (game.state !== 'initial-placement' && game.state !== 'normal') { return `You cannot place an item unless the game is active.`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } if (game.placements.roads[index] === undefined) { return `You have requested to place a road illegally!`; } /* If 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.name; } else if (game.direction === 'backward' && getFirstPlayerName(game) === session.name) { /* Done! */ delete game.direction; } else { if (game.direction === 'forward') { next = getNextPlayer(game, session.name); } else { next = getPrevPlayer(game, session.name); } } if (next) { game.turn = { name: next, color: getColorFromName(game, next) }; setForSettlementPlacement(game, getValidCorners(game)); calculateRoadLengths(game, session); addChatMessage(game, null, `It is ${next}'s turn to place a settlement.`); } else { game.turn = { actions: [], limits: { }, name: session.name, color: getColorFromName(game, session.name) }; 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]; message.push(`${receives[type]} ${type}`); } addChatMessage(game, session, `${session.name} receives ${message.join(', ')}.`); } } addChatMessage(game, null, `It is ${session.name}'s turn.`); game.state = 'normal'; } } sendUpdateToPlayers(game, { placements: game.placements, turn: game.turn, chat: game.chat, state: game.state, longestRoad: game.longestRoad, longestRoadLength: game.longestRoadLength }); sendUpdateToPlayer(session, { private: game.player }); } const asdf = () => { const game = 0, session = 0; switch (game) { case 'select-resources': if (!game || !game.turn || !game.turn.actions || game.turn.actions.indexOf('select-resources') === -1) { return `Please, let's not cheat. Ok?`; console.log(game); break; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; break; } const count = (game.turn.active === 'monopoly') ? 1 : 2; cards = req.body; if (!cards || cards.length > count || cards.length === 0) { return `You have chosen the wrong number of cards!`; break; } 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)) { error = `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}`); } if (error) { break; } addActivity(game, session, `${session.name} has chosen ${display.join(', ')}!`); 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; } } if (gave.length) { addChatMessage(game, session, `${session.name} player Monopoly and selected ${display.join(', ')}. ` + `Players ${gave.join(', ')}. In total, they received ${total} ${type}.`); } else { addActivity(game, session, 'No players had that resource. Wa-waaaa.'); } 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.`); break; } delete game.turn.active; game.turn.actions = []; break; case 'buy-settlement': if (game.state !== 'normal') { error = `You cannot purchase a settlement unless the game is active.`; break; } if (session.color !== game.turn.color) { error = `It is not your turn! It is ${game.turn.name}'s turn.`; break; } if (!game.turn.roll) { error = `You cannot build until you have rolled.`; break; } if (game.turn && game.turn.robberInAction) { error = `Robber is in action. You can not purchase until all Robber tasks are resolved.`; break; } if (player.brick < 1 || player.wood < 1 || player.wheat < 1 || player.sheep < 1) { error = `You have insufficient resources to build a settlement.`; break; } if (player.settlements < 1) { error = `You have already built all of your settlements.`; break; } corners = getValidCorners(game, session.color); if (corners.length === 0) { error = `There are no valid locations for you to place a settlement.`; break; } setForSettlementPlacement(game, corners); addActivity(game, session, `${game.turn.name} is considering placing a settlement.`); break; case 'buy-city': if (game.state !== 'normal') { error = `You cannot purchase a city unless the game is active.`; break; } if (session.color !== game.turn.color) { error = `It is not your turn! It is ${game.turn.name}'s turn.`; break; } if (!game.turn.roll) { error = `You cannot build until you have rolled.`; break; } if (player.wheat < 2 || player.stone < 3) { error = `You have insufficient resources to build a city.`; break; } if (game.turn && game.turn.robberInAction) { error = `Robber is in action. You can not purchase until all Robber tasks are resolved.`; break; } if (player.city < 1) { error = `You have already built all of your cities.`; break; } corners = getValidCorners(game, session.color, 'settlement'); if (corners.length === 0) { error = `There are no valid locations for you to place a city.`; break; } setForCityPlacement(game, corners); addActivity(game, session, `${game.turn.name} is considering upgrading a settlement to a city.`); break; case 'place-city': if (game.state !== 'normal') { error = `You cannot place an item unless the game is active.`; break; } if (session.color !== game.turn.color) { error = `It is not your turn! It is ${game.turn.name}'s turn.`; break; } index = parseInt(value); if (game.placements.corners[index] === undefined) { error = `You have requested to place a city illegally!`; break; } /* 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) { error = `You tried to cheat! You should not try to break the rules.`; break; } corner = game.placements.corners[index]; if (corner.color !== session.color) { error = `This location already has a settlement belonging to ${game.players[corner.color].name}!`; break; } if (corner.type !== 'settlement') { error = `This location already has a city!`; break; } if (!game.turn.free) { if (player.wheat < 2 || player.stone < 3) { error = `You have insufficient resources to build a city.`; break; } } if (player.city < 1) { error = `You have already built all of your cities.`; break; } 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, `${name} upgraded a settlement to a city!`); break; case 'buy-road': if (game.state !== 'normal') { error = `You cannot purchase a road unless the game is active.`; break; } if (session.color !== game.turn.color) { error = `It is not your turn! It is ${game.turn.name}'s turn.`; break; } if (!game.turn.roll) { error = `You cannot build until you have rolled.`; break; } if (game.turn && game.turn.robberInAction) { error = `Robber is in action. You can not purchase until all Robber tasks are resolved.`; break; } if (player.brick < 1 || player.wood < 1) { error = `You have insufficient resources to build a road.`; break; } if (player.roads < 1) { error = `You have already built all of your roads.`; break; } let roads = getValidRoads(game, session.color); if (roads.length === 0) { error = `There are no valid locations for you to place a road.`; break; } setForRoadPlacement(game, roads); addActivity(game, session, `${game.turn.name} is considering building a road.`); break; break; case 'discard': if (game.turn.roll !== 7) { error = `You can only discard due to the Robber!`; break; } const discards = req.body; let sum = 0; for (let type in discards) { if (player[type] < parseInt(discards[type])) { error = `You have requested to discard more ${type} than you have.` break; } sum += parseInt(discards[type]); } if (sum > player.mustDiscard) { error = `You have requested to discard more cards than you are allowed!`; break; } 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) { addChatMessage(game, null, `${session.name} did not discard enough and must discard ${player.mustDiscard} more cards.`); break; } let move = true; for (let color in game.players) { const discard = game.players[color].mustDiscard; 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); } } break; } }; 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.session); if (session && session.ws) { console.log(`Closing WebSocket to ${session.name} due to inactivity.`); session.ws.close(); 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.`; } 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, id) => { 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) { peers[peer].send(JSON.stringify({ type: 'addPeer', data: { 'peer_id': session.name, 'should_create_offer': false } })); ws.send(JSON.stringify({ type: 'addPeer', data: {'peer_id': peer, 'should_create_offer': true} })); } /* Add this user as a peer connected to this WebSocket */ peers[session.name] = ws; }; const part = (peers, session, id) => { 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].send(JSON.stringify({ type: 'removePeer', data: {'peer_id': peer} })); ws.send(JSON.stringify({ type: 'removePeer', data: {'peer_id': peer} })); } }; const getName = (session) => { return session.name ? session.name : session.id; } 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; } reducedGame.sessions[id] = reduced; /* Do not send session-id as those are secrets */ reducedSessions.push(reduced); } delete reducedGame.unselected; /* Save per turn while debugging... */ await writeFile(`games/${game.id}.${game.turns}`, JSON.stringify(reducedGame, null, 2)) .catch((error) => { console.error(`${session.id} Unable to write to games/${game.id}`); console.error(error); }); await writeFile(`games/${game.id}`, JSON.stringify(reducedGame, null, 2)) .catch((error) => { console.error(`${session.id} Unable to write to 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; } sendUpdateToPlayers(game, update); } const all = `[ all ]`; const info = `[ info ]`; const todo = `[ todo ]`; const sendGameToPlayer = (game, session) => { console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`); session.ws.send(JSON.stringify({ type: 'game-update', update: getFilteredGameForPlayer(game, session) })); }; const sendGameToPlayers = (game) => { console.log(`${all}: -> sendGamePlayers - full game`); for (let key in game.sessions) { const _session = game.sessions[key]; if (!_session.ws) { continue; } _session.ws.send(JSON.stringify({ type: 'game-update', update: getFilteredGameForPlayer(game, _session) })); } }; const sendUpdateToPlayers = async (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]; if (!_session.ws) { continue; } _session.ws.send(message); } await saveGame(game); } const sendUpdateToPlayer = async (session, 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 }); session.ws.send(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 })); } router.ws("/ws/:id", (ws, req) => { /* Connect the WebSocket to the app's sessionParser */ req.app.locals.sessionParser(req, {}, () => { if (!req.session.player_id) { req.session.player_id = crypto.randomBytes(16).toString('hex'); console.log(`[${req.session.player_id.substring(0, 8)}]: wss - New session connected`); } else { console.log(`[${req.session.player_id.substring(0, 8)}]: wss - Existing session being used`); } wsConnect(ws, req); }); }); 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') { continue; } player.resources = 0; [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { player.resources += player[resource]; delete player[resource]; }); delete player.development; } return filtered; }; const wsConnect = async (ws, req) => { const { id } = req.params; const gameId = id; if (!req.session.player_id) { throw new Error(`player_id not set from http load`); } const short = `[${req.session.player_id.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.message); const game = await loadGame(gameId); if (!game) { return; } const session = getSession(game, req.session); session.live = false; if (session.ws) { session.ws.close(); session.ws = undefined; } departLobby(game, session); }); ws.on('close', async (event) => { const game = await loadGame(gameId); if (!game) { return; } const session = getSession(game, req.session); if (session.player) { session.player.live = false; } session.live = false; if (session.ws) { /* Cleanup any voice channels */ if (id in audio) { part(audio[id], session, id); } session.ws.close(); session.ws = undefined; console.log(`${short}:WebSocket closed for ${getName(session)}`); } departLobby(game, session); console.log(`${short} - closed connection`); }); ws.on('message', async (message) => { let data; try { data = JSON.parse(message); } catch (error) { console.error(`${session.id}: parse error`, message); return; } const game = await loadGame(gameId); const session = getSession(game, req.session); if (!session.ws) { session.ws = ws; } if (session.player) { session.player.live = true; } session.live = true; session.lastActive = Date.now(); let error, warning, update, processed = true; switch (data.type) { case 'join': join(audio[id], session, id); break; case 'part': part(audio[id], session, id); break; case 'relayICECandidate': { if (!(id in audio)) { console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`); return; } const { peer_id, ice_candidate } = data.config; if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, ice_candidate); message = JSON.stringify({ type: 'iceCandidate', data: {'peer_id': getName(session), 'ice_candidate': ice_candidate } }); if (peer_id in audio[id]) { audio[id][peer_id].send(message); } } break; case 'relaySessionDescription': { if (!(id in audio)) { console.error(`${id} - relaySessionDescription - Does not have Audio`); return; } const { peer_id, session_description } = data.config; 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].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 'player-name': console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${data.name}`) error = setPlayerName(game, session, data.name); if (error) { sendError(session, error); break; } 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 { await saveGame(game); } break; case 'color': warning = setPlayerColor(game, session, data.value); if (warning) { sendWarning(session, warning); } else { await saveGame(game); } break; default: console.warn(`WARNING: Requested SET unsupported field: ${data.field}`); break; } break; case 'get': console.log(`${short}: <- get:${getName(session)} ${data.fields.join(',')}`); update = {}; data.fields.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': update[field] = game[field]; 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(session, update); break; case 'chat': console.log(`${short}:${id} - ${data.type} - ${data.message}`) addChatMessage(game, session, `${session.name}: ${data.message}`); parseChatCommands(game, data.message); sendUpdateToPlayers(game, { chat: game.chat }); break; case 'roll': console.log(`${short}: <- roll:${getName(session)}`); warning = roll(game, session); if (warning) { sendWarning(session, warning); } break; default: processed = false; break; } if (processed) { 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; } switch (data.type) { case 'shuffle': console.log(`${short}: <- shuffle:${getName(session)}`); warning = shuffle(game, session); if (error) { warning(session, error); } else { warning(game); } 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-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 'pass': console.log(`${short}: <- pass:${getName(session)}`); warning = pass(game, session); if (warning) { sendWarning(session, warning); } break; default: console.warn(`Unsupported request: ${data.type}`); break; } }); /* 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.session); session.ws = ws; if (session.player) { session.player.live = true; } session.live = true; session.lastActive = Date.now(); if (session.name) { sendUpdateToPlayers(game, { players: game.players, unselected: getFilteredUnselected(game) }); } 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)}`); if (session.keepAlive) { 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 sendGameToSession = (session, reducedSessions, game, reducedGame, error, res) => { const player = session.player ? session.player : undefined; /* Strip out data that should not be shared with players */ delete reducedGame.developmentCards; const playerGame = Object.assign({}, reducedGame, { timestamp: Date.now(), status: error ? error : "success", name: session.name, color: session.color, order: (session.color in game.players) ? game.players[session.color].order : 0, player: player, sessions: reducedSessions, layout: layout }); if (!res) { if (!error) { if (!session.ws) { console.error(`No WebSocket connection to ${session.name}`); } else { console.log(`Sending update to ${session.id}:${session.name ? session.name : 'Unnamed'}`); sendUpdateToPlayer(session, playerGame); } } } else { console.log(`Returning update to ${session.name ? session.name : 'Unnamed'}`); res.status(200).send(playerGame); } } const sendGame = async (req, res, game, error, wsUpdate) => { /* Update the session lastActive clock */ let session; if (req.session) { session = getSession(game, req.session); session.lastActive = Date.now(); if (session.player) { session.player.lastActive = session.lastActive; } } else { session = { name: "command line" }; } /* 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; } player.points = 0; if (key === game.longestRoad) { player.points += 2; } if (key === game.largestArmy) { 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 (!game.winner && (player.points >= 10 && session.color === key)) { addChatMessage(game, null, `${player.name} won the game with ${player.points} victory points!`); game.winner = key; game.state = 'winner'; delete game.turn.roll; } } /* 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; } } /* 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); } if (!wsUpdate) { /* Save per turn while debugging... */ await writeFile(`games/${game.id}.${game.turns}`, JSON.stringify(reducedGame, null, 2)) .catch((error) => { console.error(`Unable to write to games/${game.id}`); console.error(error); }); await writeFile(`games/${game.id}`, JSON.stringify(reducedGame, null, 2)) .catch((error) => { console.error(`Unable to write to games/${game.id}`); console.error(error); }); } if (wsUpdate) { /* This is a one-shot request from a client to send the game-update over WebSocket */ sendGameToSession(session, reducedSessions, game, reducedGame); } else { for (let id in game.sessions) { const target = game.sessions[id], useWS = target !== session; if (useWS) { if (!error) { sendGameToSession(target, reducedSessions, game, reducedGame); } } else { sendGameToSession(target, reducedSessions, game, reducedGame, error, res); } } } } const getFilteredGameForPlayer = (game, session) => { /* 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; } player.points = 0; if (key === game.longestRoad) { player.points += 2; } if (key === game.largestArmy) { 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++; } }); console.log(`${todo}: Move game win state to card play section`); if (!game.winner && (player.points >= 10 && session.color === key)) { game.winner = key; game.state = 'winner'; delete game.turn.roll; } } /* 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; } } /* 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; reducedGame.unselected = getFilteredUnselected(game); return Object.assign(reducedGame, { live: true, timestamp: Date.now(), status: session.error ? session.error : "success", name: session.name, color: session.color, order: (session.color in game.players) ? game.players[session.color].order : 0, player: player, sessions: reducedSessions, layout: layout }); } const robberSteal = (game, color, type) => { if (!game.stolen) { game.stolen = {}; } if (!(color in game.stolen)) { game.stolen[color] = {}; } if (!(type in game.stolen)) { game.stolen[type] = 0; } if (!(type in game.stolen[color])) { game.stolen[color][type] = 0; } game.robberStole = game.robberStole ? game.robberStole++ : 1; game.stolen[type]++; game.stolen[color][type]++; } const resetGame = (game) => { Object.assign(game, { startTime: Date.now(), state: 'lobby', turns: 0, 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: {}, longestRoad: undefined, longestRoadLength: 0, largestArmy: undefined, largestArmySize: 0, winner: undefined, longestRoad: undefined }); /* 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 */ for (let color in game.players) { clearPlayer(game.players[color]); } /* Ensure sessions are connected to player objects */ for (let key in game.sessions) { const session = game.sessions[key]; if (session.color) { session.player = game.players[session.color]; session.player.status = 'Active'; session.player.lastActive = Date.now(); session.player.live = session.live; } } } 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(`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: [] }; [ "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; }; router.post("/", (req, res/*, next*/) => { console.log("POST games/"); const game = createGame(); if (!req.session.player_id) { req.session.player_id = crypto.randomBytes(16).toString('hex'); console.log(`[${req.session.player_id.substring(0, 8)}]: https - New session connected`); } else { console.log(`[${req.session.player_id.substring(0, 8)}]: https - Existing session being used`); } const session = getSession(game, req.session); saveGame(game); return res.status(200).send(getFilteredGameForPlayer(game, session)); }); 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.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 = []; 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++; } } shuffleArray(game.developmentCards); game.signature = gameSignature(game); } /* return gameDB.sequelize.query("SELECT " + "photos.*,albums.path AS path,photohashes.hash,modified,(albums.path || photos.filename) AS filepath FROM photos " + "LEFT JOIN albums ON albums.id=photos.albumId " + "LEFT JOIN photohashes ON photohashes.photoId=photos.id " + "WHERE photos.id=:id", { replacements: { id: id }, type: gameDB.Sequelize.QueryTypes.SELECT, raw: true }).then(function(photos) { if (photos.length == 0) { return null; } */ if (0) { router.get("/*", (req, res/*, next*/) => { return gameDB.sequelize.query(query, { replacements: replacements, type: gameDB.Sequelize.QueryTypes.SELECT }).then((photos) => { }); }); } module.exports = router;