5220 lines
154 KiB
JavaScript
Executable File
5220 lines
154 KiB
JavaScript
Executable File
"use strict";
|
|
|
|
const express = require("express"),
|
|
router = express.Router(),
|
|
crypto = require("crypto")
|
|
const { readFile, writeFile, mkdir } = require("fs").promises,
|
|
fs = require("fs"),
|
|
accessSync = fs.accessSync,
|
|
randomWords = require("random-words"),
|
|
equal = require("fast-deep-equal");
|
|
const { layout, staticData } = require('../util/layout.js');
|
|
const basePath = require('../basepath');
|
|
|
|
const { getValidRoads, getValidCorners, isRuleEnabled } = require('../util/validLocations.js');
|
|
|
|
|
|
const MAX_SETTLEMENTS = 5;
|
|
const MAX_CITIES = 4;
|
|
const MAX_ROADS = 15;
|
|
|
|
const types = [ 'wheat', 'stone', 'sheep', 'wood', 'brick' ];
|
|
|
|
const debug = {
|
|
audio: false,
|
|
get: true,
|
|
set: true,
|
|
update: false
|
|
};
|
|
|
|
// Normalize incoming websocket messages to a canonical { type, data }
|
|
// shape. Some clients historically sent the payload as { type, data } while
|
|
// others used a flatter shape. This helper accepts either a string or an
|
|
// already-parsed object and returns a stable object so handlers don't need
|
|
// to defensively check multiple nested locations.
|
|
function normalizeIncoming(msg) {
|
|
if (!msg) return { type: null, data: null };
|
|
let parsed = null;
|
|
try {
|
|
if (typeof msg === 'string') {
|
|
parsed = JSON.parse(msg);
|
|
} else {
|
|
parsed = msg;
|
|
}
|
|
} catch (e) {
|
|
// if parsing failed, return nulls so the caller can log/ignore
|
|
return { type: null, data: null };
|
|
}
|
|
if (!parsed) return { type: null, data: null };
|
|
const type = parsed.type || parsed.action || null;
|
|
// Prefer parsed.data when present, but allow flattened payloads where
|
|
// properties like `name` live at the root.
|
|
const data = parsed.data || (Object.keys(parsed).length ? Object.assign({}, parsed) : null);
|
|
return { type, data };
|
|
}
|
|
|
|
let gameDB;
|
|
|
|
require("../db/games").then(function(db) {
|
|
gameDB = db;
|
|
});
|
|
|
|
function shuffleArray(array) {
|
|
var currentIndex = array.length, temporaryValue, randomIndex;
|
|
|
|
// While there remain elements to shuffle...
|
|
while (0 !== currentIndex) {
|
|
|
|
// Pick a remaining element...
|
|
randomIndex = Math.floor(Math.random() * currentIndex);
|
|
currentIndex -= 1;
|
|
|
|
// And swap it with the current element.
|
|
temporaryValue = array[currentIndex];
|
|
array[currentIndex] = array[randomIndex];
|
|
array[randomIndex] = temporaryValue;
|
|
}
|
|
|
|
return array;
|
|
}
|
|
|
|
|
|
const games = {};
|
|
const audio = {};
|
|
|
|
const processTies = (players) => {
|
|
|
|
/* Sort the players into buckets based on their
|
|
* order, and their current roll. If a resulting
|
|
* roll array has more than one element, then there
|
|
* is a tie that must be resolved */
|
|
let slots = [];
|
|
players.forEach(player => {
|
|
if (!slots[player.order]) {
|
|
slots[player.order] = [];
|
|
}
|
|
slots[player.order].push(player);
|
|
});
|
|
|
|
let ties = false, position = 1;
|
|
|
|
const irstify = (position) => {
|
|
switch (position) {
|
|
case 1: return `1st`;
|
|
case 2: return `2nd`;
|
|
case 3: return `3rd`;
|
|
case 4: return `4th`;
|
|
default: return position;
|
|
}
|
|
}
|
|
|
|
/* Reverse from high to low */
|
|
slots.reverse().forEach((slot) => {
|
|
if (slot.length !== 1) {
|
|
ties = true;
|
|
slot.forEach(player => {
|
|
player.orderRoll = 0; /* Ties have to be re-rolled */
|
|
player.position = irstify(position);
|
|
player.orderStatus = `Tied for ${irstify(position)}`;
|
|
player.tied = true;
|
|
});
|
|
} else {
|
|
slot[0].tied = false;
|
|
slot[0].position = irstify(position);
|
|
slot[0].orderStatus = `Placed in ${irstify(position)}.`;
|
|
}
|
|
position += slot.length
|
|
});
|
|
|
|
return ties;
|
|
}
|
|
|
|
const processGameOrder = (game, player, dice) => {
|
|
if (player.orderRoll) {
|
|
return `You have already rolled for game order and are not in a tie.`;
|
|
}
|
|
|
|
player.orderRoll = dice;
|
|
player.order = player.order * 6 + dice;
|
|
|
|
const players = [];
|
|
|
|
let doneRolling = true;
|
|
for (let key in game.players) {
|
|
if (!game.players[key].orderRoll) {
|
|
doneRolling = false;
|
|
}
|
|
players.push(game.players[key]);
|
|
}
|
|
|
|
/* If 'doneRolling' is FALSE then there are still players to roll */
|
|
if (!doneRolling) {
|
|
sendUpdateToPlayers(game, {
|
|
players: getFilteredPlayers(game),
|
|
chat: game.chat
|
|
})
|
|
return;
|
|
}
|
|
|
|
/* sort updated player.order into the array */
|
|
players.sort((A, B) => {
|
|
return B.order - A.order;
|
|
});
|
|
|
|
console.log(`Pre process ties: `, players);
|
|
|
|
if (processTies(players)) {
|
|
console.log(`${info}: There are ties in player rolls:`, players);
|
|
sendUpdateToPlayers(game, {
|
|
players: getFilteredPlayers(game),
|
|
chat: game.chat
|
|
});
|
|
return;
|
|
}
|
|
|
|
addChatMessage(game, null, `Player order set to ` +
|
|
players.map((player) => `${player.position}: ${player.name}`)
|
|
.join(', ') + `.`);
|
|
|
|
game.playerOrder = players.map(player => player.color);
|
|
game.state = 'initial-placement';
|
|
game.direction = 'forward';
|
|
game.turn = {
|
|
name: players[0].name,
|
|
color: players[0].color
|
|
};
|
|
setForSettlementPlacement(game, getValidCorners(game));
|
|
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
|
|
addChatMessage(game, null, `Initial settlement placement has started!`);
|
|
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
|
|
|
|
sendUpdateToPlayers(game, {
|
|
players: getFilteredPlayers(game),
|
|
state: game.state,
|
|
direction: game.direction,
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities
|
|
});
|
|
}
|
|
|
|
const processVolcano = (game, session, dice) => {
|
|
const player = session.player,
|
|
name = session.name ? session.name : "Unnamed";
|
|
|
|
const volcano = layout.tiles.findIndex((tile, index) =>
|
|
staticData.tiles[game.tileOrder[index]].type === 'desert');
|
|
|
|
/* Find the volcano tile */
|
|
console.log(`${info}: Processing volcano roll!`, { dice });
|
|
addChatMessage(game, session, `${name} rolled ${dice[0]} for the Volcano!`);
|
|
|
|
game.dice = dice;
|
|
game.state = 'normal';
|
|
|
|
game.turn.volcano = layout.tiles[volcano].corners[dice[0] % 6];
|
|
const corner = game.placements.corners[game.turn.volcano];
|
|
if (corner.color) {
|
|
const player = game.players[corner.color];
|
|
if (corner.type === 'city') {
|
|
if (player.settlements) {
|
|
addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`);
|
|
player.cities++;
|
|
player.settlements--;
|
|
corner.type = 'settlement';
|
|
} else {
|
|
addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`);
|
|
delete corner.type;
|
|
delete corner.color;
|
|
player.cities++;
|
|
}
|
|
} else {
|
|
addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
|
|
delete corner.type;
|
|
delete corner.color;
|
|
player.settlements++;
|
|
}
|
|
}
|
|
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
state: game.state,
|
|
chat: game.chat,
|
|
dice: game.dice,
|
|
placements: game.placements,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const roll = (game, session, dice) => {
|
|
const player = session.player,
|
|
name = session.name ? session.name : "Unnamed";
|
|
|
|
if (!dice) {
|
|
dice = [ Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6) ];
|
|
}
|
|
switch (game.state) {
|
|
case "lobby": /* currently not available as roll is only after color is
|
|
* set for players */
|
|
addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
|
|
sendUpdateToPlayers(game, { chat: game.chat });
|
|
return;
|
|
|
|
case "game-order":
|
|
game.startTime = Date.now();
|
|
addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
|
|
return processGameOrder(game, player, dice[0]);
|
|
|
|
case "normal":
|
|
if (game.turn.color !== session.color) {
|
|
return `It is not your turn.`;
|
|
}
|
|
if (game.turn.roll) {
|
|
return `You already rolled this turn.`;
|
|
}
|
|
processRoll(game, session, dice);
|
|
return;
|
|
|
|
case 'volcano':
|
|
if (game.turn.color !== session.color) {
|
|
return `It is not your turn.`;
|
|
}
|
|
if (game.turn.select) {
|
|
return `You can not roll for the Volcano until all players have mined their resources.`;
|
|
}
|
|
/* Only use the first die for the Volcano roll */
|
|
processVolcano(game, session, [ dice[0] ]);
|
|
return;
|
|
|
|
default:
|
|
return `Invalid game state (${game.state}) in roll.`;
|
|
}
|
|
}
|
|
|
|
const sessionFromColor = (game, color) => {
|
|
for (let key in game.sessions) {
|
|
if (game.sessions[key].color === color) {
|
|
return game.sessions[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
const distributeResources = (game, roll) => {
|
|
console.log(`Roll: ${roll}`);
|
|
/* Find which tiles have this roll */
|
|
let tiles = [];
|
|
for (let i = 0; i < game.pipOrder.length; i++) {
|
|
let index = game.pipOrder[i];
|
|
if (staticData.pips[index].roll === roll) {
|
|
if (game.robber === i) {
|
|
tiles.push({ robber: true, index: i });
|
|
} else {
|
|
tiles.push({ robber: false, index: i });
|
|
}
|
|
}
|
|
}
|
|
|
|
const receives = {
|
|
"O": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
|
|
"R": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
|
|
"W": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
|
|
"B": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
|
|
"robber": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }
|
|
};
|
|
|
|
/* Find which corners are on each tile */
|
|
tiles.forEach(tile => {
|
|
let shuffle = game.tileOrder[tile.index];
|
|
const resource = game.tiles[shuffle];
|
|
layout.tiles[tile.index].corners.forEach(cornerIndex => {
|
|
const active = game.placements.corners[cornerIndex];
|
|
if (active && active.color) {
|
|
const count = active.type === 'settlement' ? 1 : 2;
|
|
if (!tile.robber) {
|
|
receives[active.color][resource.type] += count;
|
|
} else {
|
|
if (isRuleEnabled(game, `robin-hood-robber`)
|
|
&& game.players[active.color].points <= 2) {
|
|
addChatMessage(game, null, `Robber does not steal ${count}
|
|
${resource.type} from ${game.players[active.color].name} ` +
|
|
`due to Robin Hood Robber house rule.`);
|
|
console.log(`robin-hood-robber`, game.players[active.color],
|
|
active.color);
|
|
receives[active.color][resource.type] += count;
|
|
} else {
|
|
trackTheft(game, active.color, 'robber', resource.type, count);
|
|
receives.robber[resource.type] += count;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
});
|
|
|
|
const robber = [];
|
|
for (let color in receives) {
|
|
const entry = receives[color];
|
|
if (!entry.wood && !entry.brick && !entry.sheep && !entry.wheat && !entry.stone) {
|
|
continue;
|
|
}
|
|
let message = [], session;
|
|
for (let type in entry) {
|
|
if (entry[type] === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (color !== 'robber') {
|
|
session = sessionFromColor(game, color);
|
|
session.player[type] += entry[type];
|
|
session.player.resources += entry[type];
|
|
message.push(`${entry[type]} ${type}`);
|
|
} else {
|
|
|
|
robber.push(`${entry[type]} ${type}`);
|
|
}
|
|
}
|
|
|
|
if (session) {
|
|
addChatMessage(game, session, `${session.name} receives ${message.join(', ')} for pip ${roll}.`);
|
|
}
|
|
}
|
|
|
|
if (robber.length) {
|
|
addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robber.join(', ')}!`);
|
|
}
|
|
}
|
|
|
|
const pickRobber = (game) => {
|
|
const selection = Math.floor(Math.random() * 3);
|
|
switch (selection) {
|
|
case 0:
|
|
game.robberName = 'Robert';
|
|
break;
|
|
case 1:
|
|
game.robberName = 'Roberta';
|
|
break;
|
|
case 2:
|
|
game.robberName = 'Velocirobber';
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
const processRoll = (game, session, dice) => {
|
|
if (!dice[1]) {
|
|
console.error(`Invalid roll sequence!`);
|
|
return;
|
|
}
|
|
|
|
addChatMessage(game, session, `${session.name} rolled ` +
|
|
`${dice[0]}, ${dice[1]}.`);
|
|
|
|
const sum = dice[0] + dice[1];
|
|
|
|
game.dice = dice;
|
|
game.turn.roll = sum;
|
|
|
|
if (game.turn.roll !== 7) {
|
|
let synonym = isRuleEnabled(game, 'twelve-and-two-are-synonyms')
|
|
&& (sum === 2 || sum === 12);
|
|
|
|
distributeResources(game, game.turn.roll);
|
|
|
|
if (isRuleEnabled(game, 'twelve-and-two-are-synonyms')) {
|
|
if (dice[0] + dice[1] === 12) {
|
|
addChatMessage(game, session, `House rule 'Twelve and Two are
|
|
Synonyms' activated. Twelve was rolled, so two is triggered too!`);
|
|
distributeResources(game, 2);
|
|
}
|
|
if (dice[0] + dice[1] === 2) {
|
|
addChatMessage(game, session, `House rule 'Twelve and Two are
|
|
Synonyms' activated. Two was rolled, so twelve is triggered too!`);
|
|
distributeResources(game, 12);
|
|
}
|
|
}
|
|
|
|
if (isRuleEnabled(game, 'roll-double-roll-again')) {
|
|
if (dice[0] === dice[1]) {
|
|
addChatMessage(game, session, `House rule 'Roll Double, Roll
|
|
Again' activated.`);
|
|
game.turn.roll = 0;
|
|
}
|
|
}
|
|
|
|
if (isRuleEnabled(game, 'volcano')) {
|
|
if (sum === parseInt(game.rules['volcano'].number)
|
|
|| (synonym
|
|
&& (game.rules['volcano'].number === 2
|
|
|| game.rules['volcano'].number === 12))) {
|
|
addChatMessage(game, session, `House rule 'Volcano' activated. The
|
|
Volcano is erupting!`);
|
|
|
|
game.state = 'volcano';
|
|
|
|
let count = 0;
|
|
|
|
if (game.rules['volcano'].gold) {
|
|
game.turn.select = {};
|
|
const volcano = layout.tiles.find((tile, index) =>
|
|
staticData.tiles[game.tileOrder[index]].type === 'desert');
|
|
volcano.corners.forEach(index => {
|
|
const corner = game.placements.corners[index];
|
|
if (corner.color) {
|
|
if (!(corner.color in game.turn.select)) {
|
|
game.turn.select[corner.color] = 0;
|
|
}
|
|
game.turn.select[corner.color] +=
|
|
corner.type === 'settlement' ? 1 : 2;
|
|
count += corner.type === 'settlement' ? 1 : 2;
|
|
}
|
|
});
|
|
console.log(`Volcano! - `, {
|
|
mode: 'gold',
|
|
selected: game.turn.select
|
|
});
|
|
if (count) {
|
|
/* To gain volcano resources, you need at least 3 settlements,
|
|
* so Robin Hood Robber does not apply */
|
|
if (volcano === layout.tiles[game.robber]) {
|
|
addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson blocked ${count} volcanic mineral resources!`);
|
|
addChatMessage(game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!`);
|
|
delete game.turn.select;
|
|
} else {
|
|
addChatMessage(game, null, `House rule 'Volcanoes have minerals' activated. Players must select which resources to receive from the Volcano!`);
|
|
game.turn.actions = ['select-resources'];
|
|
game.turn.active = 'volcano';
|
|
}
|
|
} else {
|
|
addChatMessage(game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!`);
|
|
delete game.turn.select;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].player) {
|
|
sendUpdateToPlayer(game, game.sessions[id], {
|
|
private: game.sessions[id].player
|
|
});
|
|
}
|
|
}
|
|
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
players: getFilteredPlayers(game),
|
|
chat: game.chat,
|
|
dice: game.dice,
|
|
state: game.state
|
|
});
|
|
return;
|
|
}
|
|
|
|
/* ROBBER Robber Robinson! */
|
|
|
|
game.turn.robberInAction = true;
|
|
delete game.turn.placedRobber;
|
|
const mustDiscard = [];
|
|
|
|
for (let id in game.sessions) {
|
|
const player = game.sessions[id].player;
|
|
if (player) {
|
|
let discard = player.stone + player.wheat + player.brick + player.wood + player.sheep;
|
|
if (discard > 7) {
|
|
discard = Math.floor(discard / 2);
|
|
player.mustDiscard = discard;
|
|
mustDiscard.push(player);
|
|
} else {
|
|
delete player.mustDiscard;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mustDiscard.length === 0) {
|
|
addChatMessage(game, null, `ROBBER! ${game.robberName} Robber Roberson has fled, and no one had to discard!`);
|
|
addChatMessage(game, null, `A new robber has arrived and must be placed by ${game.turn.name}.`);
|
|
game.turn.actions = [ 'place-robber' ];
|
|
game.turn.limits = { pips: [] };
|
|
for (let i = 0; i < 19; i++) {
|
|
if (i === game.robber) {
|
|
continue;
|
|
}
|
|
game.turn.limits.pips.push(i);
|
|
}
|
|
} else {
|
|
mustDiscard.forEach(player => {
|
|
addChatMessage(game, null, `The robber was rolled and ${player.name} must discard ${player.mustDiscard} resource cards!`);
|
|
for (let key in game.sessions) {
|
|
if (game.sessions[key].player === player) {
|
|
sendUpdateToPlayer(game, game.sessions[key], {
|
|
private: player
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
players: getFilteredPlayers(game),
|
|
chat: game.chat,
|
|
dice: game.dice
|
|
});
|
|
}
|
|
|
|
const newPlayer = (color) => {
|
|
return {
|
|
roads: MAX_ROADS,
|
|
cities: MAX_CITIES,
|
|
settlements: MAX_SETTLEMENTS,
|
|
points: 0,
|
|
status: "Not active",
|
|
lastActive: 0,
|
|
resources: 0,
|
|
order: 0,
|
|
stone: 0,
|
|
wheat: 0,
|
|
sheep: 0,
|
|
wood: 0,
|
|
brick: 0,
|
|
army: 0,
|
|
development: [],
|
|
color: color,
|
|
name: "",
|
|
totalTime: 0,
|
|
turnStart: 0,
|
|
ports: 0,
|
|
developmentCards: 0
|
|
};
|
|
}
|
|
|
|
const getSession = (game, id) => {
|
|
if (!game.sessions) {
|
|
game.sessions = {};
|
|
}
|
|
|
|
/* If this session is not yet in the game, add it and set the player's name */
|
|
if (!(id in game.sessions)) {
|
|
game.sessions[id] = {
|
|
id: `[${id.substring(0, 8)}]`,
|
|
name: '',
|
|
color: '',
|
|
player: undefined,
|
|
lastActive: Date.now(),
|
|
live: true
|
|
};
|
|
}
|
|
|
|
const session = game.sessions[id];
|
|
session.lastActive = Date.now();
|
|
session.live = true;
|
|
if (session.player) {
|
|
session.player.live = true;
|
|
session.player.lastActive = session.lastActive;
|
|
}
|
|
|
|
/* Expire old unused sessions */
|
|
for (let _id in game.sessions) {
|
|
const _session = game.sessions[_id];
|
|
if (_session.color || _session.name || _session.player) {
|
|
continue;
|
|
}
|
|
if (_id === id) {
|
|
continue;
|
|
}
|
|
/* 60 minutes */
|
|
const age = Date.now() - _session.lastActive;
|
|
if (age > 60 * 60 * 1000) {
|
|
console.log(`${_session.id}: Expiring old session ${_id}: ${age/(60 * 1000)} minutes`);
|
|
delete game.sessions[_id];
|
|
if (_id in game.sessions) {
|
|
console.log('delete DID NOT WORK!');
|
|
}
|
|
}
|
|
}
|
|
|
|
return game.sessions[id];
|
|
};
|
|
|
|
const loadGame = async (id) => {
|
|
if (/^\.|\//.exec(id)) {
|
|
return undefined;
|
|
}
|
|
|
|
if (id in games) {
|
|
// If we have a cached game in memory, ensure any ephemeral flags that
|
|
// control per-session lifecycle (like _initialSnapshotSent) are cleared
|
|
// so that a newly attached websocket will receive the consolidated
|
|
// initial snapshot. This is important for long-running dev servers
|
|
// where the in-memory cache may persist between reconnects.
|
|
const cached = games[id];
|
|
for (let sid in cached.sessions) {
|
|
if (cached.sessions[sid] && cached.sessions[sid]._initialSnapshotSent) {
|
|
delete cached.sessions[sid]._initialSnapshotSent;
|
|
}
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
let game = await readFile(`/db/games/${id}`)
|
|
.catch(() => {
|
|
return;
|
|
});
|
|
|
|
if (game) {
|
|
try {
|
|
game = JSON.parse(game);
|
|
console.log(`${info}: Creating backup of /db/games/${id}`);
|
|
await writeFile(`/db/games/${id}.bk`, JSON.stringify(game));
|
|
} catch (error) {
|
|
console.log(`Load or parse error from /db/games/${id}:`, error);
|
|
console.log(`Attempting to load backup from /db/games/${id}.bk`);
|
|
game = await readFile(`/db/games/${id}.bk`)
|
|
.catch(() => {
|
|
console.error(error, game);
|
|
});
|
|
if (game) {
|
|
try {
|
|
game = JSON.parse(game);
|
|
console.log(`Saving backup to /db/games/${id}`);
|
|
await writeFile(`/db/games/${id}`, JSON.stringify(game, null, 2));
|
|
} catch (error) {
|
|
console.error(error);
|
|
game = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!game) {
|
|
game = createGame(id);
|
|
}
|
|
|
|
/* Clear out cached names from player colors and rebuild them
|
|
* from the information in the saved game sessions */
|
|
for (let color in game.players) {
|
|
delete game.players[color].name;
|
|
game.players[color].status = 'Not active';
|
|
}
|
|
|
|
/* Reconnect session player colors to the player objects */
|
|
game.unselected = [];
|
|
for (let id in game.sessions) {
|
|
const session = game.sessions[id];
|
|
if (session.name && session.color && session.color in game.players) {
|
|
session.player = game.players[session.color];
|
|
session.player.name = session.name;
|
|
session.player.status = 'Active';
|
|
session.player.live = false;
|
|
} else {
|
|
session.color = '';
|
|
session.player = undefined;
|
|
}
|
|
|
|
session.live = false;
|
|
// Ensure we treat initial snapshot as unsent on (re)load so new socket
|
|
// attachments will get a fresh 'initial-game' message.
|
|
if (session._initialSnapshotSent) {
|
|
delete session._initialSnapshotSent;
|
|
}
|
|
|
|
/* Populate the 'unselected' list from the session table */
|
|
if (!game.sessions[id].color && game.sessions[id].name) {
|
|
game.unselected.push(game.sessions[id]);
|
|
}
|
|
}
|
|
|
|
games[id] = game;
|
|
return game;
|
|
};
|
|
|
|
const clearPlayer = (player) => {
|
|
const color = player.color;
|
|
for (let key in player) {
|
|
delete player[key];
|
|
}
|
|
Object.assign(player, newPlayer(color));
|
|
}
|
|
|
|
const canGiveBuilding = (game) => {
|
|
if (!game.turn.roll) {
|
|
return `Admin cannot give a building until the dice have been rolled.`;
|
|
}
|
|
if (game.turn.actions && game.turn.actions.length !== 0) {
|
|
return `Admin cannot give a building while other actions in play: ${game.turn.actions.join(', ')}.`
|
|
}
|
|
}
|
|
|
|
const adminCommands = (game, action, value, query) => {
|
|
let color, player, parts, session, corners, error;
|
|
|
|
switch (action) {
|
|
case 'rules':
|
|
const rule = value.replace(/=.*$/, '');
|
|
if (rule === 'list') {
|
|
const rules = {};
|
|
for (let key in supportedRules) {
|
|
if (game.rules[key]) {
|
|
rules[key] = game.rules[key];
|
|
} else {
|
|
rules[key] = { enabled: false };
|
|
}
|
|
}
|
|
return JSON.stringify(rules, null, 2);
|
|
}
|
|
let values = value.replace(/^.*=/, '').split(',');
|
|
const rules = {};
|
|
rules[rule] = {};
|
|
values.forEach(keypair => {
|
|
let [ key, value ] = keypair.split(':');
|
|
if (value === 'true') {
|
|
value = true;
|
|
} else if (value === 'false') {
|
|
value = false;
|
|
} else if (parseInt(value) === value) {
|
|
value = parseInt(value);
|
|
}
|
|
rules[rule][key] = value;
|
|
});
|
|
console.log(`admin - setRules -`, rules);
|
|
setRules(game, undefined, rules);
|
|
break;
|
|
|
|
case "debug":
|
|
if (parseInt(value) === 0 || value === 'false') {
|
|
delete game.debug;
|
|
} else {
|
|
game.debug = true;
|
|
}
|
|
break;
|
|
|
|
case "give":
|
|
parts = value.match(/^([^-]+)(-(.*))?$/);
|
|
if (!parts) {
|
|
return `Unable to parse give request.`;
|
|
}
|
|
const type = parts[1], card = parts[3] || 1;
|
|
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].name === game.turn.name) {
|
|
session = game.sessions[id];
|
|
}
|
|
}
|
|
|
|
if (!session) {
|
|
return `Unable to determine current player turn to give resources.`;
|
|
}
|
|
|
|
let done = true;
|
|
switch (type) {
|
|
case 'road':
|
|
error = canGiveBuilding(game);
|
|
if (error) {
|
|
return error;
|
|
}
|
|
|
|
if (session.player.roads === 0) {
|
|
return `Player ${game.turn.name} does not have any more roads to give.`;
|
|
}
|
|
let roads = getValidRoads(game, session.color);
|
|
if (roads.length === 0) {
|
|
return `There are no valid locations for ${game.turn.name} to place a road.`;
|
|
}
|
|
game.turn.free = true;
|
|
setForRoadPlacement(game, roads);
|
|
addChatMessage(game, null, `Admin gave a road to ${game.turn.name}.` +
|
|
`They must now place the road.`);
|
|
break;
|
|
case 'city':
|
|
error = canGiveBuilding(game);
|
|
if (error) {
|
|
return error;
|
|
}
|
|
|
|
if (session.player.cities === 0) {
|
|
return `Player ${game.turn.name} does not have any more cities to give.`;
|
|
}
|
|
corners = getValidCorners(game, session.color, 'settlement');
|
|
if (corners.length === 0) {
|
|
return `There are no valid locations for ${game.turn.name} to place a settlement.`;
|
|
}
|
|
game.turn.free = true;
|
|
setForCityPlacement(game, corners);
|
|
addChatMessage(game, null, `Admin gave a city to ${game.turn.name}. ` +
|
|
`They must now place the city.`);
|
|
break;
|
|
case 'settlement':
|
|
error = canGiveBuilding(game);
|
|
if (error) {
|
|
return error;
|
|
}
|
|
|
|
if (session.player.settlements === 0) {
|
|
return `Player ${game.turn.name} does not have any more settlements to give.`;
|
|
}
|
|
corners = getValidCorners(game, session.color);
|
|
if (corners.length === 0) {
|
|
return `There are no valid locations for ${game.turn.name} to place a settlement.`;
|
|
}
|
|
game.turn.free = true;
|
|
setForSettlementPlacement(game, corners);
|
|
addChatMessage(game, null, `Admin gave a settlment to ${game.turn.name}. ` +
|
|
`They must now place the settlement.`);
|
|
break;
|
|
case 'wheat':
|
|
case 'sheep':
|
|
case 'wood':
|
|
case 'stone':
|
|
case 'brick':
|
|
const count = parseInt(card);
|
|
session.player[type] += count;
|
|
session.resources += count;
|
|
addChatMessage(game, null, `Admin gave ${count} ${type} to ${game.turn.name}.`);
|
|
break;
|
|
default:
|
|
done = false;
|
|
break;
|
|
}
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
const index = game.developmentCards.findIndex(item =>
|
|
item.card.toString() === card && item.type === type);
|
|
|
|
if (index === -1) {
|
|
console.log({ card, type}, game.developmentCards);
|
|
return `Unable to find ${type}-${card} in the current deck of development cards.`;
|
|
}
|
|
|
|
let tmp = game.developmentCards.splice(index, 1)[0];
|
|
tmp.turn = game.turns ? game.turns - 1 : 0;
|
|
session.player.development.push(tmp);
|
|
addChatMessage(game, null, `Admin gave a ${card}-${type} to ${game.turn.name}.`);
|
|
break;
|
|
|
|
case "cards":
|
|
let results = game.developmentCards.map(card => `${card.type}-${card.card}`)
|
|
.join(', ');
|
|
return results;
|
|
|
|
case "roll":
|
|
let dice = (query.dice || Math.ceil(Math.random() * 6)).split(',');
|
|
dice = dice.map(die => parseInt(die));
|
|
|
|
console.log({ dice });
|
|
if (!value) {
|
|
return `Unable to parse roll request.`;
|
|
}
|
|
|
|
switch (value) {
|
|
case 'orange': color = 'O'; break;
|
|
case 'red': color = 'R'; break;
|
|
case 'blue': color = 'B'; break;
|
|
case 'white': color = 'W'; break;
|
|
}
|
|
if (!color) {
|
|
return `Unable to find player ${value}`
|
|
}
|
|
const rollingPlayer = (color) => {
|
|
for (let id in game.sessions) {
|
|
if ((color
|
|
&& game.sessions[id].player
|
|
&& game.sessions[id].player.color === color)
|
|
|| (game.sessions[id].name === game.turn.name)) {
|
|
return game.sessions[id];
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
addChatMessage(game, null,
|
|
`Admin rolling ${dice.join(', ')} for ${value}.`);
|
|
if (game.state === 'game-order') {
|
|
session = rollingPlayer(color);
|
|
} else {
|
|
session = rollingPlayer();
|
|
}
|
|
if (!session) {
|
|
return `Unable to determine current player turn for admin roll.`;
|
|
}
|
|
let warning = roll(game, session, dice);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
|
|
case "pass":
|
|
let name = game.turn.name;
|
|
const next = getNextPlayerSession(game, name);
|
|
game.turn = {
|
|
name: next.player,
|
|
color: next.color
|
|
};
|
|
game.turns++;
|
|
startTurnTimer(game, next);
|
|
addChatMessage(game, null, `The admin skipped ${name}'s turn.`);
|
|
addChatMessage(game, null, `It is ${next.name}'s turn.`);
|
|
break;
|
|
|
|
case "kick":
|
|
switch (value) {
|
|
case 'orange': color = 'O'; break;
|
|
case 'red': color = 'R'; break;
|
|
case 'blue': color = 'B'; break;
|
|
case 'white': color = 'W'; break;
|
|
}
|
|
if (!color) {
|
|
return `Unable to find player ${value}`
|
|
}
|
|
|
|
player = game.players[color];
|
|
for (let id in game.sessions) {
|
|
const session = game.sessions[id];
|
|
if (session.player !== player) {
|
|
continue;
|
|
}
|
|
console.log(`Kicking ${value} from ${game.id}.`);
|
|
const preamble = session.name ? `${session.name}, playing as ${colorToWord(color)},` : colorToWord(color);
|
|
addChatMessage(game, null, `${preamble} was kicked from game by the Admin.`);
|
|
if (player) {
|
|
clearPlayer(player);
|
|
session.player = undefined;
|
|
}
|
|
session.color = "";
|
|
return;
|
|
}
|
|
return `Unable to find active session for ${colorToWord(color)} (${value})`;
|
|
case "state":
|
|
if (game.state !== 'lobby') {
|
|
return `Game already started.`;
|
|
}
|
|
if (game.active < 2) {
|
|
return `Not enough players in game to start.`;
|
|
}
|
|
game.state = 'game-order';
|
|
/* Delete any non-played colors from the player map; reduces all
|
|
* code that would otherwise have to filter out players by checking
|
|
* the 'Not active' state of player.status */
|
|
for (let key in game.players) {
|
|
if (game.players[key].status !== 'Active') {
|
|
delete game.players[key];
|
|
}
|
|
}
|
|
addChatMessage(game, null, `Admin requested to start the game.`);
|
|
break;
|
|
|
|
default:
|
|
return `Invalid admin action ${action}.`;
|
|
}
|
|
};
|
|
|
|
const setPlayerName = (game, session, name) => {
|
|
if (session.name === name) {
|
|
return; /* no-op */
|
|
}
|
|
if (session.color) {
|
|
return `You cannot change your name while you have a color selected.`;
|
|
}
|
|
|
|
if (!name) {
|
|
return `You can not set your name to nothing!`;
|
|
}
|
|
|
|
if (name.toLowerCase() === 'the bank') {
|
|
return `You cannot play as the bank!`;
|
|
}
|
|
|
|
/* Check to ensure name is not already in use */
|
|
let rejoin = false;
|
|
for (let id in game.sessions) {
|
|
const tmp = game.sessions[id];
|
|
if (tmp === session || !tmp.name) {
|
|
continue;
|
|
}
|
|
if (tmp.name.toLowerCase() === name.toLowerCase()) {
|
|
if (!tmp.player || (Date.now() - tmp.player.lastActive) > 60000) {
|
|
rejoin = true;
|
|
/* Update the session object from tmp, but retain websocket
|
|
* from active session */
|
|
Object.assign(session, tmp, { ws: session.ws, id: session.id });
|
|
console.log(`${info}: ${name} has been reallocated to a new session.`);
|
|
delete game.sessions[id];
|
|
} else {
|
|
return `${name} is already taken and has been active in the last minute.`;
|
|
}
|
|
}
|
|
}
|
|
|
|
let message;
|
|
|
|
if (!session.name) {
|
|
message = `A new player has entered the lobby as ${name}.`;
|
|
} else {
|
|
if (rejoin) {
|
|
if (session.color) {
|
|
message = `${name} has reconnected to the game.`;
|
|
} else {
|
|
message = `${name} has rejoined the lobby.`;
|
|
}
|
|
session.name = name;
|
|
if (session.ws && (game.id in audio)
|
|
&& session.name in audio[game.id]) {
|
|
part(audio[game.id], session);
|
|
}
|
|
} else {
|
|
message = `${session.name} has changed their name to ${name}.`;
|
|
if (session.ws && game.id in audio) {
|
|
part(audio[game.id], session);
|
|
}
|
|
}
|
|
}
|
|
|
|
session.name = name;
|
|
session.live = true;
|
|
if (session.player) {
|
|
session.color = session.player.color;
|
|
session.player.name = session.name;
|
|
session.player.status = `Active`;
|
|
session.player.lastActive = Date.now();
|
|
session.player.name = name;
|
|
session.player.live = true;
|
|
}
|
|
|
|
if (session.ws && session.hasAudio) {
|
|
join(audio[game.id], session, {
|
|
hasVideo: session.video ? true : false,
|
|
hasAudio: session.audio ? true : false
|
|
});
|
|
}
|
|
console.log(`${info}: ${message}`);
|
|
addChatMessage(game, null, message);
|
|
|
|
/* Rebuild the unselected list */
|
|
if (!session.color) {
|
|
console.log(`${info}: Adding ${session.name} to the unselected`);
|
|
}
|
|
game.unselected = [];
|
|
for (let id in game.sessions) {
|
|
if (!game.sessions[id].color && game.sessions[id].name) {
|
|
game.unselected.push(game.sessions[id]);
|
|
}
|
|
}
|
|
|
|
sendUpdateToPlayer(game, session, {
|
|
name: session.name,
|
|
color: session.color,
|
|
live: session.live,
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
players: getFilteredPlayers(game),
|
|
unselected: getFilteredUnselected(game),
|
|
chat: game.chat
|
|
});
|
|
/* Now that a name is set, send the full game to the player */
|
|
sendGameToPlayer(game, session);
|
|
}
|
|
|
|
const colorToWord = (color) => {
|
|
switch (color) {
|
|
case 'O': return 'orange';
|
|
case 'W': return 'white';
|
|
case 'B': return 'blue';
|
|
case 'R': return 'red';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
const getActiveCount = (game) => {
|
|
let active = 0;
|
|
for (let color in game.players) {
|
|
if (!game.players[color].name) {
|
|
continue;
|
|
}
|
|
active++;
|
|
}
|
|
return active;
|
|
}
|
|
|
|
const setPlayerColor = (game, session, color) => {
|
|
/* Selecting the same color is a NO-OP */
|
|
if (session.color === color) {
|
|
return;
|
|
}
|
|
|
|
/* Verify the player has a name set */
|
|
if (!session.name) {
|
|
return `You may only select a player when you have set your name.`;
|
|
}
|
|
|
|
if (game.state !== 'lobby') {
|
|
return `You may only select a player when the game is in the lobby.`;
|
|
}
|
|
|
|
/* Verify selection is valid */
|
|
if (color && !(color in game.players)) {
|
|
return `An invalid player selection was attempted.`;
|
|
}
|
|
|
|
/* Verify selection is not already taken */
|
|
if (color && game.players[color].status !== 'Not active') {
|
|
return `${game.players[color].name} already has ${colorToWord(color)}`;
|
|
}
|
|
|
|
let active = getActiveCount(game);
|
|
|
|
if (session.player) {
|
|
/* Deselect currently active player for this session */
|
|
clearPlayer(session.player);
|
|
session.player = undefined;
|
|
const old_color = session.color;
|
|
session.color = '';
|
|
active--;
|
|
|
|
/* If the player is not selecting a color, then return */
|
|
if (!color) {
|
|
addChatMessage(game, null,
|
|
`${session.name} is no longer ${colorToWord(old_color)}.`);
|
|
game.unselected.push(session);
|
|
game.active = active;
|
|
if (active === 1) {
|
|
addChatMessage(game, null,
|
|
`There are no longer enough players to start a game.`);
|
|
}
|
|
sendUpdateToPlayer(game, session, {
|
|
name: session.name,
|
|
color: '',
|
|
live: session.live,
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
active: game.active,
|
|
unselected: getFilteredUnselected(game),
|
|
players: getFilteredPlayers(game),
|
|
chat: game.chat
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
/* All good -- set this player to requested selection */
|
|
active++;
|
|
session.color = color;
|
|
session.live = true;
|
|
session.player = game.players[color];
|
|
session.player.name = session.name;
|
|
session.player.status = `Active`;
|
|
session.player.lastActive = Date.now();
|
|
session.player.live = true;
|
|
addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`);
|
|
|
|
const update = {
|
|
players: getFilteredPlayers(game),
|
|
chat: game.chat
|
|
};
|
|
|
|
/* Rebuild the unselected list */
|
|
const unselected = [];
|
|
for (let id in game.sessions) {
|
|
if (!game.sessions[id].color && game.sessions[id].name) {
|
|
unselected.push(game.sessions[id]);
|
|
}
|
|
}
|
|
if (unselected.length !== game.unselected.length) {
|
|
game.unselected = unselected;
|
|
update.unselected = getFilteredUnselected(game);
|
|
}
|
|
|
|
if (game.active !== active) {
|
|
if (game.active < 2 && active >= 2) {
|
|
addChatMessage(game, null,
|
|
`There are now enough players to start the game.`);
|
|
}
|
|
game.active = active;
|
|
update.active = game.active;
|
|
}
|
|
|
|
sendUpdateToPlayer(game, session, {
|
|
name: session.name,
|
|
color: session.color,
|
|
live: session.live,
|
|
private: session.player,
|
|
});
|
|
sendUpdateToPlayers(game, update);
|
|
};
|
|
|
|
const addActivity = (game, session, message) => {
|
|
let date = Date.now();
|
|
if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
|
|
date++;
|
|
}
|
|
game.activities.push({ color: session ? session.color : '', message, date });
|
|
if (game.activities.length > 30) {
|
|
game.activities.splice(0, game.activities.length - 30);
|
|
}
|
|
}
|
|
|
|
const addChatMessage = (game, session, message, isNormalChat) => {
|
|
let now = Date.now();
|
|
let lastTime = 0;
|
|
if (game.chat.length) {
|
|
lastTime = game.chat[game.chat.length - 1].date;
|
|
}
|
|
if (now <= lastTime) {
|
|
now = lastTime + 1;
|
|
}
|
|
|
|
const entry = {
|
|
date: now,
|
|
message: message
|
|
};
|
|
if (isNormalChat) {
|
|
entry.normalChat = true;
|
|
}
|
|
if (session && session.name) {
|
|
entry.from = session.name;
|
|
}
|
|
if (session && session.color) {
|
|
entry.color = session.color;
|
|
}
|
|
game.chat.push(entry);
|
|
if (game.chat.length > 50) {
|
|
game.chat.splice(0, game.chat.length - 50);
|
|
}
|
|
};
|
|
|
|
const getColorFromName = (game, name) => {
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].name === name) {
|
|
return game.sessions[id].color;
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const getLastPlayerName = (game) => {
|
|
let index = game.playerOrder.length - 1;
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].color === game.playerOrder[index]) {
|
|
return game.sessions[id].name;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
const getFirstPlayerName = (game) => {
|
|
let index = 0;
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].color === game.playerOrder[index]) {
|
|
return game.sessions[id].name;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
const getNextPlayerSession = (game, name) => {
|
|
let color;
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].name === name) {
|
|
color = game.sessions[id].color;
|
|
break;
|
|
}
|
|
}
|
|
|
|
let index = game.playerOrder.indexOf(color);
|
|
index = (index + 1) % game.playerOrder.length;
|
|
color = game.playerOrder[index];
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].color === color) {
|
|
return game.sessions[id];
|
|
}
|
|
}
|
|
console.error(`getNextPlayerSession -- no player found!`);
|
|
console.log(game.players);
|
|
}
|
|
|
|
const getPrevPlayerSession = (game, name) => {
|
|
let color;
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].name === name) {
|
|
color = game.sessions[id].color;
|
|
break;
|
|
}
|
|
}
|
|
let index = game.playerOrder.indexOf(color);
|
|
index = (index - 1) % game.playerOrder.length;
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].color === game.playerOrder[index]) {
|
|
return game.sessions[id];
|
|
}
|
|
}
|
|
console.error(`getNextPlayerSession -- no player found!`);
|
|
console.log(game.players);
|
|
}
|
|
|
|
const processCorner = (game, color, cornerIndex, placedCorner) => {
|
|
/* If this corner is allocated and isn't assigned to the walking color, skip it */
|
|
if (placedCorner.color && placedCorner.color !== color) {
|
|
return 0;
|
|
}
|
|
/* If this corner is already being walked, skip it */
|
|
if (placedCorner.walking) {
|
|
return 0;
|
|
}
|
|
|
|
placedCorner.walking = true;
|
|
/* Calculate the longest road branching from both corners */
|
|
let longest = 0;
|
|
layout.corners[cornerIndex].roads.forEach(roadIndex => {
|
|
const placedRoad = game.placements.roads[roadIndex];
|
|
if (placedRoad.walking) {
|
|
return;
|
|
}
|
|
const tmp = processRoad(game, color, roadIndex, placedRoad);
|
|
longest = Math.max(tmp, longest);
|
|
/*if (tmp > longest) {
|
|
longest = tmp;
|
|
placedCorner.longestRoad = roadIndex;
|
|
placedCorner.longest
|
|
}
|
|
longest = Math.max(
|
|
*/
|
|
});
|
|
|
|
return longest;
|
|
};
|
|
|
|
const buildCornerGraph = (game, color, cornerIndex, placedCorner, set) => {
|
|
/* If this corner is allocated and isn't assigned to the walking color, skip it */
|
|
if (placedCorner.color && placedCorner.color !== color) {
|
|
return;
|
|
}
|
|
/* If this corner is already being walked, skip it */
|
|
if (placedCorner.walking) {
|
|
return;
|
|
}
|
|
|
|
placedCorner.walking = true;
|
|
/* Calculate the longest road branching from both corners */
|
|
layout.corners[cornerIndex].roads.forEach(roadIndex => {
|
|
const placedRoad = game.placements.roads[roadIndex];
|
|
buildRoadGraph(game, color, roadIndex, placedRoad, set);
|
|
});
|
|
};
|
|
|
|
const processRoad = (game, color, roadIndex, placedRoad) => {
|
|
/* If this road isn't assigned to the walking color, skip it */
|
|
if (placedRoad.color !== color) {
|
|
return 0;
|
|
}
|
|
|
|
/* If this road is already being walked, skip it */
|
|
if (placedRoad.walking) {
|
|
return 0;
|
|
}
|
|
|
|
placedRoad.walking = true;
|
|
/* Calculate the longest road branching from both corners */
|
|
let roadLength = 1;
|
|
layout.roads[roadIndex].corners.forEach(cornerIndex => {
|
|
const placedCorner = game.placements.corners[cornerIndex];
|
|
if (placedCorner.walking) {
|
|
return;
|
|
}
|
|
roadLength += processCorner(game, color, cornerIndex, placedCorner);
|
|
});
|
|
|
|
return roadLength;
|
|
};
|
|
|
|
const buildRoadGraph = (game, color, roadIndex, placedRoad, set) => {
|
|
/* If this road isn't assigned to the walking color, skip it */
|
|
if (placedRoad.color !== color) {
|
|
return;
|
|
}
|
|
/* If this road is already being walked, skip it */
|
|
if (placedRoad.walking) {
|
|
return;
|
|
}
|
|
|
|
placedRoad.walking = true;
|
|
set.push(roadIndex);
|
|
/* Calculate the longest road branching from both corners */
|
|
layout.roads[roadIndex].corners.forEach(cornerIndex => {
|
|
const placedCorner = game.placements.corners[cornerIndex];
|
|
buildCornerGraph(game, color, cornerIndex, placedCorner, set)
|
|
});
|
|
};
|
|
|
|
const clearRoadWalking = (game) => {
|
|
/* Clear out walk markers on roads */
|
|
layout.roads.forEach((item, itemIndex) => {
|
|
delete game.placements.roads[itemIndex].walking;
|
|
});
|
|
|
|
/* Clear out walk markers on corners */
|
|
layout.corners.forEach((item, itemIndex) => {
|
|
delete game.placements.corners[itemIndex].walking;
|
|
});
|
|
}
|
|
|
|
const calculateRoadLengths = (game, session) => {
|
|
clearRoadWalking(game);
|
|
|
|
let currentLongest = game.longestRoad,
|
|
currentLength = currentLongest
|
|
? game.players[currentLongest].longestRoad
|
|
: -1;
|
|
|
|
/* Clear out player longest road counts */
|
|
for (let key in game.players) {
|
|
game.players[key].longestRoad = 0;
|
|
}
|
|
|
|
/* Build a set of connected road graphs. Once all graphs are
|
|
* constructed, walk through each graph, starting from each
|
|
* location in the graph. If the length ever equals the
|
|
* number of items in the graph, short circuit--longest path.
|
|
* Otherwise, check all paths from each segment. This is
|
|
* needed to catch loops where starting from an outside end
|
|
* point may result in not counting the length of the loop
|
|
*/
|
|
let graphs = [];
|
|
layout.roads.forEach((road, roadIndex) => {
|
|
const placedRoad = game.placements.roads[roadIndex];
|
|
if (placedRoad.color) {
|
|
let set = [];
|
|
buildRoadGraph(game, placedRoad.color, roadIndex, placedRoad, set);
|
|
if (set.length) {
|
|
graphs.push({ color: placedRoad.color, set });
|
|
}
|
|
}
|
|
});
|
|
|
|
if (debug.road) console.log('Graphs A:', graphs);
|
|
|
|
clearRoadWalking(game);
|
|
graphs.forEach(graph => {
|
|
graph.longestRoad = 0;
|
|
graph.set.forEach(roadIndex => {
|
|
const placedRoad = game.placements.roads[roadIndex];
|
|
clearRoadWalking(game);
|
|
const length = processRoad(game, placedRoad.color, roadIndex, placedRoad);
|
|
if (length >= graph.longestRoad) {
|
|
graph.longestStartSegment = roadIndex;
|
|
graph.longestRoad = length;
|
|
}
|
|
});
|
|
});
|
|
|
|
if (debug.road) console.log('Graphs B:', graphs);
|
|
|
|
if (debug.road) console.log('Pre update:',
|
|
game.placements.roads.filter(road => road.color));
|
|
|
|
for (let color in game.players) {
|
|
if (game.players[color] === 'Not active') {
|
|
continue;
|
|
}
|
|
game.players[color].longestRoad = 0;
|
|
}
|
|
|
|
graphs.forEach(graph => {
|
|
graph.set.forEach(roadIndex => {
|
|
const placedRoad = game.placements.roads[roadIndex];
|
|
clearRoadWalking(game);
|
|
const longestRoad = processRoad(game, placedRoad.color, roadIndex, placedRoad);
|
|
placedRoad.longestRoad = longestRoad;
|
|
game.players[placedRoad.color].longestRoad =
|
|
Math.max(game.players[placedRoad.color].longestRoad, longestRoad);
|
|
});
|
|
});
|
|
|
|
game.placements.roads.forEach(road => delete road.walking);
|
|
|
|
if (debug.road) console.log('Post update:',
|
|
game.placements.roads.filter(road => road.color));
|
|
|
|
let checkForTies = false;
|
|
|
|
if (debug.road) console.log(currentLongest, currentLength);
|
|
|
|
if (currentLongest && game.players[currentLongest].longestRoad < currentLength) {
|
|
const _session = sessionFromColor(game, currentLongest);
|
|
addChatMessage(game, session, `${session.name} had their longest road split!`);
|
|
checkForTies = true;
|
|
}
|
|
|
|
let longestRoad = 4, longestPlayers = [];
|
|
for (let key in game.players) {
|
|
const player = game.players[key];
|
|
if (player.status === 'Not active') {
|
|
continue;
|
|
}
|
|
if (player.longestRoad > longestRoad) {
|
|
longestPlayers = [ player ];
|
|
longestRoad = player.longestRoad;
|
|
} else if (game.players[key].longestRoad === longestRoad) {
|
|
if (longestRoad >= 5) {
|
|
longestPlayers.push(player);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log({ longestPlayers });
|
|
|
|
if (longestPlayers.length > 0) {
|
|
if (longestPlayers.length === 1) {
|
|
game.longestRoadLength = longestRoad;
|
|
if (game.longestRoad !== longestPlayers[0].color) {
|
|
game.longestRoad = longestPlayers[0].color;
|
|
addChatMessage(game, session,
|
|
`${longestPlayers[0].name} now has the longest ` +
|
|
`road (${longestRoad})!`);
|
|
}
|
|
} else {
|
|
if (checkForTies) {
|
|
game.longestRoadLength = longestRoad;
|
|
const names = longestPlayers.map(player => player.name);
|
|
addChatMessage(game, session, `${names.join(', ')} are tied for longest ` +
|
|
`road (${longestRoad})!`);
|
|
}
|
|
/* Do not reset the longest road! Current Longest is still longest! */
|
|
}
|
|
} else {
|
|
game.longestRoad = false;
|
|
game.longestRoadLength = 0;
|
|
}
|
|
};
|
|
|
|
const isCompatibleOffer = (player, offer) => {
|
|
const isBank = offer.name === 'The bank';
|
|
let valid = player.gets.length === offer.gives.length &&
|
|
player.gives.length === offer.gets.length;
|
|
|
|
if (!valid) {
|
|
console.log(`Gives and gets lengths do not match!`);
|
|
return false;
|
|
}
|
|
|
|
console.log({
|
|
player: 'Submitting player',
|
|
gets: player.gets,
|
|
gives: player.gives
|
|
}, {
|
|
name: offer.name,
|
|
gets: offer.gets,
|
|
gives: offer.gives
|
|
});
|
|
|
|
player.gets.forEach(get => {
|
|
if (!valid) {
|
|
return;
|
|
}
|
|
valid = offer.gives.find(item =>
|
|
(item.type === get.type || isBank) &&
|
|
item.count === get.count) !== undefined;
|
|
});
|
|
|
|
if (valid) player.gives.forEach(give => {
|
|
if (!valid) {
|
|
return;
|
|
}
|
|
valid = offer.gets.find(item =>
|
|
(item.type === give.type || isBank) &&
|
|
item.count === give.count) !== undefined;
|
|
});
|
|
return valid;
|
|
};
|
|
|
|
const isSameOffer = (player, offer) => {
|
|
const isBank = offer.name === 'The bank';
|
|
if (isBank) {
|
|
return false;
|
|
}
|
|
let same = player.gets && player.gives &&
|
|
player.gets.length === offer.gets.length &&
|
|
player.gives.length === offer.gives.length;
|
|
|
|
if (!same) {
|
|
return false;
|
|
}
|
|
|
|
player.gets.forEach(get => {
|
|
if (!same) {
|
|
return;
|
|
}
|
|
same = offer.gets.find(item =>
|
|
item.type === get.type && item.count === get.count) !== undefined;
|
|
});
|
|
|
|
if (same) player.gives.forEach(give => {
|
|
if (!same) {
|
|
return;
|
|
}
|
|
same = offer.gives.find(item =>
|
|
item.type === give.type && item.count === give.count) !== undefined;
|
|
});
|
|
return same;
|
|
};
|
|
|
|
/* Verifies player can meet the offer */
|
|
const checkPlayerOffer = (game, player, offer) => {
|
|
let error = undefined;
|
|
const name = player.name;
|
|
|
|
console.log({ checkPlayerOffer: {
|
|
name: name,
|
|
player: player,
|
|
gets: offer.gets,
|
|
gives: offer.gives,
|
|
sheep: player.sheep,
|
|
wheat: player.wheat,
|
|
brick: player.brick,
|
|
stone: player.stone,
|
|
wood: player.wood,
|
|
description: offerToString(offer)
|
|
} });
|
|
|
|
offer.gives.forEach(give => {
|
|
if (!error) {
|
|
return;
|
|
}
|
|
|
|
if (!(give.type in player)) {
|
|
error = `${give.type} is not a valid resource!`;
|
|
return;
|
|
}
|
|
|
|
if (give.count <= 0) {
|
|
error = `${give.count} must be more than 0!`
|
|
return;
|
|
}
|
|
|
|
if (player[give.type] < give.count) {
|
|
error = `${name} does do not have ${give.count} ${give.type}!`;
|
|
return;
|
|
}
|
|
|
|
if (offer.gets.find(get => give.type === get.type)) {
|
|
error = `${name} can not give and get the same resource type!`;
|
|
return;
|
|
}
|
|
});
|
|
|
|
if (!error) offer.gets.forEach(get => {
|
|
if (error) {
|
|
return;
|
|
}
|
|
if (get.count <= 0) {
|
|
error = `${get.count} must be more than 0!`;
|
|
return;
|
|
}
|
|
if (offer.gives.find(give => get.type === give.type)) {
|
|
error = `${name} can not give and get the same resource type!`;
|
|
};
|
|
})
|
|
|
|
return error;
|
|
};
|
|
|
|
const canMeetOffer = (player, offer) => {
|
|
for (let i = 0; i < offer.gets.length; i++) {
|
|
const get = offer.gets[i];
|
|
if (get.type === 'bank') {
|
|
if (player[player.gives[0].type] < get.count || get.count <= 0) {
|
|
return false;
|
|
}
|
|
} else if (player[get.type] < get.count || get.count <= 0) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const gameSignature = (game) => {
|
|
if (!game) {
|
|
return "";
|
|
}
|
|
const salt = 251;
|
|
const signature =
|
|
game.borderOrder.map(border => `00${(Number(border)^salt).toString(16)}`.slice(-2)).join('') + '-' +
|
|
game.pipOrder.map((pip, index) => `00${(Number(pip)^salt^(salt*index)).toString(16)}`.slice(-2)).join('') + '-' +
|
|
game.tileOrder.map((tile, index) => `00${(Number(tile)^salt^(salt*index)).toString(16)}`.slice(-2)).join('');
|
|
|
|
return signature;
|
|
};
|
|
|
|
const setGameFromSignature = (game, border, pip, tile) => {
|
|
const salt = 251;
|
|
const borders = [], pips = [], tiles = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
borders[i] = parseInt(border.slice(i * 2, (i * 2) + 2), 16)^salt;
|
|
if (borders[i] > 6) {
|
|
return false;
|
|
}
|
|
}
|
|
for (let i = 0; i < 19; i++) {
|
|
pips[i] = parseInt(pip.slice(i * 2, (i * 2) + 2), 16)^salt^(salt*i) % 256;
|
|
if (pips[i] > 18) {
|
|
return false;
|
|
}
|
|
}
|
|
for (let i = 0; i < 19; i++) {
|
|
tiles[i] = parseInt(tile.slice(i * 2, (i * 2) + 2), 16)^salt^(salt*i) % 256;
|
|
if (tiles[i] > 18) {
|
|
return false;
|
|
}
|
|
}
|
|
game.borderOrder = borders;
|
|
game.pipOrder = pips;
|
|
game.tileOrder = tiles;
|
|
return true;
|
|
}
|
|
|
|
const offerToString = (offer) => {
|
|
return offer.gives.map(item => `${item.count} ${item.type}`).join(', ') +
|
|
' in exchange for ' +
|
|
offer.gets.map(item => `${item.count} ${item.type}`).join(', ');
|
|
}
|
|
|
|
const setForRoadPlacement = (game, limits) => {
|
|
game.turn.actions = [ 'place-road' ];
|
|
game.turn.limits = { roads: limits };
|
|
}
|
|
|
|
const setForCityPlacement = (game, limits) => {
|
|
game.turn.actions = [ 'place-city' ];
|
|
game.turn.limits = { corners: limits };
|
|
}
|
|
|
|
const setForSettlementPlacement = (game, limits) => {
|
|
game.turn.actions = [ 'place-settlement' ];
|
|
game.turn.limits = { corners: limits };
|
|
}
|
|
|
|
router.put("/:id/:action/:value?", async (req, res) => {
|
|
const { action, id } = req.params,
|
|
value = req.params.value ? req.params.value : "";
|
|
console.log(`PUT games/${id}/${action}/${value}`);
|
|
|
|
const game = await loadGame(id);
|
|
if (!game) {
|
|
const error = `Game not found and cannot be created: ${id}`;
|
|
return res.status(404).send(error);
|
|
}
|
|
|
|
let error = 'Invalid request';
|
|
|
|
if ('private-token' in req.headers) {
|
|
if (req.headers['private-token'] !== req.app.get('admin')) {
|
|
error = `Invalid admin credentials.`;
|
|
} else {
|
|
error = adminCommands(game, action, value, req.query);
|
|
}
|
|
if (!error) {
|
|
sendGameToPlayers(game);
|
|
} else {
|
|
console.log(`admin-action error: ${error}`);
|
|
}
|
|
}
|
|
|
|
return res.status(400).send(error);
|
|
});
|
|
|
|
const startTrade = (game, session) => {
|
|
/* Only the active player can begin trading */
|
|
if (game.turn.name !== session.name) {
|
|
return `You cannot start trading negotiations when it is not your turn.`
|
|
}
|
|
/* Clear any free gives if the player begins trading */
|
|
if (game.turn.free) {
|
|
delete game.turn.free;
|
|
}
|
|
game.turn.actions = ['trade'];
|
|
game.turn.limits = {};
|
|
for (let key in game.players) {
|
|
game.players[key].gives = [];
|
|
game.players[key].gets = [];
|
|
delete game.players[key].offerRejected;
|
|
}
|
|
addActivity(game, session,
|
|
`${session.name} requested to begin trading negotiations.`);
|
|
};
|
|
|
|
const cancelTrade = (game, session) => {
|
|
/* TODO: Perhaps 'cancel' is how a player can remove an offer... */
|
|
if (game.turn.name !== session.name) {
|
|
return `Only the active player can cancel trading negotiations.`;
|
|
}
|
|
game.turn.actions = [];
|
|
game.turn.limits = {};
|
|
addActivity(game, session, `${session.name} has cancelled trading negotiations.`);
|
|
};
|
|
|
|
const processOffer = (game, session, offer) => {
|
|
let warning = checkPlayerOffer(game, session.player, offer);
|
|
if (warning) {
|
|
return warning;
|
|
}
|
|
|
|
if (isSameOffer(session.player, offer)) {
|
|
console.log(session.player);
|
|
return `You already have a pending offer submitted for ${offerToString(offer)}.`;
|
|
}
|
|
|
|
session.player.gives = offer.gives;
|
|
session.player.gets = offer.gets;
|
|
session.player.offerRejected = {};
|
|
|
|
if (game.turn.color === session.color) {
|
|
game.turn.offer = offer;
|
|
}
|
|
|
|
/* If this offer matches what another player wants, clear rejection
|
|
* on of that other player's offer */
|
|
for (let color in game.players) {
|
|
if (color === session.color) {
|
|
continue;
|
|
}
|
|
const other = game.players[color];
|
|
if (other.status !== 'Active') {
|
|
continue;
|
|
}
|
|
/* Comparison reverses give/get order */
|
|
if (isSameOffer(other, { gives: offer.gets, gets: offer.gives })) {
|
|
if (other.offerRejected) {
|
|
delete other.offerRejected[session.color];
|
|
}
|
|
}
|
|
}
|
|
|
|
addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`);
|
|
};
|
|
|
|
const rejectOffer = (game, session, offer) => {
|
|
/* If the active player rejected an offer, they rejected another player */
|
|
const other = game.players[offer.color];
|
|
if (!other.offerRejected) {
|
|
other.offerRejected = {};
|
|
}
|
|
other.offerRejected[session.color] = true;
|
|
if (!session.player.offerRejected) {
|
|
session.player.offerRejected = {};
|
|
}
|
|
session.player.offerRejected[offer.color] = true;
|
|
addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`);
|
|
};
|
|
|
|
const acceptOffer = (game, session, offer) => {
|
|
const name = session.name, player = session.player;
|
|
|
|
if (game.turn.name !== name) {
|
|
return `Only the active player can accept an offer.`;
|
|
}
|
|
|
|
let target;
|
|
|
|
console.log({ description: offerToString(offer) });
|
|
|
|
let warning = checkPlayerOffer(game, session.player, offer);
|
|
if (warning) {
|
|
return warning;
|
|
}
|
|
|
|
if (!isCompatibleOffer(session.player, {
|
|
name: offer.name,
|
|
gives: offer.gets,
|
|
gets: offer.gives
|
|
})) {
|
|
return `Unfortunately, trades were re-negotiated in transit and 1 ` +
|
|
`the deal is invalid!`;
|
|
}
|
|
|
|
/* Verify that the offer sent by the active player matches what
|
|
* the latest offer was that was received by the requesting player */
|
|
if (!offer.name || offer.name !== 'The bank') {
|
|
target = game.players[offer.color];
|
|
if (offer.color in target.offerRejected) {
|
|
return `${target.name} rejected this offer.`;
|
|
}
|
|
if (!isCompatibleOffer(target, offer)) {
|
|
return `Unfortunately, trades were re-negotiated in transit and ` +
|
|
`the deal is invalid!`;
|
|
}
|
|
|
|
warning = checkPlayerOffer(game, target, {
|
|
gives: offer.gets,
|
|
gets: offer.gives
|
|
});
|
|
if (warning) {
|
|
return warning;
|
|
}
|
|
|
|
if (!isSameOffer(target, { gives: offer.gets, gets: offer.gives })) {
|
|
console.log({ target, offer });
|
|
return `These terms were not agreed to by ${target.name}!`;
|
|
}
|
|
|
|
if (!canMeetOffer(target, player)) {
|
|
return `${target.name} cannot meet the terms.`;
|
|
}
|
|
} else {
|
|
target = offer;
|
|
}
|
|
|
|
debugChat(game, 'Before trade');
|
|
|
|
/* Transfer goods */
|
|
offer.gets.forEach(item => {
|
|
if (target.name !== 'The bank') {
|
|
target[item.type] -= item.count;
|
|
target.resources -= item.count;
|
|
}
|
|
player[item.type] += item.count;
|
|
player.resources += item.count;
|
|
});
|
|
offer.gives.forEach(item => {
|
|
if (target.name !== 'The bank') {
|
|
target[item.type] += item.count;
|
|
target.resources += item.count;
|
|
}
|
|
player[item.type] -= item.count;
|
|
player.resources -= item.count;
|
|
});
|
|
|
|
const from = (offer.name === 'The bank') ? 'the bank' : offer.name;
|
|
addChatMessage(game, session, `${session.name} traded ` +
|
|
` ${offerToString(offer)} ` +
|
|
`from ${from}.`);
|
|
addActivity(game, session, `${session.name} accepted a trade from ${from}.`)
|
|
delete game.turn.offer;
|
|
if (target) {
|
|
delete target.gives;
|
|
delete target.gets;
|
|
}
|
|
delete session.player.gives;
|
|
delete session.player.gets;
|
|
delete game.turn.offer;
|
|
|
|
debugChat(game, 'After trade');
|
|
|
|
/* Debug!!! */
|
|
for (let key in game.players) {
|
|
if (!game.players[key].state === 'Active') {
|
|
continue;
|
|
}
|
|
types.forEach(type => {
|
|
if (game.players[key][type] < 0) {
|
|
throw new Error(`Player resources are below zero! BUG BUG BUG!`);
|
|
}
|
|
});
|
|
}
|
|
game.turn.actions = [];
|
|
};
|
|
|
|
const trade = (game, session, action, offer) => {
|
|
if (game.state !== "normal") {
|
|
return `Game not in correct state to begin trading.`;
|
|
}
|
|
|
|
if (!game.turn.actions || game.turn.actions.indexOf('trade') === -1) {
|
|
return startTrade(game, session);
|
|
}
|
|
|
|
/* Only the active player can cancel trading */
|
|
if (action === 'cancel') {
|
|
return cancelTrade(game, session);
|
|
}
|
|
|
|
/* Any player can make an offer */
|
|
if (action === 'offer') {
|
|
return processOffer(game, session, offer);
|
|
}
|
|
|
|
/* Any player can reject an offer */
|
|
if (action === 'reject') {
|
|
return rejectOffer(game, session, offer);
|
|
}
|
|
|
|
/* Only the active player can accept an offer */
|
|
if (action === 'accept') {
|
|
if (offer.name === 'The bank') {
|
|
session.player.gets = offer.gets;
|
|
session.player.gives = offer.gives;
|
|
}
|
|
return acceptOffer(game, session, offer);
|
|
}
|
|
}
|
|
|
|
const clearTimeNotice= (game, session) => {
|
|
if (!session.player.turnNotice) {
|
|
/* benign state; don't alert the user */
|
|
//return `You have not been idle.`;
|
|
}
|
|
session.player.turnNotice = "";
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
};
|
|
|
|
const startTurnTimer = (game, session) => {
|
|
const timeout = 90;
|
|
if (!session.ws) {
|
|
console.log(`${session.id}: Aborting turn timer as ${session.name} is disconnected.`);
|
|
} else {
|
|
console.log(`${session.id}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`);
|
|
}
|
|
if (game.turnTimer) {
|
|
clearTimeout(game.turnTimer);
|
|
}
|
|
if (!session.connected) {
|
|
game.turnTimer = 0;
|
|
return;
|
|
}
|
|
game.turnTimer = setTimeout(() => {
|
|
console.log(`${session.id}: Turn timer expired for ${session.name}`);
|
|
session.player.turnNotice = 'It is still your turn.';
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
resetTurnTimer(game, session);
|
|
}, timeout * 1000);
|
|
}
|
|
|
|
const resetTurnTimer = (game, session) => {
|
|
startTurnTimer(game, session);
|
|
}
|
|
|
|
const stopTurnTimer = (game) => {
|
|
if (game.turnTimer) {
|
|
console.log(`${info}: Stopping turn timer.`);
|
|
clearTimeout(game.turnTimer);
|
|
game.turnTimer = 0;
|
|
}
|
|
}
|
|
|
|
const shuffle = (game, session) => {
|
|
if (game.state !== "lobby") {
|
|
return `Game no longer in lobby (${game.state}). Can not shuffle board.`;
|
|
}
|
|
if (game.turns > 0) {
|
|
return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`;
|
|
}
|
|
shuffleBoard(game);
|
|
console.log(`${session.id}: Shuffled to new signature: ${game.signature}`);
|
|
|
|
sendUpdateToPlayers(game, {
|
|
pipOrder: game.pipOrder,
|
|
tileOrder: game.tileOrder,
|
|
borderOrder: game.borderOrder,
|
|
robber: game.robber,
|
|
robberName: game.robberName,
|
|
signature: game.signature,
|
|
animationSeeds: game.animationSeeds
|
|
});
|
|
}
|
|
|
|
const pass = (game, session) => {
|
|
const name = session.name;
|
|
if (game.turn.name !== name) {
|
|
return `You cannot pass when it isn't your turn.`
|
|
}
|
|
|
|
/* If the current turn is a robber placement, and everyone has
|
|
* discarded, set the limits for where the robber can be placed */
|
|
if (game.turn && game.turn.robberInAction) {
|
|
return `Robber is in action. Turn can not stop until all Robber tasks are resolved.`;
|
|
}
|
|
|
|
if (game.state === 'volcano') {
|
|
return `You cannot not stop turn until you have finished the Volcano tasks.`;
|
|
}
|
|
|
|
const next = getNextPlayerSession(game, session.name);
|
|
session.player.totalTime += Date.now() - session.player.turnStart;
|
|
session.player.turnNotice = "";
|
|
game.turn = {
|
|
name: next.name,
|
|
color: next.color
|
|
};
|
|
next.player.turnStart = Date.now();
|
|
startTurnTimer(game, next);
|
|
game.turns++;
|
|
addActivity(game, session, `${name} passed their turn.`);
|
|
addChatMessage(game, null, `It is ${next.name}'s turn.`);
|
|
sendUpdateToPlayer(game, next, {
|
|
private: next.player
|
|
});
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
delete game.dice;
|
|
|
|
sendUpdateToPlayers(game, {
|
|
turns: game.turns,
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities,
|
|
dice: game.dice
|
|
});
|
|
saveGame(game);
|
|
}
|
|
|
|
const placeRobber = (game, session, robber) => {
|
|
const name = session.name;
|
|
robber = parseInt(robber);
|
|
|
|
if (game.state !== 'normal' && game.turn.roll !== 7) {
|
|
return `You cannot place robber unless 7 was rolled!`;
|
|
}
|
|
if (game.turn.name !== name) {
|
|
return `You cannot place the robber when it isn't your turn.`;
|
|
}
|
|
|
|
for (let color in game.players) {
|
|
if (game.players[color].status === 'Not active') {
|
|
continue;
|
|
}
|
|
if (game.players[color].mustDiscard > 0) {
|
|
return `You cannot place the robber until everyone has discarded!`;
|
|
}
|
|
}
|
|
|
|
if (game.robber === robber) {
|
|
return `You must move the robber to a new location!`;
|
|
}
|
|
game.robber = robber;
|
|
game.turn.placedRobber = true;
|
|
|
|
pickRobber(game);
|
|
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
|
|
|
|
let targets = [];
|
|
layout.tiles[robber].corners.forEach(cornerIndex => {
|
|
const active = game.placements.corners[cornerIndex];
|
|
if (active && active.color
|
|
&& active.color !== game.turn.color
|
|
&& targets.findIndex(item => item.color === active.color) === -1) {
|
|
targets.push({
|
|
color: active.color,
|
|
name: game.players[active.color].name
|
|
});
|
|
}
|
|
});
|
|
|
|
if (targets.length) {
|
|
game.turn.actions = [ 'steal-resource' ],
|
|
game.turn.limits = { players: targets };
|
|
} else {
|
|
game.turn.actions = [];
|
|
game.turn.robberInAction = false;
|
|
delete game.turn.limits;
|
|
addChatMessage(game, null,
|
|
`The dread robber ${game.robberName} was placed on a terrain ` +
|
|
`with no other players, ` +
|
|
`so ${game.turn.name} does not steal resources from anyone.`);
|
|
}
|
|
|
|
sendUpdateToPlayers(game, {
|
|
placements: game.placements,
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
robber: game.robber,
|
|
robberName: game.robberName,
|
|
activities: game.activities
|
|
});
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
}
|
|
|
|
const stealResource = (game, session, color) => {
|
|
if (game.turn.actions.indexOf('steal-resource') === -1) {
|
|
return `You can only steal a resource when it is valid to do so!`;
|
|
}
|
|
if (game.turn.limits.players.findIndex(item => item.color === color) === -1) {
|
|
return `You can only steal a resource from a player on this terrain!`;
|
|
}
|
|
let victim;
|
|
for (let key in game.sessions) {
|
|
if (game.sessions[key].color === color) {
|
|
victim = game.sessions[key];
|
|
break;
|
|
}
|
|
}
|
|
if (!victim) {
|
|
return `You sent a wierd color for the target to steal from.`;
|
|
}
|
|
const cards = [];
|
|
[ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(field => {
|
|
for (let i = 0; i < victim.player[field]; i++) {
|
|
cards.push(field);
|
|
}
|
|
});
|
|
|
|
debugChat(game, 'Before steal');
|
|
|
|
if (cards.length === 0) {
|
|
addChatMessage(game, session,
|
|
`${victim.name} ` +
|
|
`did not have any cards for ${session.name} to steal.`);
|
|
game.turn.actions = [];
|
|
game.turn.limits = {};
|
|
} else {
|
|
let index = Math.floor(Math.random() * cards.length),
|
|
type = cards[index];
|
|
victim.player[type]--;
|
|
victim.player.resources--;
|
|
session.player[type]++;
|
|
session.player.resources++;
|
|
game.turn.actions = [];
|
|
game.turn.limits = {};
|
|
trackTheft(game, victim.color, session.color, type, 1);
|
|
|
|
addChatMessage(game, session,
|
|
`${session.name} randomly stole 1 ${type} from ` +
|
|
`${victim.name}.`);
|
|
sendUpdateToPlayer(game, victim, {
|
|
private: victim.player
|
|
});
|
|
}
|
|
debugChat(game, 'After steal');
|
|
|
|
game.turn.robberInAction = false;
|
|
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const buyDevelopment = (game, session) => {
|
|
const player = session.player;
|
|
|
|
if (game.state !== 'normal') {
|
|
return `You cannot purchase a development card unless the game is active (${game.state}).`;
|
|
}
|
|
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
|
|
if (!game.turn.roll) {
|
|
return `You cannot build until you have rolled.`;
|
|
}
|
|
|
|
if (game.turn && game.turn.robberInAction) {
|
|
return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
|
|
}
|
|
|
|
if (game.developmentCards.length < 1) {
|
|
return `There are no more development cards!`;
|
|
}
|
|
|
|
if (player.stone < 1 || player.wheat < 1 || player.sheep < 1) {
|
|
return `You have insufficient resources to purchase a development card.`;
|
|
}
|
|
|
|
if (game.turn.developmentPurchased) {
|
|
return `You have already purchased a development card this turn.`;
|
|
}
|
|
|
|
debugChat(game, 'Before development purchase');
|
|
addActivity(game, session, `${session.name} purchased a development card.`);
|
|
addChatMessage(game, session, `${session.name} spent 1 stone, 1 wheat, 1 sheep to purchase a development card.`)
|
|
player.stone--;
|
|
player.wheat--;
|
|
player.sheep--;
|
|
player.resources = 0;
|
|
player.developmentCards++;
|
|
[ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => {
|
|
player.resources += player[resource];
|
|
});
|
|
debugChat(game, 'After development purchase');
|
|
const card = game.developmentCards.pop();
|
|
card.turn = game.turns;
|
|
player.development.push(card);
|
|
|
|
if (isRuleEnabled(game, 'most-developed')) {
|
|
if (player.development.length >= 5
|
|
&& (!game.mostDeveloped
|
|
|| player.developmentCards
|
|
> game.players[game.mostDeveloped].developmentCards)) {
|
|
if (game.mostDeveloped !== session.color) {
|
|
game.mostDeveloped = session.color;
|
|
game.mostDevelopmentCards = player.developmentCards;
|
|
addChatMessage(game, session, `${session.name} now has the most development cards (${player.developmentCards})!`)
|
|
}
|
|
}
|
|
}
|
|
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
|
|
sendUpdateToPlayers(game, {
|
|
chat: game.chat,
|
|
activities: game.activities,
|
|
mostDeveloped: game.mostDeveloped,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const playCard = (game, session, card) => {
|
|
const name = session.name, player = session.player;
|
|
|
|
if (game.state !== 'normal') {
|
|
return `You cannot purchase a development card unless the game is active (${game.state}).`;
|
|
}
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
if (!game.turn.roll) {
|
|
return `You cannot play a card until you have rolled.`;
|
|
}
|
|
|
|
if (game.turn && game.turn.robberInAction) {
|
|
return `Robber is in action. You can not play a card until all Robber tasks are resolved.`;
|
|
}
|
|
|
|
card = player.development.find(
|
|
item => item.type == card.type
|
|
&& item.card == card.card
|
|
&& !item.card.played);
|
|
if (!card) {
|
|
return `The card you want to play was not found in your hand!`;
|
|
}
|
|
|
|
if (player.playedCard === game.turns && card.type !== 'vp') {
|
|
return `You can only play one development card per turn!`;
|
|
}
|
|
|
|
/* Check if this is a victory point */
|
|
if (card.type === 'vp') {
|
|
let points = player.points;
|
|
player.development.forEach(item => {
|
|
if (item.type === 'vp') {
|
|
points++;
|
|
}
|
|
});
|
|
if (points < getVictoryPointRule(game)) {
|
|
return `You can not play victory point cards until you can reach ${getVictoryPointRule(game)}!`;
|
|
}
|
|
addChatMessage(game, session, `${name} played a Victory Point card.`);
|
|
}
|
|
|
|
if (card.type === 'progress') {
|
|
switch (card.card) {
|
|
case 'road-1':
|
|
case 'road-2':
|
|
const allowed = Math.min(player.roads, 2);
|
|
if (!allowed) {
|
|
addChatMessage(game, session, `${session.name} played a Road Building card, but has no roads to build.`);
|
|
break;
|
|
}
|
|
let roads = getValidRoads(game, session.color);
|
|
if (roads.length === 0) {
|
|
addChatMessage(game, session, `${session.name} played a Road Building card, but they do not have any valid locations to place them.`);
|
|
break;
|
|
}
|
|
game.turn.active = 'road-building';
|
|
game.turn.free = true;
|
|
game.turn.freeRoads = allowed;
|
|
addChatMessage(game, session, `${session.name} played a Road Building card. They now place ${allowed} roads for free.`);
|
|
setForRoadPlacement(game, roads);
|
|
break;
|
|
case 'monopoly':
|
|
game.turn.actions = [ 'select-resources' ];
|
|
game.turn.active = 'monopoly';
|
|
addActivity(game, session, `${session.name} played the Monopoly card, and is selecting their resource type to claim.`);
|
|
break;
|
|
case 'year-of-plenty':
|
|
game.turn.actions = [ 'select-resources' ];
|
|
game.turn.active = 'year-of-plenty';
|
|
addActivity(game, session, `${session.name} played the Year of Plenty card.`);
|
|
break;
|
|
default:
|
|
addActivity(game, session, `Oh no! ${card.card} isn't impmented yet!`);
|
|
break;
|
|
}
|
|
}
|
|
card.played = true;
|
|
player.playedCard = game.turns;
|
|
|
|
if (card.type === 'army') {
|
|
player.army++;
|
|
addChatMessage(game, session, `${session.name} played a Knight and must move the robber!`);
|
|
|
|
if (player.army > 2 &&
|
|
(!game.largestArmy || game.players[game.largestArmy].army < player.army)) {
|
|
if (game.largestArmy !== session.color) {
|
|
game.largestArmy = session.color;
|
|
game.largestArmySize = player.army;
|
|
addChatMessage(game, session, `${session.name} now has the largest army (${player.army})!`)
|
|
}
|
|
}
|
|
|
|
game.turn.robberInAction = true;
|
|
delete game.turn.placedRobber;
|
|
addChatMessage(game, null, `The robber ${game.robberName} has fled before the power of the Knight, ` +
|
|
`but a new robber has returned and ${session.name} must now place them.`);
|
|
game.turn.actions = [ 'place-robber', 'playing-knight' ];
|
|
game.turn.limits = { pips: [] };
|
|
for (let i = 0; i < 19; i++) {
|
|
if (i === game.robber) {
|
|
continue;
|
|
}
|
|
game.turn.limits.pips.push(i);
|
|
}
|
|
}
|
|
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
chat: game.chat,
|
|
activities: game.activities,
|
|
largestArmy: game.largestArmy,
|
|
largestArmySize: game.largestArmySize,
|
|
turn: game.turn,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const placeSettlement = (game, session, index) => {
|
|
const player = session.player;
|
|
index = parseInt(index);
|
|
|
|
if (game.state !== 'initial-placement' && game.state !== 'normal') {
|
|
return `You cannot place a settlement unless the game is active (${game.state}).`;
|
|
}
|
|
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
|
|
/* index out of range... */
|
|
if (game.placements.corners[index] === undefined) {
|
|
return `You have requested to place a settlement illegally!`;
|
|
}
|
|
|
|
/* If this is not a valid road in the turn limits, discard it */
|
|
if (!game.turn
|
|
|| !game.turn.limits
|
|
|| !game.turn.limits.corners
|
|
|| game.turn.limits.corners.indexOf(index) === -1) {
|
|
return `You tried to cheat! You should not try to break the rules.`;
|
|
}
|
|
const corner = game.placements.corners[index];
|
|
if (corner.color) {
|
|
return `This location already has a settlement belonging to ${game.players[corner.color].name}!`;
|
|
}
|
|
|
|
if (!player.banks) {
|
|
player.banks = [];
|
|
}
|
|
|
|
if (game.state === 'normal') {
|
|
if (!game.turn.free) {
|
|
if (player.brick < 1 || player.wood < 1 || player.wheat < 1 || player.sheep < 1) {
|
|
return `You have insufficient resources to build a settlement.`;
|
|
}
|
|
}
|
|
|
|
if (player.settlements < 1) {
|
|
return `You have already built all of your settlements.`;
|
|
}
|
|
|
|
player.settlements--;
|
|
|
|
if (!game.turn.free) {
|
|
addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood, 1 sheep, 1 wheat to purchase a settlement.`)
|
|
player.brick--;
|
|
player.wood--;
|
|
player.wheat--;
|
|
player.sheep--;
|
|
player.resources = 0;
|
|
[ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => {
|
|
player.resources += player[resource];
|
|
});
|
|
}
|
|
delete game.turn.free;
|
|
|
|
corner.color = session.color;
|
|
corner.type = 'settlement';
|
|
let bankType = undefined;
|
|
|
|
if (layout.corners[index].banks.length) {
|
|
layout.corners[index].banks.forEach(bank => {
|
|
const border = game.borderOrder[Math.floor(bank / 3)],
|
|
type = game.borders[border][bank % 3];
|
|
console.log(`${session.id}: Bank ${bank} = ${type}`);
|
|
if (!type) {
|
|
console.log(`${session.id}: Bank ${bank}`)
|
|
return;
|
|
}
|
|
bankType = (type === 'bank')
|
|
? '3 of anything for 1 resource'
|
|
: `2 ${type} for 1 resource`;
|
|
if (player.banks.indexOf(type) === -1) {
|
|
player.banks.push(type);
|
|
}
|
|
|
|
player.ports++;
|
|
|
|
if (isRuleEnabled(game, 'port-of-call')) {
|
|
console.log(`Checking port-of-call`, player.ports, game.mostPorts);
|
|
if (player.ports >= 3
|
|
&& (!game.mostPorts
|
|
|| player.ports > game.mostPortCount)) {
|
|
if (game.mostPorts !== session.color) {
|
|
game.mostPorts = session.color;
|
|
game.mostPortCount = player.ports;
|
|
addChatMessage(game, session, `${session.name} now has the most ports (${player.ports})!`)
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
game.turn.actions = [];
|
|
game.turn.limits = {};
|
|
if (bankType) {
|
|
addActivity(game, session,
|
|
`${session.name} placed a settlement by a maritime bank that trades ${bankType}.`);
|
|
} else {
|
|
addActivity(game, session, `${session.name} placed a settlement.`);
|
|
}
|
|
calculateRoadLengths(game, session);
|
|
} else if (game.state === 'initial-placement') {
|
|
if (game.direction && game.direction === 'backward') {
|
|
session.initialSettlement = index;
|
|
}
|
|
corner.color = session.color;
|
|
corner.type = 'settlement';
|
|
let bankType = undefined;
|
|
if (layout.corners[index].banks.length) {
|
|
layout.corners[index].banks.forEach(bank => {
|
|
const border = game.borderOrder[Math.floor(bank / 3)],
|
|
type = game.borders[border][bank % 3];
|
|
console.log(`${session.id}: Bank ${bank} = ${type}`);
|
|
if (!type) {
|
|
return;
|
|
}
|
|
bankType = (type === 'bank')
|
|
? '3 of anything for 1 resource'
|
|
: `2 ${type} for 1 resource`;
|
|
if (player.banks.indexOf(type) === -1) {
|
|
player.banks.push(type);
|
|
}
|
|
player.ports++;
|
|
});
|
|
}
|
|
player.settlements--;
|
|
if (bankType) {
|
|
addActivity(game, session,
|
|
`${session.name} placed a settlement by a maritime bank that trades ${bankType}. ` +
|
|
`Next, they need to place a road.`);
|
|
} else {
|
|
addActivity(game, session, `${session.name} placed a settlement. ` +
|
|
`Next, they need to place a road.`);
|
|
}
|
|
setForRoadPlacement(game, layout.corners[index].roads);
|
|
}
|
|
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
placements: game.placements,
|
|
activities: game.activities,
|
|
mostPorts: game.mostPorts,
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const placeRoad = (game, session, index) => {
|
|
const player = session.player;
|
|
index = parseInt(index);
|
|
if (game.state !== 'initial-placement' && game.state !== 'normal') {
|
|
return `You cannot purchase a place a road unless the game is active (${game.state}).`;
|
|
}
|
|
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
|
|
/* Valid index location */
|
|
if (game.placements.roads[index] === undefined) {
|
|
return `You have requested to place a road illegally!`;
|
|
}
|
|
|
|
/* If this is not a valid road in the turn limits, discard it */
|
|
if (!game.turn
|
|
|| !game.turn.limits
|
|
|| !game.turn.limits.roads
|
|
|| game.turn.limits.roads.indexOf(index) === -1) {
|
|
return `You tried to cheat! You should not try to break the rules.`;
|
|
}
|
|
|
|
const road = game.placements.roads[index];
|
|
if (road.color) {
|
|
return `This location already has a road belonging to ${game.players[road.color].name}!`;
|
|
}
|
|
|
|
if (game.state === 'normal') {
|
|
if (!game.turn.free) {
|
|
if (player.brick < 1 || player.wood < 1) {
|
|
return `You have insufficient resources to build a road.`;
|
|
}
|
|
}
|
|
if (player.roads < 1) {
|
|
return `You have already built all of your roads.`;
|
|
}
|
|
|
|
debugChat(game, 'Before road purchase');
|
|
|
|
player.roads--;
|
|
if (!game.turn.free) {
|
|
addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood to purchase a road.`)
|
|
player.brick--;
|
|
player.wood--;
|
|
player.resources = 0;
|
|
[ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => {
|
|
player.resources += player[resource];
|
|
});
|
|
}
|
|
|
|
debugChat(game, 'After road purchase');
|
|
|
|
road.color = session.color;
|
|
addActivity(game, session, `${session.name} placed a road.`);
|
|
calculateRoadLengths(game, session);
|
|
|
|
let resetLimits = true;
|
|
if (game.turn.active === 'road-building') {
|
|
game.turn.freeRoads--;
|
|
if (game.turn.freeRoads === 0) {
|
|
delete game.turn.free;
|
|
delete game.turn.active;
|
|
delete game.turn.freeRaods;
|
|
}
|
|
|
|
let roads = getValidRoads(game, session.color);
|
|
if (roads.length === 0) {
|
|
delete game.turn.active;
|
|
delete game.turn.freeRaods;
|
|
addActivity(game, session, `${session.name} has another road to play, but there are no more valid locations.`);
|
|
} else if (game.turn.freeRoads !== 0) {
|
|
game.turn.free = true;
|
|
setForRoadPlacement(game, roads);
|
|
resetLimits = false;
|
|
}
|
|
}
|
|
|
|
if (resetLimits) {
|
|
delete game.turn.free;
|
|
game.turn.actions = [];
|
|
game.turn.limits = {};
|
|
}
|
|
} else if (game.state === 'initial-placement') {
|
|
road.color = session.color;
|
|
addActivity(game, session, `${session.name} placed a road.`);
|
|
calculateRoadLengths(game, session);
|
|
|
|
let next;
|
|
if (game.direction === 'forward' && getLastPlayerName(game) === session.name) {
|
|
game.direction = 'backward';
|
|
next = session.player;
|
|
} else if (game.direction === 'backward' && getFirstPlayerName(game) === session.name) {
|
|
/* Done! */
|
|
delete game.direction;
|
|
} else {
|
|
if (game.direction === 'forward') {
|
|
next = getNextPlayerSession(game, session.name);
|
|
} else {
|
|
next = getPrevPlayerSession(game, session.name);
|
|
}
|
|
}
|
|
if (next) {
|
|
game.turn = {
|
|
name: next.name,
|
|
color: next.color
|
|
};
|
|
startTurnTimer(game, next);
|
|
setForSettlementPlacement(game, getValidCorners(game));
|
|
calculateRoadLengths(game, session);
|
|
addChatMessage(game, null, `It is ${next.name}'s turn to place a settlement.`);
|
|
} else {
|
|
game.turn = {
|
|
actions: [],
|
|
limits: { },
|
|
name: session.name,
|
|
color: getColorFromName(game, session.name)
|
|
};
|
|
session.player.turnStart = Date.now();
|
|
|
|
addChatMessage(game, null, `Everyone has placed their two settlements!`);
|
|
|
|
/* Figure out which players received which resources */
|
|
for (let id in game.sessions) {
|
|
const session = game.sessions[id], player = session.player,
|
|
receives = {};
|
|
if (!player) {
|
|
continue;
|
|
}
|
|
if (session.initialSettlement) {
|
|
layout.tiles.forEach((tile, index) => {
|
|
if (tile.corners.indexOf(session.initialSettlement) !== -1) {
|
|
const resource = staticData.tiles[game.tileOrder[index]].type;
|
|
if (!(resource in receives)) {
|
|
receives[resource] = 0;
|
|
}
|
|
receives[resource]++;
|
|
}
|
|
});
|
|
let message = [];
|
|
for (let type in receives) {
|
|
player[type] += receives[type];
|
|
player.resources += receives[type];
|
|
sendUpdateToPlayer(game, session, {
|
|
private: player
|
|
});
|
|
message.push(`${receives[type]} ${type}`);
|
|
}
|
|
addChatMessage(game, session, `${session.name} receives ${message.join(', ')} for initial settlement placement.`);
|
|
}
|
|
}
|
|
addChatMessage(game, null, `It is ${session.name}'s turn.`);
|
|
game.state = 'normal';
|
|
}
|
|
}
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
placements: game.placements,
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
state: game.state,
|
|
longestRoad: game.longestRoad,
|
|
longestRoadLength: game.longestRoadLength,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const getVictoryPointRule = (game) => {
|
|
const minVP = 10;
|
|
if (!isRuleEnabled(game, 'victory-points')
|
|
|| !('points' in game.rules['victory-points'])) {
|
|
return minVP;
|
|
}
|
|
return game.rules['victory-points'].points;
|
|
}
|
|
|
|
const supportedRules = {
|
|
'victory-points': (game, session, rule, rules) => {
|
|
if (!('points' in rules[rule])) {
|
|
return `No points specified for victory-points`;
|
|
}
|
|
if (!rules[rule].enabled) {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has disabled the Victory Point ` +
|
|
`house rule.`);
|
|
} else {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} set the minimum Victory Points to ` +
|
|
`${rules[rule].points}`);
|
|
}
|
|
},
|
|
'roll-double-roll-again': (game, session, rule, rules) => {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Roll Double, Roll Again house rule.`);
|
|
},
|
|
'volcano': (game, session, rule, rules) => {
|
|
if (!rules[rule].enabled) {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has disabled the Volcano ` +
|
|
`house rule.`);
|
|
} else {
|
|
if (!(rule in game.rules) || !game.rules[rule].enabled) {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} enabled the Volcano ` +
|
|
`house rule with roll set to ` +
|
|
`${rules[rule].number} and 'Volanoes have gold' mode ` +
|
|
`${rules[rule].gold ? 'en' : 'dis'}abled.`);
|
|
} else {
|
|
if (game.rules[rule].number !== rules[rule].number) {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} set the Volcano roll to ` +
|
|
`${rules[rule].number}`);
|
|
}
|
|
|
|
if (game.rules[rule].gold !== rules[rule].gold) {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ` +
|
|
`${rules[rule].gold ? 'en' : 'dis'}abled the ` +
|
|
`'Volcanoes have gold' mode.`);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
'twelve-and-two-are-synonyms': (game, session, rule, rules) => {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Twelve and Two are Synonyms house rule.`);
|
|
game.rules[rule] = rules[rule];
|
|
},
|
|
'most-developed': (game, session, rule, rules) => {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Most Developed house rule.`);
|
|
},
|
|
'port-of-call': (game, session, rule, rules) => {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Another Round of Port house rule.`);
|
|
},
|
|
'slowest-turn': (game, session, rule, rules) => {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Slowest Turn house rule.`);
|
|
},
|
|
'tiles-start-facing-down': (game, session, rule, rules) => {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Tiles Start Facing Down house rule.`);
|
|
if (rules[rule].enabled) {
|
|
shuffle(game, session);
|
|
}
|
|
},
|
|
'robin-hood-robber': (game, session, rule, rules) => {
|
|
addChatMessage(game, null,
|
|
`${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Robin Hood Robber house rule.`);
|
|
}
|
|
};
|
|
|
|
const setRules = (game, session, rules) => {
|
|
if (game.state !== 'lobby') {
|
|
return `You can not modify House Rules once the game has started.`;
|
|
}
|
|
|
|
for (let rule in rules) {
|
|
if (equal(game.rules[rule], rules[rule])) {
|
|
continue;
|
|
}
|
|
|
|
if (rule in supportedRules) {
|
|
const warning = supportedRules[rule](game, session, rule, rules);
|
|
if (warning) {
|
|
return warning;
|
|
}
|
|
game.rules[rule] = rules[rule];
|
|
} else {
|
|
return `Rule ${rule} not recognized.`;
|
|
}
|
|
}
|
|
|
|
sendUpdateToPlayers(game, {
|
|
rules: game.rules,
|
|
chat: game.chat
|
|
});
|
|
};
|
|
|
|
const discard = (game, session, discards) => {
|
|
const player = session.player;
|
|
|
|
if (game.turn.roll !== 7) {
|
|
return `You can only discard due to the Robber!`;
|
|
}
|
|
let sum = 0;
|
|
for (let type in discards) {
|
|
if (player[type] < parseInt(discards[type])) {
|
|
return `You have requested to discard more ${type} than you have.`
|
|
}
|
|
sum += parseInt(discards[type]);
|
|
}
|
|
if (sum > player.mustDiscard) {
|
|
return `You can not discard that many cards! You can only discard ${player.mustDiscard}.`;
|
|
}
|
|
|
|
if (sum === 0) {
|
|
return `You must discard at least one card.`;
|
|
}
|
|
|
|
for (let type in discards) {
|
|
const count = parseInt(discards[type]);
|
|
player[type] -= count;
|
|
player.mustDiscard -= count;
|
|
player.resources -= count;
|
|
}
|
|
addChatMessage(game, null, `${session.name} discarded ${sum} resource cards.`);
|
|
if (player.mustDiscard > 0) {
|
|
addChatMessage(game, null, `${session.name} did not discard enough and must discard ${player.mustDiscard} more cards.`);
|
|
}
|
|
|
|
let move = true;
|
|
for (let color in game.players) {
|
|
const discard = game.players[color].mustDiscard > 0;
|
|
if (discard) {
|
|
move = false;
|
|
}
|
|
}
|
|
|
|
if (move) {
|
|
addChatMessage(game, null, `Drat! A new robber has arrived and must be placed by ${game.turn.name}!`);
|
|
game.turn.actions = [ 'place-robber' ];
|
|
game.turn.limits = { pips: [] };
|
|
for (let i = 0; i < 19; i++) {
|
|
if (i === game.robber) {
|
|
continue;
|
|
}
|
|
game.turn.limits.pips.push(i);
|
|
}
|
|
}
|
|
sendUpdateToPlayer(game, session, {
|
|
private: player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
players: getFilteredPlayers(game),
|
|
chat: game.chat,
|
|
turn: game.turn
|
|
});
|
|
}
|
|
|
|
const buyRoad = (game, session) => {
|
|
const player = session.player;
|
|
|
|
if (game.state !== 'normal') {
|
|
return `You cannot purchase a development card unless the game is active (${game.state}).`;
|
|
}
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
if (!game.turn.roll) {
|
|
return `You cannot build until you have rolled.`;
|
|
}
|
|
|
|
if (game.turn && game.turn.robberInAction) {
|
|
return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
|
|
}
|
|
|
|
if (player.brick < 1 || player.wood < 1) {
|
|
return `You have insufficient resources to build a road.`;
|
|
}
|
|
if (player.roads < 1) {
|
|
return `You have already built all of your roads.`;
|
|
}
|
|
const roads = getValidRoads(game, session.color);
|
|
if (roads.length === 0) {
|
|
return `There are no valid locations for you to place a road.`;
|
|
}
|
|
setForRoadPlacement(game, roads);
|
|
addActivity(game, session, `${game.turn.name} is considering building a road.`);
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities
|
|
});
|
|
}
|
|
|
|
const selectResources = (game, session, cards) => {
|
|
const player = session.player;
|
|
if (!game || !game.turn || !game.turn.actions ||
|
|
game.turn.actions.indexOf('select-resources') === -1) {
|
|
return `Please, let's not cheat. Ok?`;
|
|
}
|
|
|
|
if ((session.color !== game.turn.color)
|
|
&& (!game.turn.select || !(session.color in game.turn.select))) {
|
|
console.log(session.color, game.turn.color, game.turn.select);
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
|
|
let count = 2;
|
|
if (game.turn && game.turn.active === 'monopoly') {
|
|
count = 1;
|
|
}
|
|
if (game.state === 'volcano') {
|
|
console.log({ cards, turn: game.turn });
|
|
if (!game.turn.select) {
|
|
count = 0;
|
|
} else if (session.color in game.turn.select) {
|
|
count = game.turn.select[session.color];
|
|
delete game.turn.select[session.color];
|
|
if (Object.getOwnPropertyNames(game.turn.select).length === 0) {
|
|
addChatMessage(game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!`);
|
|
delete game.turn.select;
|
|
}
|
|
} else {
|
|
count = 0;
|
|
}
|
|
}
|
|
|
|
if (!cards || cards.length > count || cards.length === 0) {
|
|
return `You have chosen the wrong number of cards!`;
|
|
}
|
|
|
|
const isValidCard = (type) => {
|
|
switch (type.trim()) {
|
|
case 'wheat':
|
|
case 'brick':
|
|
case 'sheep':
|
|
case 'stone':
|
|
case 'wood':
|
|
return true;
|
|
default:
|
|
return false;
|
|
};
|
|
}
|
|
|
|
const selected = {};
|
|
cards.forEach(card => {
|
|
if (!isValidCard(card)) {
|
|
return `Invalid resource type!`;
|
|
}
|
|
if (card in selected) {
|
|
selected[card]++;
|
|
} else {
|
|
selected[card] = 1;
|
|
}
|
|
});
|
|
const display = [];
|
|
for (let card in selected) {
|
|
display.push(`${selected[card]} ${card}`);
|
|
}
|
|
|
|
switch (game.turn.active) {
|
|
case 'monopoly':
|
|
const gave = [], type = cards[0];
|
|
let total = 0;
|
|
for (let color in game.players) {
|
|
const player = game.players[color];
|
|
if (player.status === 'Not active') {
|
|
continue
|
|
}
|
|
if (color === session.color) {
|
|
continue;
|
|
}
|
|
if (player[type]) {
|
|
gave.push(`${player.name} gave ${player[type]} ${type}`);
|
|
session.player[type] += player[type];
|
|
session.resources += player[type];
|
|
total += player[type];
|
|
player[type] = 0;
|
|
for (let key in game.sessions) {
|
|
if (game.sessions[key].player === player) {
|
|
sendUpdateToPlayer(game, game.sessions[key], {
|
|
private: game.sessions[key].player
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (gave.length) {
|
|
addChatMessage(game, session, `${session.name} played Monopoly and selected ${display.join(', ')}. ` +
|
|
`Players ${gave.join(', ')}. In total, they received ${total} ${type}.`);
|
|
} else {
|
|
|
|
addActivity(game, session, `${session.name} has chosen ${display.join(', ')}! Unfortunately, no players had that resource. Wa-waaaa.`);
|
|
}
|
|
delete game.turn.active;
|
|
game.turn.actions = [];
|
|
break;
|
|
|
|
case 'year-of-plenty':
|
|
cards.forEach(type => {
|
|
session.player[type]++;
|
|
session.player.resources++;
|
|
});
|
|
addChatMessage(game, session, `${session.name} player Year of Plenty.` +
|
|
`They chose to receive ${display.join(', ')} from the bank.`);
|
|
delete game.turn.active;
|
|
game.turn.actions = [];
|
|
break;
|
|
case 'volcano':
|
|
cards.forEach(type => {
|
|
session.player[type]++;
|
|
session.player.resources++;
|
|
});
|
|
addChatMessage(game, session, `${session.name} player mined ${display.join(', ')} from the Volcano!`);
|
|
if (!game.turn.select) {
|
|
delete game.turn.active;
|
|
game.turn.actions = [];
|
|
}
|
|
break;
|
|
}
|
|
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const buySettlement = (game, session) => {
|
|
const player = session.player;
|
|
if (game.state !== 'normal') {
|
|
return `You cannot purchase a development card unless the game is active (${game.state}).`;
|
|
}
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
if (!game.turn.roll) {
|
|
return `You cannot build until you have rolled.`;
|
|
}
|
|
|
|
if (game.turn && game.turn.robberInAction) {
|
|
return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
|
|
}
|
|
|
|
if (player.brick < 1 || player.wood < 1 || player.wheat < 1 || player.sheep < 1) {
|
|
return `You have insufficient resources to build a settlement.`;
|
|
}
|
|
if (player.settlements < 1) {
|
|
return `You have already built all of your settlements.`;
|
|
}
|
|
const corners = getValidCorners(game, session.color);
|
|
if (corners.length === 0) {
|
|
return `There are no valid locations for you to place a settlement.`;
|
|
}
|
|
setForSettlementPlacement(game, corners);
|
|
addActivity(game, session, `${game.turn.name} is considering placing a settlement.`);
|
|
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities
|
|
});
|
|
}
|
|
|
|
const buyCity = (game, session) => {
|
|
const player = session.player;
|
|
if (game.state !== 'normal') {
|
|
return `You cannot purchase a development card unless the game is active (${game.state}).`;
|
|
}
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
if (!game.turn.roll) {
|
|
return `You cannot build until you have rolled.`;
|
|
}
|
|
if (player.wheat < 2 || player.stone < 3) {
|
|
return `You have insufficient resources to build a city.`;
|
|
}
|
|
|
|
if (game.turn && game.turn.robberInAction) {
|
|
return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
|
|
}
|
|
|
|
if (player.city < 1) {
|
|
return `You have already built all of your cities.`;
|
|
}
|
|
const corners = getValidCorners(game, session.color, 'settlement');
|
|
if (corners.length === 0) {
|
|
return `There are no valid locations for you to place a city.`;
|
|
}
|
|
setForCityPlacement(game, corners);
|
|
addActivity(game, session, `${game.turn.name} is considering upgrading a settlement to a city.`);
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities
|
|
});
|
|
}
|
|
|
|
const placeCity = (game, session, index) => {
|
|
const player = session.player;
|
|
index = parseInt(index);
|
|
if (game.state !== 'normal') {
|
|
return `You cannot purchase a development card unless the game is active (${game.state}).`;
|
|
}
|
|
if (session.color !== game.turn.color) {
|
|
return `It is not your turn! It is ${game.turn.name}'s turn.`;
|
|
}
|
|
/* Valid index check */
|
|
if (game.placements.corners[index] === undefined) {
|
|
return `You have requested to place a city illegally!`;
|
|
}
|
|
/* If this is not a placement the turn limits, discard it */
|
|
if (!game.turn
|
|
|| !game.turn.limits
|
|
|| !game.turn.limits.corners
|
|
|| game.turn.limits.corners.indexOf(index) === -1) {
|
|
return `You tried to cheat! You should not try to break the rules.`;
|
|
}
|
|
const corner = game.placements.corners[index];
|
|
if (corner.color !== session.color) {
|
|
return `This location already has a settlement belonging to ${game.players[corner.color].name}!`;
|
|
}
|
|
if (corner.type !== 'settlement') {
|
|
return `This location already has a city!`;
|
|
}
|
|
if (!game.turn.free) {
|
|
if (player.wheat < 2 || player.stone < 3) {
|
|
return `You have insufficient resources to build a city.`;
|
|
}
|
|
}
|
|
if (player.city < 1) {
|
|
return `You have already built all of your cities.`;
|
|
}
|
|
|
|
corner.color = session.color;
|
|
corner.type = 'city';
|
|
debugChat(game, 'Before city purchase');
|
|
|
|
player.cities--;
|
|
player.settlements++;
|
|
if (!game.turn.free) {
|
|
addChatMessage(game, session, `${session.name} spent 2 wheat, 3 stone to upgrade to a city.`)
|
|
player.wheat -= 2;
|
|
player.stone -= 3;
|
|
player.resources = 0;
|
|
[ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => {
|
|
player.resources += player[resource];
|
|
});
|
|
}
|
|
delete game.turn.free;
|
|
|
|
debugChat(game, 'After city purchase');
|
|
game.turn.actions = [];
|
|
game.turn.limits = {};
|
|
addActivity(game, session, `${session.name} upgraded a settlement to a city!`);
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player
|
|
});
|
|
sendUpdateToPlayers(game, {
|
|
placements: game.placements,
|
|
turn: game.turn,
|
|
chat: game.chat,
|
|
activities: game.activities,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
|
|
const ping = (session) => {
|
|
if (!session.ws) {
|
|
console.log(`[no socket] Not sending ping to ${session.name} -- connection does not exist.`);
|
|
return;
|
|
}
|
|
|
|
session.ping = Date.now();
|
|
// console.log(`Sending ping to ${session.name}`);
|
|
session.ws.send(JSON.stringify({ type: 'ping', ping: session.ping }));
|
|
if (session.keepAlive) {
|
|
clearTimeout(session.keepAlive);
|
|
}
|
|
session.keepAlive = setTimeout(() => { ping(session); }, 2500);
|
|
}
|
|
|
|
const wsInactive = (game, req) => {
|
|
const session = getSession(game, req.cookies.player);
|
|
|
|
if (session && session.ws) {
|
|
console.log(`Closing WebSocket to ${session.name} due to inactivity.`);
|
|
try {
|
|
// Defensive: close only if a socket exists; swallow any errors from closing
|
|
if (session.ws) {
|
|
try { session.ws.close(); } catch (e) { /* ignore close errors */ }
|
|
}
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
session.ws = undefined;
|
|
}
|
|
|
|
/* Prevent future pings */
|
|
if (req.keepAlive) {
|
|
clearTimeout(req.keepAlive);
|
|
}
|
|
}
|
|
|
|
const setGameState = (game, session, state) => {
|
|
if (!state) {
|
|
return `Invalid state.`;
|
|
}
|
|
|
|
if (!session.color) {
|
|
return `You must have an active player to start the game.`;
|
|
}
|
|
|
|
if (state === game.state) {
|
|
return;
|
|
}
|
|
|
|
switch (state) {
|
|
case "game-order":
|
|
if (game.state !== 'lobby') {
|
|
return `You can only start the game from the lobby.`;
|
|
}
|
|
const active = getActiveCount(game);
|
|
if (active < 2) {
|
|
return `You need at least two players to start the game.`;
|
|
}
|
|
/* Delete any non-played colors from the player map; reduces all
|
|
* code that would otherwise have to filter out players by checking
|
|
* the 'Not active' state of player.status */
|
|
for (let key in game.players) {
|
|
if (game.players[key].status !== 'Active') {
|
|
delete game.players[key];
|
|
}
|
|
}
|
|
addChatMessage(game, null, `${session.name} requested to start the game.`);
|
|
game.state = state;
|
|
|
|
sendUpdateToPlayers(game, {
|
|
state: game.state,
|
|
chat: game.chat
|
|
});
|
|
break;
|
|
}
|
|
|
|
}
|
|
|
|
const resetDisconnectCheck = (game, req) => {
|
|
if (req.disconnectCheck) {
|
|
clearTimeout(req.disconnectCheck);
|
|
}
|
|
//req.disconnectCheck = setTimeout(() => { wsInactive(game, req) }, 20000);
|
|
}
|
|
|
|
const join = (peers, session, { hasVideo, hasAudio }) => {
|
|
const ws = session.ws;
|
|
|
|
if (!session.name) {
|
|
console.error(`${session.id}: <- join - No name set yet. Audio not available.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${session.id}: <- join - ${session.name}`);
|
|
console.log(`${all}: -> addPeer - ${session.name}`);
|
|
|
|
if (session.name in peers) {
|
|
console.log(`${session.id}:${session.name} - Already joined to Audio.`);
|
|
return;
|
|
}
|
|
|
|
for (let peer in peers) {
|
|
/* Add this caller to all peers */
|
|
peers[peer].ws.send(JSON.stringify({
|
|
type: 'addPeer',
|
|
data: {
|
|
peer_id: session.name,
|
|
should_create_offer: false,
|
|
hasAudio, hasVideo
|
|
}
|
|
}));
|
|
|
|
/* Add each other peer to the caller */
|
|
ws.send(JSON.stringify({
|
|
type: 'addPeer',
|
|
data: {
|
|
peer_id: peer,
|
|
should_create_offer: true,
|
|
hasAudio: peers[peer].hasAudio,
|
|
hasVideo: peers[peer].hasVideo
|
|
}
|
|
}));
|
|
}
|
|
|
|
/* Add this user as a peer connected to this WebSocket */
|
|
peers[session.name] = {
|
|
ws,
|
|
hasAudio,
|
|
hasVideo
|
|
};
|
|
};
|
|
|
|
const part = (peers, session) => {
|
|
const ws = session.ws;
|
|
|
|
if (!session.name) {
|
|
console.error(`${session.id}: <- part - No name set yet. Audio not available.`);
|
|
return;
|
|
}
|
|
|
|
if (!(session.name in peers)) {
|
|
console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${session.id}: <- ${session.name} - Audio part.`);
|
|
console.log(`${all}: -> removePeer - ${session.name}`);
|
|
|
|
delete peers[session.name];
|
|
|
|
/* Remove this peer from all other peers, and remove each
|
|
* peer from this peer */
|
|
for (let peer in peers) {
|
|
peers[peer].ws.send(JSON.stringify({
|
|
type: 'removePeer',
|
|
data: {'peer_id': session.name}
|
|
}));
|
|
ws.send(JSON.stringify({
|
|
type: 'removePeer',
|
|
data: {'peer_id': session.name}
|
|
}));
|
|
}
|
|
};
|
|
|
|
|
|
const getName = (session) => {
|
|
return session ? (session.name ? session.name : session.id) : 'Admin';
|
|
}
|
|
|
|
const saveGame = async (game) => {
|
|
/* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then
|
|
* delete the player field from them */
|
|
const reducedGame = Object.assign({}, game, { sessions: {} }),
|
|
reducedSessions = [];
|
|
|
|
for (let id in game.sessions) {
|
|
const reduced = Object.assign({}, game.sessions[id]);
|
|
// Remove private or non-serializable fields from the session copy
|
|
if (reduced.player) delete reduced.player;
|
|
if (reduced.ws) delete reduced.ws;
|
|
if (reduced.keepAlive) delete reduced.keepAlive;
|
|
// Remove any internal helper fields (prefixed with '_') and any
|
|
// non-primitive values such as functions or timers which may cause
|
|
// JSON.stringify to throw due to circular structures.
|
|
Object.keys(reduced).forEach((k) => {
|
|
if (k.startsWith('_')) {
|
|
delete reduced[k];
|
|
} else if (typeof reduced[k] === 'function') {
|
|
delete reduced[k];
|
|
}
|
|
});
|
|
// Do not persist ephemeral test/runtime-only flags
|
|
if (reduced._initialSnapshotSent) {
|
|
delete reduced._initialSnapshotSent;
|
|
}
|
|
|
|
reducedGame.sessions[id] = reduced;
|
|
|
|
/* Do not send session-id as those are secrets */
|
|
reducedSessions.push(reduced);
|
|
}
|
|
|
|
delete reducedGame.turnTimer;
|
|
delete reducedGame.unselected;
|
|
|
|
/* Save per turn while debugging... */
|
|
game.step = game.step ? game.step : 0;
|
|
/*
|
|
await writeFile(`/db/games/${game.id}.${game.step++}`, JSON.stringify(reducedGame, null, 2))
|
|
.catch((error) => {
|
|
console.error(`${session.id} Unable to write to /db/games/${game.id}`);
|
|
console.error(error);
|
|
});
|
|
*/
|
|
await mkdir('/db/games', { recursive: true });
|
|
await writeFile(`/db/games/${game.id}`, JSON.stringify(reducedGame, null, 2))
|
|
.catch((error) => {
|
|
console.error(`Unable to write to /db/games/${game.id}`);
|
|
console.error(error);
|
|
});
|
|
}
|
|
|
|
const departLobby = (game, session, color) => {
|
|
const update = {};
|
|
update.unselected = getFilteredUnselected(game);
|
|
|
|
if (session.player) {
|
|
session.player.live = false;
|
|
update.players = game.players;
|
|
}
|
|
|
|
if (session.name) {
|
|
if (session.color) {
|
|
addChatMessage(game, null, `${session.name} has disconnected ` +
|
|
`from the game.`);
|
|
} else {
|
|
addChatMessage(game, null, `${session.name} has left the lobby.`);
|
|
}
|
|
update.chat = game.chat;
|
|
} else {
|
|
console.log(`${session.id}: departLobby - ${getName(session)} is ` +
|
|
`being removed from ${game.id}'s sessions.`);
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id] === session) {
|
|
delete game.sessions[id];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
sendUpdateToPlayers(game, update);
|
|
}
|
|
|
|
const all = `[ all ]`;
|
|
const info = `[ info ]`;
|
|
const todo = `[ todo ]`;
|
|
|
|
/* Per-session send throttle (milliseconds). Coalesce rapid updates to avoid
|
|
* tight send loops that can overwhelm clients. If multiple updates are
|
|
* enqueued within the throttle window, the latest one replaces prior pending
|
|
* updates so the client receives a single consolidated message. */
|
|
const SEND_THROTTLE_MS = 50;
|
|
// Batch incoming 'get' requests from a single websocket session so multiple
|
|
// rapid get requests (often caused by render churn) are combined into one
|
|
// response. This helps avoid processing and responding to many near-duplicate
|
|
// get messages during connection startup. Window in ms.
|
|
const INCOMING_GET_BATCH_MS = 20;
|
|
|
|
const queueSend = (session, message) => {
|
|
if (!session || !session.ws) return;
|
|
try {
|
|
// Ensure we compare a stable serialization: if message is JSON text,
|
|
// parse it and re-serialize with sorted keys so semantically-equal
|
|
// objects compare equal even when property order differs.
|
|
const stableStringify = (msg) => {
|
|
try {
|
|
const obj = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
|
const ordered = (v) => {
|
|
if (v === null || typeof v !== 'object') return v;
|
|
if (Array.isArray(v)) return v.map(ordered);
|
|
const keys = Object.keys(v).sort();
|
|
const out = {};
|
|
for (const k of keys) out[k] = ordered(v[k]);
|
|
return out;
|
|
};
|
|
return JSON.stringify(ordered(obj));
|
|
} catch (e) {
|
|
// If parsing fails, fall back to original string representation
|
|
return typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
}
|
|
};
|
|
const stableMessage = stableStringify(message);
|
|
const now = Date.now();
|
|
if (!session._lastSent) session._lastSent = 0;
|
|
const elapsed = now - session._lastSent;
|
|
// If the exact same message (in stable form) was sent last time and
|
|
// nothing is pending, skip sending to avoid pointless duplicate
|
|
// traffic.
|
|
if (!session._pendingTimeout && session._lastMessage === stableMessage) {
|
|
return;
|
|
}
|
|
|
|
// If we haven't sent recently and there's no pending timer, send now
|
|
if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) {
|
|
try {
|
|
session.ws.send(typeof message === 'string' ? message : JSON.stringify(message));
|
|
session._lastSent = Date.now();
|
|
session._lastMessage = stableMessage;
|
|
} catch (e) {
|
|
console.warn(`${session.id}: queueSend immediate send failed:`, e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Otherwise, store latest message and schedule a send
|
|
// If the pending message would equal the last-sent message, don't bother
|
|
// storing/scheduling it.
|
|
if (session._lastMessage === stableMessage) {
|
|
return;
|
|
}
|
|
session._pendingMessage = typeof message === 'string' ? message : JSON.stringify(message);
|
|
if (session._pendingTimeout) {
|
|
// already scheduled; newest message will be sent when timer fires
|
|
return;
|
|
}
|
|
const delay = Math.max(1, SEND_THROTTLE_MS - elapsed);
|
|
session._pendingTimeout = setTimeout(() => {
|
|
try {
|
|
if (session.ws && session._pendingMessage) {
|
|
session.ws.send(session._pendingMessage);
|
|
session._lastSent = Date.now();
|
|
// compute stable form of what we actually sent
|
|
try {
|
|
session._lastMessage = stableStringify(session._pendingMessage);
|
|
} catch (e) {
|
|
session._lastMessage = session._pendingMessage;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(`${session.id}: queueSend delayed send failed:`, e);
|
|
}
|
|
// clear pending fields
|
|
session._pendingMessage = undefined;
|
|
clearTimeout(session._pendingTimeout);
|
|
session._pendingTimeout = undefined;
|
|
}, delay);
|
|
} catch (e) {
|
|
console.warn(`${session.id}: queueSend exception:`, e);
|
|
}
|
|
};
|
|
|
|
const sendGameToPlayer = (game, session) => {
|
|
console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`);
|
|
if (!session.ws) {
|
|
console.log(`${session.id}: -> sendGamePlayer:: Currently no connection`);
|
|
return;
|
|
}
|
|
|
|
let update;
|
|
|
|
/* Only send empty name data to unnamed players */
|
|
if (!session.name) {
|
|
console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - only sending empty name`);
|
|
update = { name: "" };
|
|
} else {
|
|
update = getFilteredGameForPlayer(game, session);
|
|
}
|
|
|
|
const message = JSON.stringify({
|
|
type: 'game-update',
|
|
update: update
|
|
});
|
|
queueSend(session, message);
|
|
};
|
|
|
|
const sendGameToPlayers = (game) => {
|
|
console.log(`${all}: -> sendGamePlayers - full game`);
|
|
|
|
for (let key in game.sessions) {
|
|
sendGameToPlayer(game, game.sessions[key]);
|
|
}
|
|
};
|
|
|
|
const sendUpdateToPlayers = async (game, update) => {
|
|
/* Ensure clearing of a field actually gets sent by setting
|
|
* undefined to 'false'
|
|
*/
|
|
for (let key in update) {
|
|
if (update[key] === undefined) {
|
|
update[key] = false;
|
|
}
|
|
}
|
|
|
|
calculatePoints(game, update);
|
|
|
|
if (debug.update) {
|
|
console.log(`[ all ]: -> sendUpdateToPlayers - `, update);
|
|
} else {
|
|
const keys = Object.getOwnPropertyNames(update);
|
|
console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(',')}`);
|
|
}
|
|
|
|
const message = JSON.stringify({
|
|
type: 'game-update',
|
|
update
|
|
});
|
|
for (let key in game.sessions) {
|
|
const session = game.sessions[key];
|
|
/* Only send player and game data to named players */
|
|
if (!session.name) {
|
|
console.log(`${session.id}: -> sendUpdateToPlayers:` +
|
|
`${getName(session)} - only sending empty name`);
|
|
if (session.ws) {
|
|
session.ws.send(JSON.stringify({
|
|
type: 'game-update',
|
|
update: { name: "" }
|
|
}));
|
|
}
|
|
continue;
|
|
}
|
|
if (!session.ws) {
|
|
console.log(`${session.id}: -> sendUpdateToPlayers: ` +
|
|
`Currently no connection.`);
|
|
} else {
|
|
queueSend(session, message);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sendUpdateToPlayer = async (game, session, update) => {
|
|
/* If this player does not have a name, *ONLY* send the name, regardless
|
|
* of what is requested */
|
|
if (!session.name) {
|
|
console.log(`${session.id}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`);
|
|
update = { name: "" };
|
|
}
|
|
|
|
/* Ensure clearing of a field actually gets sent by setting
|
|
* undefined to 'false'
|
|
*/
|
|
for (let key in update) {
|
|
if (update[key] === undefined) {
|
|
update[key] = false;
|
|
}
|
|
}
|
|
|
|
calculatePoints(game, update);
|
|
|
|
if (debug.update) {
|
|
console.log(`${session.id}: -> sendUpdateToPlayer:${getName(session)} - `, update);
|
|
} else {
|
|
const keys = Object.getOwnPropertyNames(update);
|
|
console.log(`${session.id}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(',')}`);
|
|
}
|
|
|
|
const message = JSON.stringify({
|
|
type: 'game-update',
|
|
update
|
|
});
|
|
|
|
if (!session.ws) {
|
|
console.log(`${session.id}: -> sendUpdateToPlayer: ` +
|
|
`Currently no connection.`);
|
|
} else {
|
|
queueSend(session, message);
|
|
}
|
|
}
|
|
|
|
const getFilteredUnselected = (game) => {
|
|
if (!game.unselected) {
|
|
return [];
|
|
}
|
|
return game.unselected
|
|
.filter(session => session.live)
|
|
.map(session => session.name);
|
|
}
|
|
|
|
const parseChatCommands = (game, message) => {
|
|
/* Chat messages can set game flags and fields */
|
|
const parts = message.match(/^set +([^ ]*) +(.*)$/i);
|
|
if (!parts || parts.length !== 3) {
|
|
return;
|
|
}
|
|
switch (parts[1].toLowerCase()) {
|
|
case 'game':
|
|
if (parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) {
|
|
setBeginnerGame(game);
|
|
addChatMessage(game, session, `${session.name} set game board to the Beginner's Layout.`);
|
|
break;
|
|
}
|
|
const signature = parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i);
|
|
if (signature) {
|
|
if (setGameFromSignature(game, signature[1], signature[2], signature[3])) {
|
|
game.signature = parts[2];
|
|
addChatMessage(game, session, `${session.name} set game board to ${parts[2]}.`);
|
|
} else {
|
|
addChatMessage(game, session, `${session.name} requested an invalid game board.`);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
const sendError = (session, error) => {
|
|
session.ws.send(JSON.stringify({ type: 'error', error }));
|
|
}
|
|
|
|
const sendWarning = (session, warning) => {
|
|
session.ws.send(JSON.stringify({ type: 'warning', warning }));
|
|
}
|
|
|
|
const getFilteredPlayers = (game) => {
|
|
const filtered = {};
|
|
for (let color in game.players) {
|
|
const player = Object.assign({}, game.players[color]);
|
|
filtered[color] = player;
|
|
if (player.status === 'Not active') {
|
|
if (game.state !== 'lobby') {
|
|
delete filtered[color];
|
|
}
|
|
continue;
|
|
}
|
|
player.resources = 0;
|
|
[ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => {
|
|
player.resources += player[resource];
|
|
delete player[resource];
|
|
});
|
|
delete player.development;
|
|
}
|
|
return filtered;
|
|
};
|
|
|
|
const calculatePoints = (game, update) => {
|
|
if (game.state === 'winner') {
|
|
return;
|
|
}
|
|
/* Calculate points and determine if there is a winner */
|
|
for (let key in game.players) {
|
|
const player = game.players[key];
|
|
if (player.status === 'Not active') {
|
|
continue;
|
|
}
|
|
const currentPoints = player.points;
|
|
|
|
player.points = 0;
|
|
if (key === game.longestRoad) {
|
|
player.points += 2;
|
|
}
|
|
if (key === game.largestArmy) {
|
|
player.points += 2;
|
|
}
|
|
if (key === game.mostPorts) {
|
|
player.points += 2;
|
|
}
|
|
if (key === game.mostDeveloped) {
|
|
player.points += 2;
|
|
}
|
|
player.points += MAX_SETTLEMENTS - player.settlements;
|
|
player.points += 2 * (MAX_CITIES - player.cities);
|
|
|
|
player.unplayed = 0;
|
|
player.potential = 0;
|
|
player.development.forEach(card => {
|
|
if (card.type === 'vp') {
|
|
if (card.played) {
|
|
player.points++;
|
|
} else {
|
|
player.potential++;
|
|
}
|
|
}
|
|
if (!card.played) {
|
|
player.unplayed++;
|
|
}
|
|
});
|
|
|
|
if (player.points === currentPoints) {
|
|
continue;
|
|
}
|
|
|
|
if (player.points < getVictoryPointRule(game)) {
|
|
update.players = getFilteredPlayers(game);
|
|
continue;
|
|
}
|
|
|
|
/* This player has enough points! Check if they are the current
|
|
* player and if so, declare victory! */
|
|
console.log(`${info}: Whoa! ${player.name} has ${player.points}!`);
|
|
for (let key in game.sessions) {
|
|
if (game.sessions[key].color !== player.color
|
|
|| game.sessions[key].status === 'Not active') {
|
|
continue;
|
|
}
|
|
const message = `Wahoo! ${player.name} has ${player.points} ` +
|
|
`points on their turn and has won!`;
|
|
addChatMessage(game, null, message)
|
|
console.log(`${info}: ${message}`);
|
|
update.winner = Object.assign({}, player, {
|
|
state: 'winner',
|
|
stolen: game.stolen,
|
|
chat: game.chat,
|
|
turns: game.turns,
|
|
players: game.players,
|
|
elapsedTime: Date.now() - game.startTime
|
|
});
|
|
game.winner = update.winner;
|
|
game.state = 'winner';
|
|
game.waiting = [];
|
|
stopTurnTimer(game);
|
|
sendUpdateToPlayers(game, {
|
|
state: game.state,
|
|
winner: game.winner,
|
|
players: game.players /* unfiltered */
|
|
});
|
|
}
|
|
}
|
|
|
|
/* If the game isn't in a win state, do not share development card information
|
|
* with other players */
|
|
if (game.state !== 'winner') {
|
|
for (let key in game.players) {
|
|
const player = game.players[key];
|
|
if (player.status === 'Not active') {
|
|
continue;
|
|
}
|
|
delete player.potential;
|
|
}
|
|
}
|
|
}
|
|
|
|
const clearGame = (game, session) => {
|
|
resetGame(game);
|
|
addChatMessage(game, null,
|
|
`The game has been reset. You can play again with this board, or ` +
|
|
`click 'New Table' to mix things up a bit.`);
|
|
sendGameToPlayers(game);
|
|
};
|
|
|
|
const gotoLobby = (game, session) => {
|
|
if (!game.waiting) {
|
|
game.waiting = [];
|
|
}
|
|
const already = game.waiting.indexOf(session.name) !== -1;
|
|
|
|
const waitingFor = [];
|
|
for (let key in game.sessions) {
|
|
if (game.sessions[key] === session) {
|
|
continue;
|
|
}
|
|
|
|
if (game.sessions[key].player && game.waiting.indexOf(game.sessions[key].name) == -1) {
|
|
waitingFor.push(game.sessions[key].name);
|
|
}
|
|
}
|
|
|
|
if (!already) {
|
|
game.waiting.push(session.name);
|
|
addChatMessage(game, null, `${session.name} has gone to the lobby.`);
|
|
} else if (waitingFor.length !== 0) {
|
|
return `You are already waiting in the lobby. ` +
|
|
`${waitingFor.join(',')} still needs to go to the lobby.`;
|
|
}
|
|
|
|
if (waitingFor.length === 0) {
|
|
resetGame(game);
|
|
addChatMessage(game, null, `All players are back to the lobby.`);
|
|
addChatMessage(game, null,
|
|
`The game has been reset. You can play again with this board, or `+
|
|
`click 'New Table' to mix things up a bit.`);
|
|
sendGameToPlayers(game);
|
|
return;
|
|
}
|
|
|
|
addChatMessage(game, null, `Waiting for ${waitingFor.join(',')} to go to lobby.`);
|
|
sendUpdateToPlayers(game, {
|
|
chat: game.chat
|
|
});
|
|
}
|
|
|
|
router.ws("/ws/:id", async (ws, req) => {
|
|
if (!req.cookies || !req.cookies.player) {
|
|
// If the client hasn't established a session cookie, they cannot
|
|
// participate in a websocket-backed game session. Log the request
|
|
// headers to aid debugging (e.g. missing Cookie header due to
|
|
// cross-site requests or proxy configuration) and close the socket
|
|
// with a sensible code so the client sees a deterministic close.
|
|
try {
|
|
const remote = req.ip || (req.headers && (req.headers['x-forwarded-for'] || req.connection && req.connection.remoteAddress)) || 'unknown';
|
|
console.warn(`[ws] Rejecting connection from ${remote} - missing session cookie. headers=${JSON.stringify(req.headers || {})}`);
|
|
} catch (e) {
|
|
console.warn('[ws] Rejecting connection - missing session cookie (unable to serialize headers)');
|
|
}
|
|
try {
|
|
// Inform the client why we are closing, then close the socket.
|
|
ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` }));
|
|
} catch (e) {
|
|
/* ignore send errors */
|
|
}
|
|
try {
|
|
// 1008 = Policy Violation - appropriate for missing auth cookie
|
|
ws.close && ws.close(1008, 'Missing session cookie');
|
|
} catch (e) {
|
|
/* ignore close errors */
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const gameId = id;
|
|
|
|
const short = `[${req.cookies.player.substring(0, 8)}]`;
|
|
ws.id = short;
|
|
|
|
console.log(`${short}: Game ${gameId} - New connection from client.`);
|
|
try {
|
|
console.log(`${short}: WS handshake headers: origin=${req.headers.origin} cookie=${req.headers.cookie}`);
|
|
} catch (e) {
|
|
/* ignore logging errors */
|
|
}
|
|
if (!(id in audio)) {
|
|
audio[id] = {}; /* List of peer sockets using session.name as index. */
|
|
console.log(`${short}: Game ${id} - New Game Audio`);
|
|
} else {
|
|
console.log(`${short}: Game ${id} - Already has Audio`);
|
|
}
|
|
|
|
/* Setup WebSocket event handlers prior to performing any async calls or
|
|
* we may miss the first messages from clients */
|
|
ws.on('error', async (event) => {
|
|
console.error(`WebSocket error: `, event && event.message ? event.message : event);
|
|
const game = await loadGame(gameId);
|
|
if (!game) {
|
|
return;
|
|
}
|
|
const session = getSession(game, req.cookies.player);
|
|
session.live = false;
|
|
try {
|
|
console.log(`${short}: ws.on('error') - session.ws === ws? ${session.ws === ws}`);
|
|
console.log(`${short}: ws.on('error') - session.id=${session && session.id}`);
|
|
console.log(`${short}: ws.on('error') - stack:`, new Error().stack);
|
|
// Only close the session.ws if it is the same socket that errored.
|
|
if (session.ws && session.ws === ws) {
|
|
try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws:`, e); }
|
|
session.ws = undefined;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`${short}: exception in ws.on('error') handler:`, e);
|
|
}
|
|
|
|
departLobby(game, session);
|
|
});
|
|
|
|
ws.on('close', async (event) => {
|
|
console.log(`${short} - closed connection (event: ${event && typeof event === 'object' ? JSON.stringify(event) : event})`);
|
|
|
|
const game = await loadGame(gameId);
|
|
if (!game) {
|
|
return;
|
|
}
|
|
const session = getSession(game, req.cookies.player);
|
|
if (session.player) {
|
|
session.player.live = false;
|
|
}
|
|
session.live = false;
|
|
// Only cleanup the session.ws if it references the same socket object
|
|
try {
|
|
console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`);
|
|
console.log(`${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}`);
|
|
if (session.ws && session.ws === ws) {
|
|
/* Cleanup any voice channels */
|
|
if (id in audio) {
|
|
try { part(audio[id], session); } catch (e) { console.warn(`${short}: Error during part():`, e); }
|
|
}
|
|
try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws in on('close'):`, e); }
|
|
session.ws = undefined;
|
|
console.log(`${short}:WebSocket closed for ${getName(session)}`);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`${short}: exception in ws.on('close') handler:`, e);
|
|
}
|
|
|
|
departLobby(game, session);
|
|
|
|
/* Check for a game in the Winner state with no more connections
|
|
* and remove it */
|
|
if (game.state === 'winner') {
|
|
let dead = true;
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].live && game.sessions[id].name) {
|
|
dead = false;
|
|
}
|
|
}
|
|
if (dead) {
|
|
console.log(`${session.id}: No more players in ${game.id}. ` +
|
|
`Removing.`);
|
|
addChatMessage(game, null, `No more active players in game. ` +
|
|
`It is being removed from the server.`);
|
|
sendUpdateToPlayers(game, {
|
|
chat: game.chat
|
|
});
|
|
for (let id in game.sessions) {
|
|
if (game.sessions[id].ws) {
|
|
try {
|
|
console.log(`${short}: Removing game - closing session ${id} socket (game removal cleanup)`);
|
|
console.log(`${short}: Closing socket stack:`, new Error().stack);
|
|
game.sessions[id].ws.close();
|
|
} catch (e) {
|
|
console.warn(`${short}: error closing session socket during game removal:`, e);
|
|
}
|
|
delete game.sessions[id];
|
|
}
|
|
}
|
|
delete audio[id];
|
|
delete games[id];
|
|
try {
|
|
fs.unlinkSync(`/db/games/${id}`);
|
|
} catch (error) {
|
|
console.error(`${session.id}: Unable to remove /db/games/${id}`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('message', async (message) => {
|
|
// Normalize the incoming message to { type, data } so handlers can
|
|
// reliably access the payload without repeated defensive checks.
|
|
const incoming = normalizeIncoming(message);
|
|
if (!incoming.type) {
|
|
// If we couldn't parse or determine the type, log and ignore the
|
|
// message to preserve previous behavior.
|
|
try {
|
|
console.error(`${all}: parse/normalize error`, message);
|
|
} catch (e) {
|
|
console.error('parse/normalize error');
|
|
}
|
|
return;
|
|
}
|
|
const data = incoming.data;
|
|
const game = await loadGame(gameId);
|
|
const session = getSession(game, req.cookies.player);
|
|
// Keep track of any previously attached websocket so we can detect
|
|
// first-time attaches and websocket replacements (reconnects).
|
|
const previousWs = session.ws;
|
|
const wasAttached = !!previousWs;
|
|
// If there was a previous websocket and it's a different object, try to
|
|
// close it to avoid stale sockets lingering in memory.
|
|
if (previousWs && previousWs !== ws) {
|
|
try {
|
|
previousWs.close();
|
|
} catch (e) {
|
|
/* ignore close errors */
|
|
}
|
|
}
|
|
// Attach the current websocket for this session.
|
|
session.ws = ws;
|
|
if (session.player) {
|
|
session.player.live = true;
|
|
}
|
|
session.live = true;
|
|
session.lastActive = Date.now();
|
|
|
|
let error, warning, update, processed = true;
|
|
|
|
// If this is the first time the session attached a WebSocket, or if the
|
|
// websocket was just replaced (reconnect), send an initial consolidated
|
|
// snapshot so clients can render deterministically without needing to
|
|
// wait for a flurry of incremental game-update events.
|
|
if (!session._initialSnapshotSent) {
|
|
try {
|
|
sendInitialGameSnapshot(game, session);
|
|
session._initialSnapshotSent = true;
|
|
} catch (e) {
|
|
console.error(`${session.id}: error sending initial snapshot`, e);
|
|
}
|
|
}
|
|
|
|
switch (incoming.type) {
|
|
case 'join':
|
|
// Accept either legacy `config` or newer `data` field from clients
|
|
join(audio[id], session, data.config || data.data || {});
|
|
break;
|
|
|
|
case 'part':
|
|
part(audio[id], session);
|
|
break;
|
|
|
|
case 'relayICECandidate': {
|
|
if (!(id in audio)) {
|
|
console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`);
|
|
return;
|
|
}
|
|
|
|
// Support both { config: {...} } and { data: {...} } client payloads
|
|
const cfg = data.config || data.data || {};
|
|
const { peer_id, candidate } = cfg;
|
|
if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate);
|
|
|
|
message = JSON.stringify({
|
|
type: 'iceCandidate',
|
|
data: {'peer_id': getName(session), 'candidate': candidate }
|
|
});
|
|
|
|
if (peer_id in audio[id]) {
|
|
audio[id][peer_id].ws.send(message);
|
|
}
|
|
} break;
|
|
|
|
case 'relaySessionDescription': {
|
|
if (!(id in audio)) {
|
|
console.error(`${id} - relaySessionDescription - Does not have Audio`);
|
|
return;
|
|
}
|
|
|
|
// Support both { config: {...} } and { data: {...} } client payloads
|
|
const cfg = data.config || data.data || {};
|
|
const { peer_id, session_description } = cfg;
|
|
if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, session_description);
|
|
message = JSON.stringify({
|
|
type: 'sessionDescription',
|
|
data: {'peer_id': getName(session), 'session_description': session_description }
|
|
});
|
|
if (peer_id in audio[id]) {
|
|
audio[id][peer_id].ws.send(message);
|
|
}
|
|
} break;
|
|
|
|
case 'pong':
|
|
resetDisconnectCheck(game, req);
|
|
break;
|
|
|
|
case 'game-update':
|
|
console.log(`${short}: <- game-update ${getName(session)} - full game update.`);
|
|
sendGameToPlayer(game, session);
|
|
break;
|
|
|
|
case 'peer_state_update': {
|
|
// Broadcast a peer state update (muted/video_on) to other peers in the game audio map
|
|
if (!(id in audio)) {
|
|
console.error(`${session.id}:${id} <- peer_state_update - Does not have Audio`);
|
|
return;
|
|
}
|
|
|
|
const cfg = data.config || data.data || {};
|
|
const { peer_id, muted, video_on } = cfg;
|
|
if (!session.name) {
|
|
console.error(`${session.id}: peer_state_update - unnamed session`);
|
|
return;
|
|
}
|
|
|
|
const messagePayload = JSON.stringify({
|
|
type: 'peer_state_update',
|
|
data: { peer_id: session.name, muted, video_on },
|
|
});
|
|
|
|
// Send to all other peers
|
|
for (const other in audio[id]) {
|
|
if (other === session.name) continue;
|
|
try {
|
|
audio[id][other].ws.send(messagePayload);
|
|
} catch (e) {
|
|
console.warn(`Failed sending peer_state_update to ${other}:`, e);
|
|
}
|
|
}
|
|
} break;
|
|
|
|
case 'player-name':
|
|
// Support both legacy { type: 'player-name', name: 'Foo' }
|
|
// and normalized { type: 'player-name', data: { name: 'Foo' } }
|
|
const _pname = (data && data.name) || (data && data.data && data.data.name);
|
|
console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${_pname}`)
|
|
error = setPlayerName(game, session, _pname);
|
|
if (error) {
|
|
sendError(session, error);
|
|
}else {
|
|
saveGame(game);
|
|
}
|
|
break;
|
|
|
|
case 'set':
|
|
console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`);
|
|
switch (data.field) {
|
|
case 'state':
|
|
warning = setGameState(game, session, data.value);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
} else {
|
|
saveGame(game);
|
|
}
|
|
break;
|
|
|
|
case 'color':
|
|
warning = setPlayerColor(game, session, data.value);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
} else {
|
|
saveGame(game);
|
|
}
|
|
break;
|
|
default:
|
|
console.warn(`WARNING: Requested SET unsupported field: ${data.field}`);
|
|
break;
|
|
}
|
|
break;
|
|
|
|
case 'get':
|
|
// Batch 'get' requests per-session for a short window so multiple
|
|
// near-simultaneous requests are merged into one response. This
|
|
// reduces CPU and network churn during client startup.
|
|
const requestedFields = Array.isArray(data.fields)
|
|
? data.fields
|
|
: (data.data && Array.isArray(data.data.fields))
|
|
? data.data.fields
|
|
: [];
|
|
console.log(`${short}: <- get:${getName(session)} ${requestedFields.length ? requestedFields.join(',') : '<none>'}`);
|
|
|
|
// Ensure a batch structure exists on the session
|
|
if (!session._getBatch) {
|
|
session._getBatch = { fields: new Set(), timer: undefined };
|
|
}
|
|
// Merge requested fields into the batch set
|
|
requestedFields.forEach(f => session._getBatch.fields.add(f));
|
|
|
|
// If a timer is already scheduled, we will respond when it fires.
|
|
if (session._getBatch.timer) {
|
|
break;
|
|
}
|
|
|
|
// Schedule a single reply after the batching window
|
|
session._getBatch.timer = setTimeout(() => {
|
|
try {
|
|
const fieldsArray = Array.from(session._getBatch.fields);
|
|
const batchedUpdate = {};
|
|
fieldsArray.forEach((field) => {
|
|
switch (field) {
|
|
case 'player':
|
|
sendWarning(session, `'player' is not a valid item. use 'private' instead`);
|
|
batchedUpdate.player = undefined;
|
|
break;
|
|
case 'id':
|
|
case 'chat':
|
|
case 'startTime':
|
|
case 'state':
|
|
case 'turn':
|
|
case 'turns':
|
|
case 'winner':
|
|
case 'placements':
|
|
case 'longestRoadLength':
|
|
case 'robber':
|
|
case 'robberName':
|
|
case 'pips':
|
|
case 'pipsOrder':
|
|
case 'borders':
|
|
case 'tileOrder':
|
|
case 'active':
|
|
case 'largestArmy':
|
|
case 'mostDeveloped':
|
|
case 'mostPorts':
|
|
case 'longestRoad':
|
|
case 'tiles':
|
|
case 'pipOrder':
|
|
case 'signature':
|
|
case 'borderOrder':
|
|
case 'dice':
|
|
case 'activities':
|
|
batchedUpdate[field] = game[field];
|
|
break;
|
|
case 'rules':
|
|
batchedUpdate[field] = game.rules ? game.rules : {};
|
|
break;
|
|
case 'name':
|
|
batchedUpdate.name = session.name;
|
|
break;
|
|
case 'unselected':
|
|
batchedUpdate.unselected = getFilteredUnselected(game);
|
|
break;
|
|
case 'private':
|
|
batchedUpdate.private = session.player;
|
|
break;
|
|
case 'players':
|
|
batchedUpdate.players = getFilteredPlayers(game);
|
|
break;
|
|
case 'color':
|
|
console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`);
|
|
batchedUpdate.color = session.color;
|
|
break;
|
|
case 'timestamp':
|
|
batchedUpdate.timestamp = Date.now();
|
|
break;
|
|
default:
|
|
if (field in game) {
|
|
console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`);
|
|
batchedUpdate[field] = game[field];
|
|
} else if (field in session) {
|
|
console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`);
|
|
batchedUpdate[field] = session[field];
|
|
} else {
|
|
console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`);
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
sendUpdateToPlayer(game, session, batchedUpdate);
|
|
} catch (e) {
|
|
console.warn(`${session.id}: get batch handler failed:`, e);
|
|
}
|
|
// clear batch
|
|
session._getBatch.fields.clear();
|
|
clearTimeout(session._getBatch.timer);
|
|
session._getBatch.timer = undefined;
|
|
}, INCOMING_GET_BATCH_MS);
|
|
break;
|
|
|
|
case 'chat':
|
|
/* If the chat message is empty, do not add it to the chat */
|
|
if (data.message.trim() == '') {
|
|
break;
|
|
}
|
|
console.log(`${short}:${id} - ${data.type} - "${data.message}"`)
|
|
addChatMessage(game, session, `${session.name}: ${data.message}`, true);
|
|
parseChatCommands(game, data.message);
|
|
sendUpdateToPlayers(game, { chat: game.chat });
|
|
saveGame(game);
|
|
break;
|
|
|
|
case 'media-status':
|
|
console.log(`${short}: <- media-status - `, data.audio, data.video);
|
|
session.video = data.video;
|
|
session.audio = data.audio;
|
|
break;
|
|
|
|
default:
|
|
processed = false;
|
|
break;
|
|
}
|
|
|
|
if (processed) {
|
|
/* saveGame(game); -- do not save here; only save on changes */
|
|
return;
|
|
}
|
|
|
|
/* The rest of the actions and commands require an active game
|
|
* participant */
|
|
|
|
if (!session.player) {
|
|
error = `Player must have an active color.`;
|
|
sendError(session, error);
|
|
return;
|
|
}
|
|
|
|
processed = true;
|
|
const priorSession = session;
|
|
|
|
switch (incoming.type) {
|
|
case 'roll':
|
|
console.log(`${short}: <- roll:${getName(session)}`);
|
|
warning = roll(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'shuffle':
|
|
console.log(`${short}: <- shuffle:${getName(session)}`);
|
|
warning = shuffle(game, session);
|
|
if (warning) {
|
|
warning(session, error);
|
|
}
|
|
break;
|
|
case 'place-settlement':
|
|
console.log(`${short}: <- place-settlement:${getName(session)} ${data.index}`);
|
|
warning = placeSettlement(game, session, data.index);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'place-city':
|
|
console.log(`${short}: <- place-city:${getName(session)} ${data.index}`);
|
|
warning = placeCity(game, session, data.index);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'place-road':
|
|
console.log(`${short}: <- place-road:${getName(session)} ${data.index}`);
|
|
warning = placeRoad(game, session, data.index);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'place-robber':
|
|
console.log(`${short}: <- place-robber:${getName(session)} ${data.index}`);
|
|
warning = placeRobber(game, session, data.index);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'steal-resource':
|
|
console.log(`${short}: <- steal-resource:${getName(session)} ${data.color}`);
|
|
warning = stealResource(game, session, data.color);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'discard':
|
|
console.log(`${short}: <- discard:${getName(session)}`);
|
|
warning = discard(game, session, data.discards);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'pass':
|
|
console.log(`${short}: <- pass:${getName(session)}`);
|
|
warning = pass(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'select-resources':
|
|
console.log(`${short}: <- select-resources:${getName(session)} - `, data.cards);
|
|
warning = selectResources(game, session, data.cards);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'buy-city':
|
|
console.log(`${short}: <- buy-city:${getName(session)}`);
|
|
warning = buyCity(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'buy-road':
|
|
console.log(`${short}: <- buy-road:${getName(session)}`);
|
|
warning = buyRoad(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'buy-settlement':
|
|
console.log(`${short}: <- buy-settlement:${getName(session)}`);
|
|
warning = buySettlement(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'buy-development':
|
|
console.log(`${short}: <- buy-development:${getName(session)}`);
|
|
warning = buyDevelopment(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'play-card':
|
|
console.log(`${short}: <- play-card:${getName(session)}`);
|
|
warning = playCard(game, session, data.card);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'trade':
|
|
console.log(`${short}: <- trade:${getName(session)} - ` +
|
|
(data.action ? data.action : 'start') + ` -`,
|
|
data.offer ? data.offer : 'no trade yet');
|
|
warning = trade(game, session, data.action, data.offer);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
} else {
|
|
for (let key in game.sessions) {
|
|
const tmp = game.sessions[key];
|
|
if (tmp.player) {
|
|
sendUpdateToPlayer(game, tmp, {
|
|
private: tmp.player
|
|
});
|
|
}
|
|
}
|
|
sendUpdateToPlayers(game, {
|
|
turn: game.turn,
|
|
activities: game.activities,
|
|
chat: game.chat,
|
|
players: getFilteredPlayers(game)
|
|
});
|
|
}
|
|
break;
|
|
case 'turn-notice':
|
|
console.log(`${short}: <- turn-notice:${getName(session)}`);
|
|
warning = clearTimeNotice(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'clear-game':
|
|
console.log(`${short}: <- clear-game:${getName(session)}`);
|
|
warning = clearGame(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'goto-lobby':
|
|
console.log(`${short}: <- goto-lobby:${getName(session)}`);
|
|
warning = gotoLobby(game, session);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
case 'rules':
|
|
console.log(`${short} - <- rules:${getName(session)} - `,
|
|
data.rules);
|
|
warning = setRules(game, session, data.rules);
|
|
if (warning) {
|
|
sendWarning(session, warning);
|
|
}
|
|
break;
|
|
default:
|
|
console.warn(`Unsupported request: ${data.type}`);
|
|
processed = false;
|
|
break;
|
|
}
|
|
|
|
/* If action was taken, persist the game */
|
|
if (processed) {
|
|
saveGame(game);
|
|
}
|
|
|
|
/* If the current player took an action, reset the session timer */
|
|
if (processed && session.color === game.turn.color && game.state !== 'winner') {
|
|
resetTurnTimer(game, session);
|
|
}
|
|
});
|
|
|
|
|
|
/* This will result in the node tick moving forward; if we haven't already
|
|
* setup the event handlers, a 'message' could come through prior to this
|
|
* completing */
|
|
const game = await loadGame(gameId);
|
|
if (!game) {
|
|
console.error(`Unable to load/create new game for WS request.`);
|
|
return;
|
|
}
|
|
|
|
const session = getSession(game, req.cookies.player);
|
|
session.ws = ws;
|
|
if (session.player) {
|
|
session.player.live = true;
|
|
}
|
|
session.live = true;
|
|
session.lastActive = Date.now();
|
|
// Ensure we only attempt to send the consolidated initial snapshot once
|
|
// per session lifecycle. Tests and clients expect a single 'initial-game'
|
|
// message when a socket first attaches.
|
|
if (!session._initialSnapshotSent) {
|
|
try {
|
|
sendInitialGameSnapshot(game, session);
|
|
session._initialSnapshotSent = true;
|
|
} catch (e) {
|
|
console.error(`${session.id}: error sending initial snapshot on connect`, e);
|
|
}
|
|
}
|
|
if (session.name) {
|
|
sendUpdateToPlayers(game, {
|
|
players: getFilteredPlayers(game),
|
|
unselected: getFilteredUnselected(game)
|
|
});
|
|
}
|
|
|
|
/* If the current turn player just rejoined, set their turn timer */
|
|
if (game.turn && game.turn.color === session.color && game.state !== 'winner') {
|
|
resetTurnTimer(game, session);
|
|
}
|
|
|
|
if (session.name) {
|
|
if (session.color) {
|
|
addChatMessage(game, null, `${session.name} has reconnected to the game.`);
|
|
} else {
|
|
addChatMessage(game, null, `${session.name} has rejoined the lobby.`);
|
|
}
|
|
sendUpdateToPlayers(game, { chat: game.chat });
|
|
}
|
|
|
|
resetDisconnectCheck(game, req);
|
|
console.log(`${short}: Game ${id} - WebSocket connect from ${getName(session)}`);
|
|
|
|
/* Send initial ping to initiate communication with client */
|
|
if (!session.keepAlive) {
|
|
console.log(`${short}: Sending initial ping`);
|
|
ping(session);
|
|
} else {
|
|
clearTimeout(session.keepAlive);
|
|
session.keepAlive = setTimeout(() => { ping(session); }, 2500);
|
|
}
|
|
});
|
|
|
|
const debugChat = (game, preamble) => {
|
|
preamble = `Degug ${preamble.trim()}`;
|
|
|
|
let playerInventory = preamble;
|
|
|
|
for (let key in game.players) {
|
|
const player = game.players[key];
|
|
if (player.status === 'Not active') {
|
|
continue;
|
|
}
|
|
if (playerInventory !== '') {
|
|
playerInventory += ' player';
|
|
} else {
|
|
playerInventory += ' Player'
|
|
}
|
|
playerInventory += ` ${player.name} has `;
|
|
const has = [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].map(resource => {
|
|
const count = player[resource] ? player[resource] : 0;
|
|
return `${count} ${resource}`;
|
|
}).filter(item => item !== '').join(', ');
|
|
if (has) {
|
|
playerInventory += `${has}, `;
|
|
} else {
|
|
playerInventory += `nothing, `;
|
|
}
|
|
}
|
|
if (game.debug) {
|
|
addChatMessage(game, null, playerInventory.replace(/, $/, '').trim());
|
|
} else {
|
|
console.log(playerInventory.replace(/, $/, '').trim());
|
|
}
|
|
}
|
|
|
|
const getFilteredGameForPlayer = (game, session) => {
|
|
|
|
/* Shallow copy game, filling its sessions with a shallow copy of
|
|
* sessions so we can then delete the player field from them */
|
|
const reducedGame = Object.assign({}, game, { sessions: {} }),
|
|
reducedSessions = [];
|
|
|
|
for (let id in game.sessions) {
|
|
// Make a shallow copy and then scrub any fields that are private,
|
|
// non-serializable (timers, sockets), or internal (prefixed with '_').
|
|
const original = game.sessions[id];
|
|
const reduced = Object.assign({}, original);
|
|
|
|
// Remove obvious non-serializable fields
|
|
if ('player' in reduced) delete reduced.player;
|
|
if ('ws' in reduced) delete reduced.ws;
|
|
if ('keepAlive' in reduced) delete reduced.keepAlive;
|
|
|
|
// Remove internal helper fields (e.g. _pendingTimeout) and functions
|
|
Object.keys(reduced).forEach((k) => {
|
|
try {
|
|
if (k.startsWith('_')) {
|
|
delete reduced[k];
|
|
} else if (typeof reduced[k] === 'function') {
|
|
delete reduced[k];
|
|
} else {
|
|
// Remove values that are likely to be non-serializable objects
|
|
// such as Timers that may appear on some runtime fields.
|
|
const v = reduced[k];
|
|
if (typeof v === 'object' && v !== null) {
|
|
// A quick heuristic: if the object has constructor name 'Timeout' or
|
|
// properties typical of timer internals, drop it to avoid circular refs.
|
|
const ctor = v.constructor && v.constructor.name ? v.constructor.name : '';
|
|
if (ctor === 'Timeout' || ctor === 'TimersList') {
|
|
delete reduced[k];
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Defensive: if introspection fails, delete the key to be safe
|
|
try { delete reduced[k]; } catch (err) { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
reducedGame.sessions[id] = reduced;
|
|
/* Do not send session-id as those are secrets */
|
|
reducedSessions.push(reduced);
|
|
}
|
|
|
|
const player = session.player ? session.player : undefined;
|
|
|
|
/* Strip out data that should not be shared with players */
|
|
delete reducedGame.developmentCards;
|
|
|
|
/* Delete the game timer */
|
|
delete reducedGame.turnTimer;
|
|
|
|
reducedGame.unselected = getFilteredUnselected(game);
|
|
|
|
return Object.assign(reducedGame, {
|
|
live: true,
|
|
status: session.error ? session.error : "success",
|
|
name: session.name,
|
|
color: session.color,
|
|
order: (session.color in game.players) ? game.players[session.color].order : 0,
|
|
private: player,
|
|
sessions: reducedSessions,
|
|
layout: layout,
|
|
players: getFilteredPlayers(game),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a consolidated initial snapshot to a single session.
|
|
* This is used to allow clients (and tests) to render the full
|
|
* game state deterministically on first attach instead of having
|
|
* to wait for many incremental `game-update` messages.
|
|
*/
|
|
const sendInitialGameSnapshot = (game, session) => {
|
|
try {
|
|
const snapshot = getFilteredGameForPlayer(game, session);
|
|
const message = JSON.stringify({ type: 'initial-game', snapshot });
|
|
// Small debug log to help test harnesses detect that the server sent
|
|
// the consolidated snapshot. Keep output small to avoid noisy logs.
|
|
try {
|
|
const topKeys = Object.keys(snapshot || {}).slice(0, 10).join(',');
|
|
console.log(`${session.id}: sending initial-game snapshot keys: ${topKeys}`);
|
|
} catch (e) {
|
|
/* ignore logging errors */
|
|
}
|
|
if (session && session.ws && session.ws.send) {
|
|
session.ws.send(message);
|
|
} else {
|
|
console.warn(`${session.id}: Unable to send initial snapshot - no websocket available`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`${session.id}: error in sendInitialGameSnapshot`, err);
|
|
}
|
|
}
|
|
|
|
/* Example:
|
|
"stolen": {
|
|
"robber": {
|
|
"stole": {
|
|
"total": 5,
|
|
"wheat": 2,
|
|
"wood": 1,
|
|
"sheep": 2
|
|
}
|
|
},
|
|
"O": {
|
|
"stolen": {
|
|
"total": 2,
|
|
"wheat": 2
|
|
},
|
|
"stole": {
|
|
"total": 2,
|
|
"brick": 2
|
|
}
|
|
},
|
|
"W": {
|
|
"stolen": {
|
|
"total": 4,
|
|
"brick": 2,
|
|
"wood": 1,
|
|
"sheep": 2
|
|
},
|
|
"stole": {
|
|
"total": 3,
|
|
"brick": 2,
|
|
"wheat": 1
|
|
}
|
|
}
|
|
}
|
|
*/
|
|
const trackTheft = (game, from, to, type, count) => {
|
|
const stats = game.stolen;
|
|
|
|
/* Initialize the stole / stolen structures */
|
|
[ to, from ].forEach(player => {
|
|
if (!(player in stats)) {
|
|
stats[player] = {
|
|
stole: { /* the resources this player stole */
|
|
total: 0
|
|
},
|
|
stolen: { /* the resources stolen from this player */
|
|
total: 0,
|
|
player: 0, /* by players */
|
|
robber: 0 /* by robber */
|
|
}
|
|
};
|
|
}
|
|
});
|
|
|
|
/* Initialize 'type' field in structures */
|
|
if (!(type in stats[from].stolen)) {
|
|
stats[from].stolen[type] = 0;
|
|
}
|
|
if (!(type in stats[to].stole)) {
|
|
stats[to].stole[type] = 0;
|
|
}
|
|
|
|
/* Update counts */
|
|
stats[from].stolen.total += count;
|
|
if (to === 'robber') {
|
|
stats[from].stolen.robber += count;
|
|
} else {
|
|
stats[from].stolen.player += count;
|
|
}
|
|
stats[from].stolen[type] += count;
|
|
stats[to].stole.total += count;
|
|
stats[to].stole[type] += count;
|
|
}
|
|
|
|
const resetGame = (game) => {
|
|
Object.assign(game, {
|
|
startTime: Date.now(),
|
|
state: 'lobby',
|
|
turns: 0,
|
|
step: 0, /* used for the suffix # in game backups */
|
|
turn: {},
|
|
sheep: 19,
|
|
ore: 19,
|
|
wool: 19,
|
|
brick: 19,
|
|
wheat: 19,
|
|
placements: {
|
|
corners: [],
|
|
roads: []
|
|
},
|
|
developmentCards: [],
|
|
chat: [],
|
|
activities: [],
|
|
pipOrder: game.pipOrder,
|
|
borderOrder: game.borderOrder,
|
|
tileOrder: game.tileOrder,
|
|
signature: game.signature,
|
|
players: game.players,
|
|
stolen: {
|
|
robber: {
|
|
stole: {
|
|
total: 0
|
|
}
|
|
},
|
|
total: 0
|
|
},
|
|
longestRoad: '',
|
|
longestRoadLength: 0,
|
|
largestArmy: '',
|
|
largestArmySize: 0,
|
|
mostDeveloped: '',
|
|
mostDevelopmentCards: 0,
|
|
mostPorts: '',
|
|
mostPortCount: 0,
|
|
winner: undefined,
|
|
active: 0
|
|
});
|
|
|
|
stopTurnTimer(game);
|
|
|
|
/* Populate the game corner and road placement data as cleared */
|
|
for (let i = 0; i < layout.corners.length; i++) {
|
|
game.placements.corners[i] = {
|
|
color: undefined,
|
|
type: undefined
|
|
};
|
|
}
|
|
|
|
for (let i = 0; i < layout.roads.length; i++) {
|
|
game.placements.roads[i] = {
|
|
color: undefined,
|
|
longestRoad: undefined
|
|
};
|
|
}
|
|
|
|
/* Put the robber back on the Desert */
|
|
for (let i = 0; i < game.pipOrder.length; i++) {
|
|
if (game.pipOrder[i] === 18) {
|
|
game.robber = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* Populate the game development cards with a fresh deck */
|
|
for (let i = 1; i <= 14; i++) {
|
|
game.developmentCards.push({
|
|
type: 'army',
|
|
card: i
|
|
});
|
|
}
|
|
|
|
[ 'monopoly', 'monopoly', 'road-1', 'road-2', 'year-of-plenty', 'year-of-plenty']
|
|
.forEach(card => game.developmentCards.push({
|
|
type: 'progress',
|
|
card: card
|
|
}));
|
|
|
|
[ 'market', 'library', 'palace', 'university']
|
|
.forEach(card => game.developmentCards.push({
|
|
type: 'vp',
|
|
card: card
|
|
}));
|
|
|
|
shuffleArray(game.developmentCards);
|
|
|
|
/* Reset all player data, and add in any missing colors */
|
|
[ 'R', 'B', 'W', 'O' ].forEach(color => {
|
|
if (color in game.players) {
|
|
clearPlayer(game.players[color]);
|
|
} else {
|
|
game.players[color] = newPlayer(color);
|
|
}
|
|
});
|
|
|
|
/* Ensure sessions are connected to player objects */
|
|
for (let key in game.sessions) {
|
|
const session = game.sessions[key];
|
|
if (session.color) {
|
|
game.active++;
|
|
session.player = game.players[session.color];
|
|
session.player.status = 'Active';
|
|
session.player.lastActive = Date.now();
|
|
session.player.live = session.live;
|
|
session.player.name = session.name;
|
|
session.player.color = session.color;
|
|
}
|
|
}
|
|
|
|
game.animationSeeds = [];
|
|
for (let i = 0, p = 0; i < game.tileOrder.length; i++) {
|
|
game.animationSeeds.push(Math.random());
|
|
}
|
|
}
|
|
|
|
const createGame = (id) => {
|
|
/* Look for a new game with random words that does not already exist */
|
|
while (!id) {
|
|
id = randomWords(4).join('-');
|
|
try {
|
|
/* If file can be read, it already exists so look for a new name */
|
|
accessSync(`/db/games/${id}`, fs.F_OK);
|
|
id = '';
|
|
} catch (error) {
|
|
break;
|
|
}
|
|
}
|
|
console.log(`${info}: creating ${id}`);
|
|
|
|
const game = {
|
|
id: id,
|
|
developmentCards: [],
|
|
players: {
|
|
O: newPlayer('O'),
|
|
R: newPlayer('R'),
|
|
B: newPlayer('B'),
|
|
W: newPlayer('W')
|
|
},
|
|
sessions: {},
|
|
unselected: [],
|
|
active: 0,
|
|
rules: {
|
|
'victory-points': {
|
|
points: 10
|
|
}
|
|
},
|
|
step: 0 /* used for the suffix # in game backups */
|
|
};
|
|
|
|
[ "pips", "borders", "tiles" ].forEach((field) => {
|
|
game[field] = staticData[field]
|
|
});
|
|
|
|
setBeginnerGame(game);
|
|
resetGame(game);
|
|
|
|
addChatMessage(game, null, `New game created with Beginner's Layout: ${game.id}`);
|
|
|
|
games[game.id] = game;
|
|
audio[game.id] = {};
|
|
return game;
|
|
};
|
|
|
|
const setBeginnerGame = (game) => {
|
|
pickRobber(game);
|
|
shuffleArray(game.developmentCards);
|
|
game.borderOrder = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
game.borderOrder.push(i);
|
|
}
|
|
game.tileOrder = [
|
|
9, 12, 1,
|
|
5, 16, 13, 17,
|
|
6, 2, 0, 3, 10,
|
|
4, 11, 7, 14,
|
|
18, 8, 15
|
|
];
|
|
game.robber = 9;
|
|
game.animationSeeds = [];
|
|
for (let i = 0, p = 0; i < game.tileOrder.length; i++) {
|
|
game.animationSeeds.push(Math.random());
|
|
}
|
|
game.pipOrder = [
|
|
5, 1, 6,
|
|
7, 2, 9, 11,
|
|
12, 8, 18, 3, 4,
|
|
10, 16, 13, 0,
|
|
14, 15, 17
|
|
];
|
|
game.signature = gameSignature(game);
|
|
}
|
|
|
|
const shuffleBoard = (game) => {
|
|
pickRobber(game);
|
|
|
|
const seq = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
seq.push(i);
|
|
}
|
|
shuffleArray(seq);
|
|
game.borderOrder = seq.slice();
|
|
|
|
for (let i = 6; i < 19; i++) {
|
|
seq.push(i);
|
|
}
|
|
shuffleArray(seq);
|
|
game.tileOrder = seq.slice();
|
|
|
|
/* Pip order is from one of the random corners, then rotate around
|
|
* and skip over the desert (robber) */
|
|
|
|
/* Board:
|
|
* 0 1 2
|
|
* 3 4 5 6
|
|
* 7 8 9 10 11
|
|
* 12 13 14 15
|
|
* 16 17 18
|
|
*/
|
|
const order = [
|
|
[ 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9 ],
|
|
[ 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 5, 10, 14, 13, 8, 4, 9 ],
|
|
[ 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 10, 14, 13, 8, 4, 5, 9 ],
|
|
[ 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 14, 13, 8, 4, 5, 10, 9 ],
|
|
[ 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 13, 8, 4, 5, 10, 14, 9 ],
|
|
[ 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 8, 4, 5, 10, 14, 13, 9 ]
|
|
]
|
|
const sequence = order[Math.floor(Math.random() * order.length)];
|
|
game.pipOrder = [];
|
|
game.animationSeeds = [];
|
|
for (let i = 0, p = 0; i < sequence.length; i++) {
|
|
const target = sequence[i];
|
|
/* If the target tile is the desert (18), then set the
|
|
* pip value to the robber (18) otherwise set
|
|
* the target pip value to the currently incremeneting
|
|
* pip value. */
|
|
if (game.tiles[game.tileOrder[target]].type === 'desert') {
|
|
game.robber = target;
|
|
game.pipOrder[target] = 18;
|
|
} else {
|
|
game.pipOrder[target] = p++;
|
|
}
|
|
game.animationSeeds.push(Math.random());
|
|
}
|
|
|
|
shuffleArray(game.developmentCards);
|
|
|
|
game.signature = gameSignature(game);
|
|
}
|
|
|
|
/* Simple NO-OP to set session cookie so player-id can use it as the
|
|
* index */
|
|
router.get("/", (req, res/*, next*/) => {
|
|
let playerId;
|
|
if (!req.cookies.player) {
|
|
playerId = crypto.randomBytes(16).toString('hex');
|
|
// Determine whether this request is secure so we can set cookie flags
|
|
// appropriately. In production behind TLS we want SameSite=None and
|
|
// Secure so the cookie is sent on cross-site websocket connects.
|
|
const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production';
|
|
const cookieOpts = {
|
|
httpOnly: false,
|
|
sameSite: secure ? 'none' : 'lax',
|
|
secure: !!secure
|
|
};
|
|
// Ensure cookie is scoped to the application basePath so it will be
|
|
// included on requests under the same prefix (and on the websocket
|
|
// handshake which uses the same path prefix).
|
|
cookieOpts.path = basePath || '/';
|
|
res.cookie('player', playerId, cookieOpts);
|
|
console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`);
|
|
} else {
|
|
playerId = req.cookies.player;
|
|
}
|
|
|
|
console.log(`[${playerId.substring(0, 8)}]: Browser hand-shake achieved.`);
|
|
|
|
// Mark this response as coming from the backend API to aid debugging
|
|
res.setHeader('X-Backend', 'games');
|
|
return res.status(200).send({ player: playerId });
|
|
});
|
|
|
|
router.post("/:id?", async (req, res/*, next*/) => {
|
|
const { id } = req.params;
|
|
|
|
let playerId;
|
|
if (!req.cookies.player) {
|
|
playerId = crypto.randomBytes(16).toString('hex');
|
|
const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production';
|
|
const cookieOpts = {
|
|
httpOnly: false,
|
|
sameSite: secure ? 'none' : 'lax',
|
|
secure: !!secure
|
|
};
|
|
cookieOpts.path = basePath || '/';
|
|
res.cookie('player', playerId, cookieOpts);
|
|
console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`);
|
|
} else {
|
|
playerId = req.cookies.player;
|
|
}
|
|
|
|
if (id) {
|
|
console.log(`[${playerId.substring(0,8)}]: Attempting load of ${id}`);
|
|
} else {
|
|
console.log(`[${playerId.substring(0,8)}]: Creating new game.`);
|
|
}
|
|
const game = await loadGame(id); /* will create game if it doesn't exist */
|
|
console.log(`[${playerId.substring(0,8)}]: ${game.id} loaded.`);
|
|
|
|
return res.status(200).send({ id: game.id });
|
|
});
|
|
|
|
|
|
module.exports = router;
|