"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 session = require("express-session"); const layout = require('./layout.js'); const MAX_SETTLEMENTS = 5; const MAX_CITIES = 4; const MAX_ROADS = 15; let gameDB; require("../db/games").then(function(db) { gameDB = db; }); function shuffle(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 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(pips => { if (pips.length !== 1) { ties = true; pips.forEach(player => { player.orderRoll = 0; player.order = order; player.orderStatus = `Tied.`; }); } else { pips[0].order = order; pips[0].orderStatus = `Placed in ${order+1}.`; } order += pips.length }) }); return !ties; } const getPlayerName = (game, player) => { for (let id in game.sessions) { if (game.sessions[id].player === player) { return game.sessions[id].name; } } return ''; }; const getPlayerColor = (game, player) => { for (let color in game.players) { if (game.players[color] === player) { return color; } } return ''; } const playerNameFromColor = (game, color) => { for (let id in game.sessions) { if (game.sessions[id].color === color) { return game.sessions[id].name; } } return ''; }; const playerFromColor = (game, color) => { for (let id in game.sessions) { if (game.sessions[id].color === color) { return game.sessions[id].player; } } return undefined; }; const processGameOrder = (game, player, dice) => { let message; player.orderRoll = dice; let 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 TRUE then everyone has rolled */ if (doneRolling) { if (processTies(players)) { message = `Player order set to ${players.map((player, index) => { return `${index+1}. ${getPlayerName(game, player)}`; }).join(', ')}.`; addChatMessage(game, null, message); game.playerOrder = players.map(player => getPlayerColor(game, player)); game.state = 'initial-placement'; game.direction = 'forward'; game.turn = { name: getPlayerName(game, players[0]), color: getPlayerColor(game, players[0]) }; placeSettlement(game, getValidCorners(game)); addChatMessage(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); addChatMessage(game, null, `Initial settlement placement has started!`); message = `It is ${game.turn.name}'s turn to place a settlement.`; } else { message = `There are still ties for player order!`; } } if (message) { addChatMessage(game, null, message); } } const roll = (game, session) => { let message, error; const player = session.player, name = session.name ? session.name : "Unnamed"; switch (game.state) { case "lobby": error = `Rolling dice in the lobby is not allowed!`; case "game-order": if (!player) { error = `This player is not active!`; break; } if (player.order && player.orderRoll) { error = `Player ${name} has already rolled for player order.`; break; } game.dice = [ Math.ceil(Math.random() * 6) ]; message = `${name} rolled ${game.dice[0]}.`; addChatMessage(game, session, message); message = undefined; processGameOrder(game, player, game.dice[0]); break; case "normal": if (game.turn.color !== session.color) { error = `It is not your turn.`; break; } if (game.turn.roll) { error = `You already rolled this turn.`; break; } processRoll(game, [ Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6) ]); break; default: error = `Invalid game state (${game.state}) in roll.`; break; } if (!error && message) { addChatMessage(game, session, message); } return error; }; const sessionFromColor = (game, color) => { for (let key in game.sessions) { if (game.sessions[key].color === color) { return game.sessions[key]; } } } const distributeResources = (session, 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) { addChatMessage(game, null, `That pesky ${game.robberName} Roberson stole resources!`); } else { tiles.push(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 }, }; /* Find which corners are on each tile */ tiles.forEach(index => { let shuffle = game.tileOrder[index]; console.log(index, game.tiles[shuffle]); const resource = game.tiles[shuffle]; layout.tiles[index].corners.forEach(cornerIndex => { const active = game.placements.corners[cornerIndex]; if (active && active.color) { receives[active.color][resource.type] += active.type === 'settlement' ? 1 : 2; } }) }); for (let color in receives) { const entry = receives[color]; if (!entry.wood && !entry.brick && !entry.sheep && !entry.wheat && !entry.stone) { continue; } let message = []; for (let type in receives[color]) { const player = playerFromColor(game, color); player[type] += receives[color][type]; if (receives[color][type]) { message.push(`${receives[color][type]} ${type}`); } } addChatMessage(game, sessionFromColor(game, color), `${playerNameFromColor(game, color)} receives ${message.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, dice) => { let session; for (let id in game.sessions) { if (game.sessions[id].name === game.turn.name) { session = game.sessions[id]; } } if (!session) { console.error(`Cannot process roll without an active player session`); return; } game.dice = dice; addActivity(game, session, `${session.name} rolled ${game.dice[0]}, ${game.dice[1]}.`); game.turn.roll = game.dice[0] + game.dice[1]; if (game.turn.roll === 7) { 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 ${getPlayerName(game, player)} must discard ${player.mustDiscard} resource cards!`) ); } } else { distributeResources(session, game, game.turn.roll); } } const newPlayer = () => { return { roads: MAX_ROADS, cities: MAX_CITIES, settlements: MAX_SETTLEMENTS, points: 0, status: "Not active", lastActive: 0, order: 0, stone: 0, wheat: 0, sheep: 0, wood: 0, brick: 0, army: 0, development: [] }; } const getPlayer = (game, color) => { if (!game) { return newPlayer(); } return game.players[color]; }; const getSession = (game, session) => { if (!game.sessions) { game.sessions = {}; } if (!session.player_id) { session.player_id = crypto.randomBytes(32).toString('hex'); } const id = session.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] = { name: undefined, color: undefined, player: undefined }; } /* Expire old unused sessions */ for (let id in game.sessions) { const tmp = game.sessions[id]; if (tmp.color || tmp.name || tmp.player) { continue; } if (tmp.player_id === session.player_id) { continue; } /* 10 minutes */ if (tmp.lastActive && tmp.lastActive < Date.now() - 10 * 60 * 1000) { console.log(`Expiring old session ${id}`); delete game.sessions[id]; } } 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(`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); } /* Reconnect session player colors to the player objects */ for (let id in game.sessions) { const session = game.sessions[id]; if (session.color && session.color in game.players) { session.player = game.players[session.color]; } else { session.color = undefined; session.player = undefined; } } games[id] = game; return game; }; const clearPlayer = (player) => { for (let key in player) { delete player[key]; } Object.assign(player, newPlayer()); } 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; placeRoad(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; placeCity(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; placeSettlement(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; 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': game.dice = dice; message = `${game.turn.name} rolled ${game.dice[0]}.`; addActivity(game, session, message); message = undefined; processGameOrder(game, session.player, game.dice[0]); break; case 'normal': processRoll(game, 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) { session.player = undefined; clearPlayer(player); } 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.color) { return `You cannot change your name while you have a color selected.`; } /* Check to ensure name is not already in use */ if (game && name) for (let key in game.sessions) { const tmp = game.sessions[key]; if (tmp.name && tmp.name.toLowerCase() === name.toLowerCase()) { if (!tmp.player || (Date.now() - tmp.player.lastActive) > 60000) { Object.assign(session, tmp); delete game.sessions[key]; } else { return `${name} is already taken and has been active in the last minute.`; } } } if (name.toLowerCase() === 'the bank') { return `You cannot play as the bank!`; } const old = session.name; let message; session.name = name; if (name) { if (!old) { message = `A new player has entered the lobby as ${name}.`; } else { message = `${old} has changed their name to ${name}.`; } } else { return `You can not set your name to nothing!`; } addChatMessage(game, null, message); return undefined; } 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 setPlayerColor = (game, session, color) => { if (!game) { return `No game found`; } const name = session.name, player = session.player; /* Selecting the same color is a NO-OP */ if (session.color === color) { return; } const priorActive = getActiveCount(game); let message; if (player) { /* Deselect currently active player for this session */ clearPlayer(player); if (game.state !== 'lobby') { message = `${name} has exited to the lobby and is no longer playing as ${colorToWord(session.color)}.` addChatMessage(game, null, message); } else { message = `${name} is no longer ${colorToWord(session.color)}.`; } session.player = undefined; session.color = undefined; } /* Verify the player has a name set */ if (!name) { return `You may only select a player when you have set your name.`; } /* If the player is not selecting a color, then return */ if (!color) { if (message) { addChatMessage(game, null, message); } return; } /* Verify selection is valid */ if (!(color in game.players)) { return `An invalid player selection was attempted.`; } /* Verify selection is not already taken */ for (let key in game.sessions) { const tmp = game.sessions[key].player; if (tmp && tmp.color === color) { return `${game.sessions[key].name} already has ${colorToWord(color)}`; } } /* All good -- set this player to requested selection */ session.player = getPlayer(game, color); session.player.status = `Active`; session.player.lastActive = Date.now(); session.color = color; addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`); const afterActive = getActiveCount(game); if (afterActive !== priorActive) { if (priorActive < 2 && afterActive >= 2) { addChatMessage(game, null, `There are now enough players to start the game when you are ready.`); } } }; 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.color, message, date }); } const addChatMessage = (game, session, message) => { game.chat.push({ from: session ? session.name : undefined, color: session ? session.color : undefined, date: Date.now(), message: message }); }; 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) { addChatMessage(game, session, `${playerNameFromColor(game, currentLongest)} had their longest road split!`); checkForTies = true; } let longestRoad = 4, longestPlayers = []; for (let key in game.players) { if (game.players[key].status === 'Not active') { continue; } if (game.players[key].longestRoad > longestRoad) { longestPlayers = [ key ]; longestRoad = game.players[key].longestRoad; } else if (game.players[key].longestRoad === longestRoad) { if (longestRoad >= 5) { longestPlayers.push(key); } } } console.log({ longestPlayers }); if (longestPlayers.length > 0) { if (longestPlayers.length === 1) { game.longestRoadLength = longestRoad; if (game.longestRoad !== longestPlayers[0]) { game.longestRoad = longestPlayers[0]; addChatMessage(game, session, `${playerNameFromColor(game, game.longestRoad)} now has the longest ` + `road (${longestRoad})!`); } } else { if (checkForTies) { game.longestRoadLength = longestRoad; const names = longestPlayers.map(color => playerNameFromColor(game, color)); addChatMessage(game, session, `${names.join(', ')} are tied for longest ` + `road (${longestRoad})!`); } game.longestRoad = null; } } else { game.longestRoad = null; } }; 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 = (game, 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: getPlayerName(game, 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 make the offer */ const checkPlayerOffer = (game, player, offer) => { let error = undefined; console.log({ name: getPlayerName(game, player), gets: offer.gets, gives: offer.gives, sheep: player.sheep, wheat: player.wheat, brick: player.brick, stone: player.stone, wood: player.wood, }); 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 = `You do not have ${give.count} ${give.type}!`; return; } if (offer.gets.find(get => give.type === get.type)) { error = `You 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 = `You can not give and get the same resource type!`; }; }) return error; }; 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 placeRoad = (game, limits) => { game.turn.actions = [ 'place-road' ]; game.turn.limits = { roads: limits }; } const placeCity = (game, limits) => { game.turn.actions = [ 'place-city' ]; game.turn.limits = { corners: limits }; } const placeSettlement = (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; 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); } return sendGame(req, res, game, error); } const session = getSession(game, req.session), player = session.player; switch (action) { case 'player-name': error = setPlayerName(game, session, value); return sendGame(req, res, game, error); case 'player-selected': error = setPlayerColor(game, session, value); return sendGame(req, res, game, error); case 'chat': const chat = req.body; addChatMessage(game, session, chat.message); /* Chat messages can set game flags and fields */ const parts = chat.message.match(/^set +([^ ]*) +(.*)$/i); if (parts && parts.length === 3) { 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; } } return sendGame(req, res, game); } if (!session.player) { error = `Player must have an active color.`; return sendGame(req, res, game, error); } const name = session.name; let message, index; let corners, corner, card; switch (action) { case "trade": if (game.state !== "normal") { error = `Game not in correct state to begin trading.`; break; } if (!game.turn.actions || game.turn.actions.indexOf('trade') === -1) { /* Only the active player can begin trading */ if (game.turn.name !== name) { error = `You cannot start trading negotiations when it is not your turn.` break; } 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.`); break; } /* 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) { error = `Only the active player can cancel trading negotiations.`; break; } game.turn.actions = []; game.turn.limits = {}; addActivity(game, session, `${name} has cancelled trading negotiations.`); break; } /* Any player can make an offer */ if (value === 'offer') { const offer = req.body; error = checkPlayerOffer(game, session.player, offer); if (error) { break; } if (isSameOffer(session.player, offer)) { console.log(session.player); error = `You already have a pending offer submitted for ${offerToString(offer)}.`; break; } session.player.gives = offer.gives; session.player.gets = offer.gets; if (game.turn.name === name) { /* This is a new offer from the active player -- reset everyone's * 'offerRejected' flag */ for (let key in game.players) { delete game.players[key].offerRejected; } game.turn.offer = offer; } // addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`); break; } /* Any player can reject an offer */ if (value === 'reject') { session.player.offerRejected = true; addActivity(game, session, `${session.name} rejected ${game.turn.name}'s offer.`); break; } /* Only the active player can accept an offer */ if (value === 'accept') { if (game.turn.name !== name) { error = `Only the active player can accept an offer.`; break; } const offer = req.body; let target; error = checkPlayerOffer(game, session.player, offer); if (error) { break; } /* 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') { let mismatch = false; target = game.players[offer.color]; offer.gives.forEach(item => { const isOffered = target.gives.find( match => match.type === item.type && match.count === item.count); if (!isOffered) { mismatch = true; } }); offer.gets.forEach(item => { const isOffered = target.gets.find( match => match.type === item.type && match.count === item.count); if (!isOffered) { mismatch = true; } }); if (mismatch) { error = `Unfortunately, trades were re-negotiated in transit and the deal is invalid!`; break; } } else { target = offer; } /* Verify the requesting offer wasn't jacked --\ * make sure the target.gives === player.gets and target.gives === player.gets */ if (!isCompatibleOffer(game, player, target)) { error = `The requested offer does not match the negotiated terms!`; break; } debugChat(game, 'Before trade'); /* Transfer goods */ player.gets.forEach(item => { if (target.name !== 'The bank') { target[item.type] -= item.count; } player[item.type] += item.count; }); player.gives.forEach(item => { if (target.name !== 'The bank') { target[item.type] += item.count; } player[item.type] -= item.count; }); addChatMessage(game, session, `${session.name} has accepted a trade ` + `offer to give ${offerToString(session.player)} ` + `from ${(offer.name === 'The bank') ? 'the bank' : offer.name}.`); 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 = []; break; } break; case "roll": error = roll(game, session); break; case "shuffle": if (game.state !== "lobby") { error = `Game no longer in lobby (${game.state}). Can not shuffle board.`; } if (!error && game.turns > 0) { error = `Game already in progress (${game.turns} so far!) and cannot be shuffled.`; } if (!error) { shuffleBoard(game); const message = `${name} requested a new board. New board signature: ${game.signature}.`; addChatMessage(game, null, message); console.log(message); } break; case 'pass': if (game.turn.name !== name) { error = `You cannot pass when it isn't your turn.` break; } /* 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) { error = `Robber is in action. Turn can not stop until all Robber tasks are resolved.`; break; } 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.`); break; case 'place-robber': if (game.state !== 'normal' && game.turn.roll !== 7) { error = `You cannot place robber unless 7 was rolled!`; break; } if (game.turn.name !== name) { error = `You cannot place the robber when it isn't your turn.`; break; } for (let color in game.players) { if (game.players[color].status === 'Not active') { continue; } if (game.players[color].mustDiscard > 0) { error = `You cannot place the robber until everyone has discarded!`; break; } } const robber = parseInt(value ? value : 0); if (game.robber === robber) { error = `You must move the robber to a new location!`; break; } game.robber = robber; game.turn.placedRobber = true; pickRobber(game); addChatMessage(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); let colors = []; layout.tiles[robber].corners.forEach(cornerIndex => { const active = game.placements.corners[cornerIndex]; if (active && active.color && active.color !== game.turn.color && colors.indexOf(active.color) == -1) { colors.push(active.color); } }); if (colors.length) { game.turn.actions = [ 'steal-resource' ], game.turn.limits = { players: colors }; } 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.`); } break; case 'steal-resource': if (game.turn.actions.indexOf('steal-resource') === -1) { error = `You can only steal a resource when it is valid to do so!`; break; } if (game.turn.limits.players.indexOf(value) === -1) { error = `You can only steal a resource from a player on this terrain!`; break; } let victim = game.players[value]; 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) { addActivity(game, session, `${playerNameFromColor(game, value)} did not have any cards to steal.`); game.turn.actions = []; game.turn.limits = {}; } else { let index = Math.floor(Math.random() * cards.length), type = cards[index]; victim[type]--; session.player[type]++ game.turn.actions = []; game.turn.limits = {}; addChatMessage(game, session, `${session.name} randomly stole 1 ${type} from ${playerNameFromColor(game, value)}.`); } debugChat(game, 'After steal'); game.turn.robberInAction = false; break; case 'buy-development': if (game.state !== 'normal') { error = `You cannot purchase a development card 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.stone < 1 || player.wheat < 1 || player.sheep < 1) { error = `You have insufficient resources to purchase a development card.`; break; } if (game.developmentCards.length < 1) { error = `There are no more development cards!`; break; } if (game.turn.developmentPurchased) { error = `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, and 1 sheep to purchase a development card.`) player.stone--; player.wheat--; player.sheep--; debugChat(game, 'After development purchase'); card = game.developmentCards.pop(); card.turn = game.turns; player.development.push(card); break; case 'play-card': if (game.state !== 'normal') { error = `You cannot play a development card 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 play a card until you have rolled.`; break; } if (game.turn && game.turn.robberInAction) { error = `Robber is in action. You can not play a card until all Robber tasks are resolved.`; break; } card = req.body; card = player.development.find(item => item.type == card.type && item.card == card.card); if (!card) { error = `The card you want to play was not found in your hand!`; break; } if (player.playedCard === game.turns && card.type !== 'vp') { error = `You can only play one development card per turn!`; break; } if (card.played) { error = `You have already played this card.`; break; } /* 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) { error = `You can not play victory point cards until you can reach 10!`; break; } addActivity(game, session, `${session.name} played a Victory Point card.`); } if (card.type === 'progress') { switch (card.card) { case 'road-1': case 'road-2': addActivity(game, session, `${session.name} played a Road Building card. The server is giving them 2 brick and 2 wood to build those roads!`); player.brick += 2; player.wood += 2; break; case 'monopoly': game.turn.actions = [ 'select-resource' ]; 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-resource' ]; 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++; addActivity(game, session, `${session.name} played a Kaniget!`); if (player.army > 2 && (!game.largestArmy || game.players[game.largestArmy].army < player.army)) { if (game.largestArmy !== session.color) { game.largestArmy = session.color; 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); } } break; case 'select-resource': if (!game || !game.turn || !game.turn.actions || game.turn.actions.indexOf('select-resource') === -1) { error = `Please, let's not cheat. Ok?`; console.log(game); break; } if (session.color !== game.turn.color) { error = `It is not your turn! It is ${game.turn.name}'s turn.`; break; } const type = value.trim(); switch (type) { case 'wheat': case 'brick': case 'sheep': case 'stone': case 'wood': break; default: error = `That is not a valid resource type!`; break; }; if (error) { break; } addActivity(game, session, `${session.name} has chosen ${type}!`); switch (game.turn.active) { case 'monopoly': const gave = []; 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(`${playerNameFromColor(game, color)} gave ${player[type]} ${type}`); session.player[type] += player[type]; total += player[type]; player[type] = 0; } } if (gave.length) { addChatMessage(game, session, `Players ${gave.join(', ')}. In total, ${session.name} received ${total} ${type}.`); } else { addActivity(game, session, 'No players had that resource. Wa-waaaa.'); } break; case 'year-of-plenty': session.player[type] += 2; addChatMessage(game, session, `${session.name} received 2 ${type} 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; } placeSettlement(game, corners); addActivity(game, session, `${game.turn.name} is considering placing a settlement.`); break; case 'place-settlement': if (game.state !== 'initial-placement' && 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 settlement illegally!`; break; } /* 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) { error = `You tried to cheat! You should not try to break the rules.`; break; } corner = game.placements.corners[index]; if (corner.color) { error = `This location already has a settlement belonging to ${playerNameFromColor(game, corner.color)}!`; break; } 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) { error = `You have insufficient resources to build a settlement.`; break; } } if (player.settlements < 1) { error = `You have already built all of your settlements.`; break; } debugChat(game, 'Before settlement purchase'); player.settlements--; if (!game.turn.free) { addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood, 1 sheep, and 1 wheat to purchase a settlement.`) player.brick--; player.wood--; player.wheat--; player.sheep--; } delete game.turn.free; debugChat(game, 'After settlement purchase'); 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(`Bank ${bank} = ${type}`); if (!type) { console.log(`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, `${name} placed a settlement by a maritime bank that trades ${bankType}.`); } else { addActivity(game, 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 => { console.log(game.borderOrder); console.log(game.borders); const border = game.borderOrder[Math.floor(bank / 3)], type = game.borders[border][bank % 3]; console.log(`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, `${name} placed a settlement by a maritime bank that trades ${bankType}. ` + `Next, they need to place a road.`); } else { addActivity(game, session, `${name} placed a settlement. ` + `Next, they need to place a road.`); } placeRoad(game, layout.corners[index].roads); } 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; } placeCity(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 ${playerNameFromColor(game, corner.color)}!`; 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, and 1 stone to upgrade to a city.`) player.wheat -= 2; player.stone -= 3; } 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; } placeRoad(game, roads); addActivity(game, session, `${game.turn.name} is considering building a road.`); break; case 'place-road': if (game.state !== 'initial-placement' && 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.roads[index] === undefined) { error = `You have requested to place a road illegally!`; break; } /* 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) { error = `You tried to cheat! You should not try to break the rules.`; break; } const road = game.placements.roads[index]; if (road.color) { error = `This location already has a road belonging to ${playerNameFromColor(game, road.color)}!`; break; } if (game.state === 'normal') { if (!game.turn.free) { 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; } debugChat(game, 'Before road purchase'); player.roads--; if (!game.turn.free) { addChatMessage(game, session, `${session.name} spent 1 brick and 1 wood to purchase a road.`) player.brick--; player.wood--; } delete game.turn.free; debugChat(game, 'After road purchase'); road.color = session.color; game.turn.actions = []; game.turn.limits = {}; addActivity(game, session, `${name} placed a road.`); calculateRoadLengths(game, session); } else if (game.state === 'initial-placement') { road.color = session.color; addActivity(game, session, `${name} placed a road.`); calculateRoadLengths(game, session); let next; if (game.direction === 'forward' && getLastPlayerName(game) === name) { game.direction = 'backward'; next = name; } else if (game.direction === 'backward' && getFirstPlayerName(game) === name) { /* Done! */ delete game.direction; } else { if (game.direction === 'forward') { next = getNextPlayer(game, name); } else { next = getPrevPlayer(game, name); } } if (next) { game.turn = { name: next, color: getColorFromName(game, next) }; placeSettlement(game, getValidCorners(game)); calculateRoadLengths(game, session); addChatMessage(game, null, `It is ${next}'s turn to place a settlement.`); } else { game.turn = { actions: [], limits: { }, name: name, color: getColorFromName(game, 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]; message.push(`${receives[type]} ${type}`); } addActivity(game, session, `${session.name} receives ${message.join(', ')}.`); } } addChatMessage(game, null, `It is ${name}'s turn.`); game.state = 'normal'; } } 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) { player[type] -= parseInt(discards[type]); player.mustDiscard -= parseInt(discards[type]) } 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; case "state": const state = value; if (!state) { error = `Invalid state.`; break; } if (state === game.state) { break; } switch (state) { case "game-order": if (game.state !== 'lobby') { error = `You cannot start a game from other than the lobby.`; break; } addChatMessage(game, null, `${name} requested to start the game.`); game.state = state; break; } break; } return sendGame(req, res, game, error); }) const ping = (session) => { 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); } router.ws("/ws/:id", async (ws, req) => { const { id } = req.params; /* Setup WebSocket event handlers prior to performing any async calls or * we may miss the first messages from clients */ ws.on('error', (event) => { console.error(`WebSocket error: `, event.message); }); ws.on('open', (event) => { console.log(`WebSocket open: `, event.message); }); ws.on('message', async (message) => { /* Ensure the session is loaded prior to the first 'message' * being processed */ const game = await loadGame(id); if (!game) { console.error(`Unable to load/create new game for WS request.`); return; } const session = getSession(game, req.session); try { const data = JSON.parse(message); switch (data.type) { case 'pong': console.log(`Latency for ${session.name ? session.name : 'Unammed'} is ${Date.now() - data.timestamp}`); break; case 'game-update': console.log(`Player ${session.name ? session.name : 'Unnamed'} requested a game update.`); sendGame(req, undefined, game, undefined, ws); break; } } catch (error) { console.error(error); } }); /* 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(id); if (!game) { console.error(`Unable to load/create new game for WS request.`); return; } const session = getSession(game, req.session); console.log(`WebSocket connect from game ${id}:${session.name ? session.name : "Unnamed"}`); if (session) { console.log(`WebSocket connected for ${session.name ? session.name : "Unnamed"}`); session.ws = ws; if (session.keepAlive) { clearTimeout(session.keepAlive); } session.keepAlive = setTimeout(() => { ping(session); }, 2500); } else { console.log(`No session found for WebSocket with id ${id}`); } }); router.get("/:id", async (req, res/*, next*/) => { const { id } = req.params; // console.log("GET games/" + id); let game = await loadGame(id); if (game) { return sendGame(req, res, game) } game = createGame(id); return sendGame(req, res, game); }); const debugChat = (game, preamble) => { preamble = `Degug ${preamble.trim()}`; let playerInventory = preamble; for (let key in game.players) { if (game.players[key].status === 'Not active') { continue; } if (playerInventory !== '') { playerInventory += ' player'; } else { playerInventory += ' Player' } playerInventory += ` ${playerNameFromColor(game, key)} has `; const has = [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].map(resource => { const count = game.players[key][resource] ? game.players[key][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 getActiveCount = (game) => { let active = 0; for (let color in game.players) { const player = game.players[color]; active += ((player.status && player.status != 'Not active') ? 1 : 0); } return active; } const sendGameToSession = (session, reducedSessions, game, reducedGame, error, res) => { const player = session.player ? session.player : undefined; if (player) { player.haveResources = player.wheat > 0 || player.brick > 0 || player.sheep > 0 || player.stone > 0 || player.wood > 0; } /* 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.name}`); session.ws.send(JSON.stringify({ type: 'game-update', update: 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) => { const active = getActiveCount(game); /* Enforce game limit of >= 2 players */ if (active < 2 && game.state != 'lobby' && game.state != 'invalid') { let message = "Insufficient players in game. Setting back to lobby." addChatMessage(game, null, message); resetGame(game); } game.active = active; /* 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" }; } /* Ensure chat messages have a unique date: stamp as it is used as the index key */ let lastTime = 0; if (game.chat) game.chat.forEach((message) => { if (message.date <= lastTime) { message.date = lastTime + 1; } lastTime = message.date; }); /* 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, `${playerNameFromColor(game, key)} 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 resetGame = (game) => { console.log(`Reseting ${game.id}`); 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 }); delete game.longestRoad; delete game.largestArmy; delete game.longestRoadLength; delete game.winner; delete game.longestRoad; /* Reset all player data */ for (let color in game.players) { clearPlayer(game.players[color]); } /* 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, type: undefined }; } /* Populate the game development cards with a fresh deck */ for (let i = 1; i <= 14; i++) { game.developmentCards.push({ type: 'army', card: i }); } [ 'monopoly', 'road-1', 'road-2', '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 })); shuffle(game.developmentCards); /* 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(); } } /* Put the robber back on the Desert */ for (let i = 0; i < game.pipOrder.length; i++) { if (game.pipOrder[i] === 18) { console.log(`Setting robber at ${i}`); game.robber = i; break; } } } const createGame = (id) => { /* Look for a new game with random words that does not already exist */ while (!id) { id = randomWords(4).join('-'); console.log(`Looking for ${id}`); 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; } } const game = { id: id, developmentCards: [], players: { O: newPlayer(), R: newPlayer(), B: newPlayer(), W: newPlayer() } }; [ "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; return game; }; router.post("/:id?", (req, res/*, next*/) => { console.log("POST games/"); const { id } = req.params; if (id && id in games) { const error = `Can not create new game for ${id} -- it already exists.` console.error(error); return res.status(400).send(error); } const game = createGame(id); return sendGame(req, res, game); }); const setBeginnerGame = (game) => { pickRobber(game); shuffle(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); } shuffle(seq); game.borderOrder = seq.slice(); for (let i = 6; i < 19; i++) { seq.push(i); } shuffle(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++; } } shuffle(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;