1
0

4696 lines
148 KiB
TypeScript
Executable File

import express from "express";
import crypto from "crypto";
import { layout, staticData } from "../util/layout";
import basePath from "../basepath";
import { types, debug, all, info, INCOMING_GET_BATCH_MS } from "./games/constants";
import { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLocations";
import {
Player,
Game,
Session,
CornerPlacement,
RoadPlacement,
Offer,
Turn,
Tile,
PlayerColor,
PLAYER_COLORS,
RESOURCE_TYPES,
} from "./games/types";
import { normalizeIncoming } from "./games/utils";
import {
audio,
join as webrtcJoin,
part as webrtcPart,
handleRelayICECandidate,
handleRelaySessionDescription,
broadcastPeerStateUpdate,
} from "./webrtc-signaling";
const router = express.Router();
import {
addActivity,
addChatMessage,
getNextPlayerSession,
clearPlayer,
canGiveBuilding,
setForRoadPlacement,
setForCityPlacement,
setForSettlementPlacement,
adjustResources,
sendUpdateToPlayers,
getFilteredPlayers,
sendUpdateToPlayer,
startTurnTimer,
getName,
queueSend,
shuffle,
resetTurnTimer,
} from "./games/helpers";
import { gameDB, games } from "./games/store";
import { transientState } from "./games/sessionState";
import { createGame, resetGame, setBeginnerGame } from "./games/gameFactory";
import { getVictoryPointRule, setRules, supportedRules } from "./games/rules";
import { pickRobber } from "./games/robber";
const processTies = (players: Player[]): boolean => {
/* 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: Player[][] = [];
players.forEach((player: Player) => {
if (!slots[player.order]) {
slots[player.order] = [];
}
slots[player.order]!.push(player);
});
let ties = false,
position = 1;
const irstify = (position: number): string => {
switch (position) {
case 1:
return `1st`;
case 2:
return `2nd`;
case 3:
return `3rd`;
case 4:
return `4th`;
default:
return position.toString();
}
};
/* Reverse from high to low */
const rev = slots.slice().reverse();
for (const slot of rev) {
const s = slot || [];
// Only consider actual populated slots. Empty slots (holes) should be skipped.
if (s.length > 1) {
// real tie among multiple players in this slot
ties = true;
s.forEach((player: Player) => {
player.orderRoll = 0; /* Ties have to be re-rolled */
player.position = irstify(position);
player.orderStatus = `Tied for ${irstify(position)}`;
player.tied = true;
});
position += s.length;
} else if (s.length === 1 && s[0]) {
// single player in this slot - clear tie and record position
s[0].tied = false;
s[0].position = irstify(position);
s[0].orderStatus = `Placed in ${irstify(position)}.`;
position += 1;
} else {
// empty slot - skip
continue;
}
}
return ties;
};
const processGameOrder = (game: Game, player: Player, dice: number): string | undefined => {
if (player.orderRoll) {
return `You have already rolled for game order and are not in a tie.`;
}
player.orderRoll = dice;
player.order = (player.order || 0) * 6 + dice;
const players: Player[] = [];
let doneRolling = true;
for (const key in game.players) {
const p = game.players[key];
if (!p) {
doneRolling = false;
continue;
}
if (!p.orderRoll) {
doneRolling = false;
}
players.push(p);
}
/* If 'doneRolling' is FALSE then there are still players to roll */
if (!doneRolling) {
sendUpdateToPlayers(game, {
players: getFilteredPlayers(game),
chat: game.chat,
});
return undefined;
}
/* sort updated player.order into the array */
players.sort((A, B) => {
return B.order - A.order;
});
console.log(
`${info}: Pre process ties: `,
players.reduce((acc, p) => ({ ...acc, [p.color as string]: p.orderRoll }), {})
);
if (processTies(players)) {
console.log(
`${info}: There are ties in player rolls:`,
players.reduce((acc, p) => ({ ...acc, [p.color as string]: p.orderRoll }), {})
);
sendUpdateToPlayers(game, {
players: getFilteredPlayers(game),
chat: game.chat,
});
return undefined;
}
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";
const first = players[0];
game.turn = {
name: first?.name as string,
color: first?.color as PlayerColor,
};
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,
});
return undefined;
};
const processVolcano = (game: Game, session: Session, dice: number[]) => {
const name = session.name ? session.name : "Unnamed";
void session.player;
const volcano = layout.tiles.findIndex((_tile, index) => {
const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined;
return typeof tileIndex === "number" && !!staticData.tiles && staticData.tiles[tileIndex]?.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";
if (volcano !== -1 && layout.tiles?.[volcano] && dice && dice[0] !== undefined) {
const corners = layout.tiles[volcano].corners;
if (corners && corners[dice[0] % 6] !== undefined) {
game.turn.volcano = corners[dice[0] % 6];
}
}
const volcanoIdx = typeof game.turn.volcano === "number" ? game.turn.volcano : undefined;
const corner = volcanoIdx !== undefined ? game.placements.corners[volcanoIdx] : undefined;
if (corner && corner.color && corner.color !== "unassigned") {
const player = game.players[corner.color];
if (player) {
if (corner.type === "city") {
if (player.settlements && player.settlements > 0) {
addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`);
player.cities = (player.cities || 0) + 1;
player.settlements = (player.settlements || 0) - 1;
corner.type = "settlement";
} else {
addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`);
corner.type = "none";
corner.color = "unassigned";
player.cities = (player.cities || 0) + 1;
}
} else {
addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
corner.type = "none";
corner.color = "unassigned";
player.settlements = (player.settlements || 0) + 1;
}
}
}
sendUpdateToPlayers(game, {
turn: game.turn,
state: game.state,
chat: game.chat,
dice: game.dice,
placements: game.placements,
players: getFilteredPlayers(game),
});
};
const roll = (game: Game, session: Session, dice?: number[] | undefined): string | undefined => {
const player = session.player as 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 undefined;
case "game-order":
game.startTime = Date.now();
addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
if (typeof dice[0] !== "number") {
return `Invalid roll value.`;
}
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 */
if (typeof dice[0] !== "number") {
return `Invalid roll value.`;
}
processVolcano(game, session, [dice[0]]);
return;
default:
return `Invalid game state (${game.state}) in roll.`;
}
};
const sessionFromColor = (game: Game, color: string): Session | undefined => {
for (const key in game.sessions) {
const s = game.sessions[key];
if (s && s.color === color) {
return s;
}
}
return undefined;
};
interface ResourceCount {
wood: number;
brick: number;
sheep: number;
wheat: number;
stone: number;
desert: number;
}
type Received = Record<PlayerColor | "robber", ResourceCount>;
const distributeResources = (game: Game, roll: number): void => {
console.log(`Roll: ${roll}`);
/* Find which tiles have this roll */
const matchedTiles: Tile[] = [];
const pipOrder = game.pipOrder || [];
pipOrder.forEach((pipIndex: number, pos: number) => {
if (staticData.pips?.[pipIndex] && staticData.pips[pipIndex].roll === roll) {
/* TODO: Fix so it isn't hard coded to "wheat" and instead is the correct resource given
* the resource distribution in shuffeled */
matchedTiles.push({
type: "wheat",
robber: game.robber === pos,
index: pos,
corners: [],
pip: 0,
roads: [],
asset: 0,
});
}
});
const receives: Received = {} as Received;
PLAYER_COLORS.forEach((color) => {
receives[color] = { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0, desert: 0 };
});
/* Find which corners are on each tile */
matchedTiles.forEach((tile: Tile) => {
const tileOrder = game.tileOrder;
const gameTiles = staticData.tiles;
if (tile.index >= tileOrder.length) {
return;
}
const shuffle = tileOrder[tile.index]!;
const resource = gameTiles[shuffle] ? gameTiles[shuffle] : null;
if (!resource) {
return;
}
const tileLayout = layout.tiles[tile.index];
if (!tileLayout) {
return;
}
tileLayout.corners.forEach((cornerIndex: number) => {
const active = game.placements.corners[cornerIndex];
if (!active) {
return;
}
const count = active.type === "settlement" ? 1 : 2;
if (!tile.robber) {
if (resource.type) {
receives[active.color][resource.type]! += count;
}
} else {
const victim = game.players[active.color];
if (isRuleEnabled(game, `robin-hood-robber`) && victim && (victim.points || 0) <= 2) {
addChatMessage(
game,
null,
`Robber does not steal ${count} ${resource.type} from ${victim?.name} due to Robin Hood Robber house rule.`
);
if (resource && resource.type) receives[active.color]![resource.type]! += count;
} else {
trackTheft(game, active.color, "robber", resource.type, count);
if (resource.type) receives.robber[resource.type] += count;
}
}
});
});
const robberList: string[] = [];
PLAYER_COLORS.forEach((color) => {
const entry = receives[color];
if (!(entry.wood || entry.brick || entry.sheep || entry.wheat || entry.stone)) {
return;
}
const messageParts: string[] = [];
let s: Session | undefined;
RESOURCE_TYPES.forEach((type) => {
if (entry[type] === 0) {
return;
}
if (color !== "robber") {
s = sessionFromColor(game, color);
if (s && s.player) {
s.player[type] += entry[type];
s.player.resources += entry[type];
messageParts.push(`${entry[type]} ${type}`);
}
} else {
robberList.push(`${entry[type]} ${type}`);
}
});
if (s) {
addChatMessage(game, s, `${s.name} receives ${messageParts.join(", ")} for pip ${roll}.`);
}
});
if (robberList.length) {
addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robberList.join(", ")}!`);
}
};
const processRoll = (game: Game, session: Session, dice: number[]): any => {
if (!dice[1]) {
console.error(`Invalid roll sequence!`);
return;
}
addChatMessage(game, session, `${session.name} rolled ` + `${dice[0]}, ${dice[1]}.`);
const sum = dice && dice[0] !== undefined && dice[1] !== undefined ? dice[0] + dice[1] : 0;
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 (sum === 12) {
addChatMessage(
game,
session,
`House rule 'Twelve and Two are
Synonyms' activated. Twelve was rolled, so two is triggered too!`
);
distributeResources(game, 2);
}
if (sum === 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 volcanoIdx = layout.tiles.findIndex((_tile, index) => {
const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined;
return typeof tileIndex === "number" && !!staticData.tiles && staticData.tiles[tileIndex]?.type === "desert";
});
if (volcanoIdx !== -1 && layout.tiles[volcanoIdx]) {
const vCorners = layout.tiles[volcanoIdx].corners || [];
vCorners.forEach((index: number) => {
const corner = game.placements.corners[index];
if (corner && corner.color && corner.color !== "unassigned") {
if (!game.turn.select) {
game.turn.select = {} as Record<string, number>;
}
if (!game.turn.select) {
game.turn.select = {} as Record<string, number>;
}
if (!(corner.color in game.turn.select)) {
game.turn.select[corner.color] = 0;
}
game.turn.select[corner.color] =
(game.turn.select[corner.color] || 0) + (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 (volcanoIdx === 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!`
);
if (game.turn.select) 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) {
const _sess = game.sessions[id];
if (_sess && _sess.player) {
sendUpdateToPlayer(game, _sess, {
private: _sess.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 || 0) + (player.wheat || 0) + (player.brick || 0) + (player.wood || 0) + (player.sheep || 0);
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 < staticData.tiles.length; 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) {
const _sess = game.sessions[key];
if (_sess && _sess.player === player) {
sendUpdateToPlayer(game, _sess, {
private: player,
});
break;
}
}
});
}
sendUpdateToPlayers(game, {
turn: game.turn,
players: getFilteredPlayers(game),
chat: game.chat,
dice: game.dice,
});
};
// newPlayer is provided by ./games/playerFactory
const getSession = (game: Game, id: string): Session => {
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,
short: `[${id.substring(0, 8)}]`,
name: "",
color: "unassigned",
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) {
continue;
}
// Treat the explicit "unassigned" sentinel as not set for expiring sessions
if ((_session.color && _session.color !== "unassigned") || _session.name || _session.player) {
continue;
}
if (_id === id) {
continue;
}
/* 60 minutes */
const age = Date.now() - (_session.lastActive || 0);
if (age > 60 * 60 * 1000) {
console.log(`${session.short}: Expiring old session ${_id}: ${age / (60 * 1000)} minutes`);
delete game.sessions[_id];
transientState.clearSession(game.id, _id);
}
}
transientState.preserveSession(game.id, session.id, session);
return game.sessions[id]!;
};
const loadGame = async (id: string): Promise<Game> => {
if (/^\.|\//.exec(id)) {
throw Error("Invalid game ID");
}
if (id in games) {
const cached = games[id]!;
return cached;
}
let game: Game | null = null;
try {
game = await gameDB.getGameById(id);
} catch (e) {
console.error(`${info}: gameDB.getGameById error`, e);
game = null;
}
if (game) {
// After loading, restore transient state
transientState.restoreGame(id, game);
for (let sid in game.sessions) {
if (game.sessions[sid]) {
transientState.restoreSession(id, sid, game.sessions[sid]);
}
}
}
if (!game) {
// If an id was requested, create the new game using that id
// so clients that connect to /ws/:id or POST /:id will get a game
// with the expected slug instead of a random one.
game = await createGame(id || null);
// Persist the newly-created game immediately
try {
await gameDB.saveGame(game);
} catch (e) {
console.error(`${info}: Failed to persist newly created game ${game.id}`, e);
}
}
/* Clear out cached names from player colors and rebuild them
* from the information in the saved game sessions */
for (let color in game.players) {
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 !== "unassigned" && 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 = "unassigned";
delete session.player;
}
session.live = false;
/* Populate the 'unselected' list from the session table */
if ((!session.color || session.color === "unassigned") && session.name) {
game.unselected.push(session);
}
}
/* Reconstruct turn.limits if in initial-placement state and limits are missing */
if (
game.state === "initial-placement" &&
game.turn &&
(!game.turn.limits || Object.keys(game.turn.limits).length === 0)
) {
console.log(`${info}: Reconstructing turn.limits for initial-placement state after reload`);
const currentColor = game.turn.color;
// Check if we need to place a settlement (no action or place-settlement action)
if (!game.turn.actions || game.turn.actions.length === 0 || game.turn.actions.indexOf("place-settlement") !== -1) {
setForSettlementPlacement(game, getValidCorners(game, currentColor));
console.log(
`${info}: Set turn limits for settlement placement (${game.turn.limits?.corners?.length || 0} valid corners)`
);
}
// Check if we need to place a road
else if (game.turn.actions.indexOf("place-road") !== -1) {
// Find the most recently placed settlement by the current player
let mostRecentSettlementIndex = -1;
if (game.placements && game.placements.corners) {
// Look for settlements of the current player's color
for (let i = game.placements.corners.length - 1; i >= 0; i--) {
const corner = game.placements.corners[i];
if (corner && corner.color === currentColor && corner.type === "settlement") {
mostRecentSettlementIndex = i;
break;
}
}
}
if (mostRecentSettlementIndex >= 0 && layout.corners?.[mostRecentSettlementIndex]?.roads) {
const roads = layout.corners?.[mostRecentSettlementIndex]?.roads || [];
setForRoadPlacement(game, roads);
console.log(
`${info}: Set turn limits for road placement (${roads.length} valid roads from settlement ${mostRecentSettlementIndex})`
);
} else {
// Fallback: Allow all valid roads for this player
const roads = getValidRoads(game, currentColor);
setForRoadPlacement(game, roads);
console.log(`${info}: Set turn limits for road placement (fallback: ${roads.length} valid roads)`);
}
}
}
games[id] = game;
return game;
};
const adminCommands = (game: Game, action: string, value: string, query: any): any => {
let color: string | undefined,
parts: RegExpMatchArray | null,
session: Session | any,
corners: any,
corner: any,
error: any;
void color;
switch (action) {
case "rules":
const rule = value.replace(/=.*$/, "");
if (rule === "list") {
const rules: any = {};
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 rulesObj: Record<string, any> = {};
rulesObj[rule] = {};
values.forEach((keypair) => {
let [key, val] = keypair.split(":");
let parsed: any = val;
if (val === "true") {
parsed = true;
} else if (val === "false") {
parsed = false;
} else if (typeof val === "string" && !isNaN(parseInt(val))) {
parsed = parseInt(val);
}
if (rule && key) rulesObj[rule][key] = parsed;
});
console.log(`admin - setRules -`, rulesObj);
setRules(game, undefined, rulesObj);
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;
if (game.sessions) {
for (let id in game.sessions) {
if (game.sessions[id] && 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 === "unassigned" ? "" : 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 === "unassigned" ? "" : 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 === "unassigned" ? "" : 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(String(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: any) => 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];
if (tmp) {
(tmp as any)["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: any) => `${card.type}-${card.card}`).join(", ");
return results;
case "roll":
let diceRaw = (query.dice || Math.ceil(Math.random() * 6)).toString();
let dice = diceRaw.split(",").map((die: string) => 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 (corner && corner.color && corner.color !== "unassigned") {
const player = game.players ? game.players[corner.color] : undefined;
if (player) {
if (corner.type === "city") {
if (player.settlements) {
addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`);
player.cities = (player.cities || 0) + 1;
player.settlements = (player.settlements || 1) - 1;
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 = (player.cities || 0) + 1;
}
} else {
addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
delete corner.type;
delete corner.color;
player.settlements = (player.settlements || 0) + 1;
}
}
}
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 || "");
if (!next) {
addChatMessage(game, null, `Admin attempted to skip turn but no next player was found.`);
break;
}
game.turn = {
name: next.name,
color: next.color,
} as unknown as Turn;
game.turns = (game.turns || 0) + 1;
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 (corner && corner.color && corner.color !== "unassigned") {
const player = game.players[corner.color];
if (player) {
if (corner.type === "city") {
if (player.settlements && player.settlements > 0) {
addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`);
player.cities = (player.cities || 0) + 1;
player.settlements = (player.settlements || 0) - 1;
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 = (player.cities || 0) + 1;
}
} else {
addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
delete corner.type;
delete corner.color;
player.settlements = (player.settlements || 0) + 1;
}
}
}
break;
case "state":
if (game.state !== "lobby") {
return `Game already started.`;
}
if (!game.active || 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) {
const p = game.players[key];
if (!p || p.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: Game, session: Session, name: string): string | undefined => {
if (session.name === name) {
return; /* no-op */
}
if (session.color !== "unassigned") {
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 || tmp === session || !tmp.name) {
continue;
}
if (tmp.name.toLowerCase() === name.toLowerCase()) {
if (!tmp.player || Date.now() - (tmp.player.lastActive || 0) > 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];
transientState.clearSession(game.id, 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 !== "unassigned") {
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]) {
webrtcPart(audio[game.id], session);
}
} else {
message = `${session.name} has changed their name to ${name}.`;
if (session.ws && game.id in audio) {
webrtcPart(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) {
webrtcJoin(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 === "unassigned") {
console.log(`${info}: Adding ${session.name} to the unselected`);
}
game.unselected = [];
for (let id in game.sessions) {
const s = game.sessions[id];
if (!s) continue;
if (!s.color && s.name) {
game.unselected.push(s);
}
}
sendUpdateToPlayer(game, session, {
name: session.name,
color: session.color,
live: session.live,
private: session.player,
});
sendUpdateToPlayers(game, {
players: getFilteredPlayers(game),
participants: getParticipants(game),
unselected: getFilteredUnselected(game),
chat: game.chat,
});
/* Now that a name is set, send the full game to the player */
sendGameToPlayer(game, session);
return undefined;
};
const colorToWord = (color: string): string => {
switch (color) {
case "O":
return "orange";
case "W":
return "white";
case "B":
return "blue";
case "R":
return "red";
default:
return "";
}
};
const getActiveCount = (game: Game): number => {
let active = 0;
for (let color in game.players) {
const p = game.players[color];
if (!p || !p.name) {
continue;
}
active++;
}
return active;
};
const setPlayerColor = (game: Game, session: Session, color: PlayerColor): string | undefined => {
/* 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) {
const candidate = game.players[color];
if (!candidate) {
return `An invalid player selection was attempted.`;
}
if (candidate.status !== "Not active") {
return `${candidate.name} already has ${colorToWord(color)}`;
}
}
let active = getActiveCount(game);
if (session.player) {
/* Deselect currently active player for this session */
clearPlayer(session.player);
// remove the player association
delete (session as any).player;
const old_color = session.color;
session.color = "unassigned";
active--;
/* If the player is not selecting a color, then return */
if (!color) {
const msg = String(session.name || "") + " is no longer " + String(colorToWord(String(old_color)));
addChatMessage(game, null, msg);
if (!game.unselected) game.unselected = [] as any[];
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;
const picked = game.players[color];
if (picked) {
(session as any).player = picked;
picked.name = session.name;
picked.status = `Active`;
picked.lastActive = Date.now();
picked.live = true;
}
addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`);
const update: any = {
players: getFilteredPlayers(game),
participants: getParticipants(game),
chat: game.chat,
};
/* Rebuild the unselected list */
const unselected = [];
for (let id in game.sessions) {
const s = game.sessions[id];
if (!s) continue;
if (!s.color && s.name) {
unselected.push(s);
}
}
if (!game.unselected) game.unselected = [] as any[];
if (unselected.length !== game.unselected.length) {
game.unselected = unselected;
update.unselected = getFilteredUnselected(game);
}
if (!game.active) game.active = 0;
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);
return undefined;
};
const processCorner = (game: Game, color: string, cornerIndex: number, placedCorner: CornerPlacement): number => {
/* If this corner is allocated and isn't assigned to the walking color, skip it */
if (placedCorner.color && placedCorner.color !== "unassigned" && 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: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
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: Game,
color: string,
cornerIndex: number,
placedCorner: CornerPlacement,
set: any
): void => {
/* If this corner is allocated and isn't assigned to the walking color, skip it */
if (placedCorner.color && placedCorner.color !== "unassigned" && 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: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
buildRoadGraph(game, color, roadIndex, placedRoad, set);
});
};
const processRoad = (game: Game, color: string, roadIndex: number, placedRoad: RoadPlacement): number => {
/* 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: number) => {
const placedCorner = game.placements.corners?.[cornerIndex];
if (!placedCorner) return;
if (placedCorner.walking) {
return;
}
roadLength += processCorner(game, color, cornerIndex, placedCorner);
});
return roadLength;
};
const buildRoadGraph = (game: Game, color: string, roadIndex: number, placedRoad: RoadPlacement, set: number[]) => {
/* 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: number) => {
const placedCorner = game.placements?.corners?.[cornerIndex];
if (!placedCorner) return;
buildCornerGraph(game, color, cornerIndex, placedCorner, set);
});
};
const clearRoadWalking = (game: Game): void => {
/* Clear out walk markers on roads */
layout.roads.forEach((_item, itemIndex) => {
if (game.placements?.roads?.[itemIndex]) {
delete game.placements.roads[itemIndex].walking;
}
});
/* Clear out walk markers on corners */
layout.corners.forEach((_item, itemIndex) => {
if (game.placements?.corners?.[itemIndex]) {
delete game.placements.corners[itemIndex].walking;
}
});
};
const calculateRoadLengths = (game: Game, session: Session): void => {
clearRoadWalking(game);
let currentLongest = game.longestRoad,
currentLength =
currentLongest && typeof currentLongest === "string" && game.players[currentLongest]
? game.players[currentLongest].longestRoad || -1
: -1;
/* Clear out player longest road counts */
for (let key in game.players) {
if (game.players[key]) {
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: { color: string; set: number[]; longestRoad?: number; longestStartSegment?: number }[] = [];
layout.roads.forEach((_: any, roadIndex: number) => {
const placedRoad = game.placements?.roads?.[roadIndex];
if (placedRoad && placedRoad.color && placedRoad.color !== "unassigned" && typeof placedRoad.color === "string") {
let set: number[] = [];
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: any) => {
graph.longestRoad = 0;
graph.set.forEach((roadIndex: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
clearRoadWalking(game);
const length = processRoad(game, placedRoad.color as string, 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]?.status === "Not active") {
continue;
}
if (game.players[color]) {
game.players[color].longestRoad = 0;
}
}
graphs.forEach((graph: any) => {
graph.set.forEach((roadIndex: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
clearRoadWalking(game);
const longestRoad = processRoad(game, placedRoad.color as string, roadIndex, placedRoad);
placedRoad["longestRoad"] = longestRoad;
if (placedRoad.color && placedRoad.color !== "unassigned" && typeof placedRoad.color === "string") {
const player = game.players[placedRoad.color];
if (player) {
const prevVal = player["longestRoad"] || 0;
player["longestRoad"] = Math.max(prevVal, longestRoad);
}
}
});
});
game.placements.roads.forEach((road: any) => delete road.walking);
if (debug.road)
console.log(
"Post update:",
game.placements.roads.filter((road: any) => road.color)
);
let checkForTies = false;
if (debug.road) console.log(currentLongest, currentLength);
if (currentLongest && typeof currentLongest === "string" && game.players[currentLongest]) {
const playerLongest = game.players[currentLongest]["longestRoad"] || 0;
if (playerLongest < currentLength) {
const prevSession = sessionFromColor(game, currentLongest as string);
if (prevSession) {
addChatMessage(game, prevSession, `${prevSession.name} had their longest road split!`);
}
checkForTies = true;
}
}
let longestRoad = 4;
let longestPlayers: Player[] = [];
for (let key in game.players) {
const player = game.players[key];
if (!player || player.status === "Not active") {
continue;
}
const pLen = player.longestRoad || 0;
if (pLen > longestRoad) {
longestPlayers = [player];
longestRoad = pLen;
} else if (pLen === longestRoad) {
if (longestRoad >= 5) {
longestPlayers.push(player);
}
}
}
console.log({ longestPlayers });
if (longestPlayers.length > 0) {
if (longestPlayers.length === 1) {
game.longestRoadLength = longestRoad;
if (longestPlayers[0] && longestPlayers[0].color) {
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: Player, offer: Offer): boolean => {
const isBank = (offer as any)["name"] === "The bank";
const playerGetsLen = (player as any)["gets"] ? (player as any)["gets"].length : 0;
const playerGivesLen = (player as any)["gives"] ? (player as any)["gives"].length : 0;
const offerGetsLen = (offer as any)["gets"] ? (offer as any)["gets"].length : 0;
const offerGivesLen = (offer as any)["gives"] ? (offer as any)["gives"].length : 0;
let valid = playerGetsLen === offerGivesLen && playerGivesLen === offerGetsLen;
if (!valid) {
console.log(`Gives and gets lengths do not match!`);
return false;
}
console.log(
{
player: "Submitting player",
gets: (player as any)["gets"],
gives: (player as any)["gives"],
},
{
name: (offer as any)["name"],
gets: (offer as any)["gets"],
gives: (offer as any)["gives"],
}
);
for (const get of (player as any)["gets"] || []) {
if (
!(offer as any)["gives"] ||
!(offer as any)["gives"].some((item: any) => (item.type === get.type || isBank) && item.count === get.count)
) {
valid = false;
break;
}
}
if (valid) {
for (const give of (player as any)["gives"] || []) {
if (
!(offer as any)["gets"] ||
!(offer as any)["gets"].some((item: any) => (item.type === give.type || isBank) && item.count === give.count)
) {
valid = false;
break;
}
}
}
return valid;
};
const isSameOffer = (player: Player, offer: Offer): boolean => {
const isBank = (offer as any)["name"] === "The bank";
if (isBank) {
return false;
}
if (!(player as any)["gets"] || !(player as any)["gives"] || !(offer as any)["gets"] || !(offer as any)["gives"]) {
return false;
}
if (
(player as any)["gets"].length !== (offer as any)["gets"].length ||
(player as any)["gives"].length !== (offer as any)["gives"].length
) {
return false;
}
for (const get of (player as any)["gets"]) {
if (!(offer as any)["gets"].find((item: any) => item.type === get.type && item.count === get.count)) {
return false;
}
}
for (const give of (player as any)["gives"]) {
if (!(offer as any)["gives"].find((item: any) => item.type === give.type && item.count === give.count)) {
return false;
}
}
return true;
};
/* Verifies player can meet the offer */
const checkPlayerOffer = (_game: Game, player: Player, offer: Offer): string | undefined => {
let error: string | undefined = undefined;
const name = player.name || "Unknown";
console.log({
checkPlayerOffer: {
name,
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),
},
});
for (const give of (offer as any)["gives"] || []) {
if (error) break;
if (!(give.type in (player as any))) {
error = `${give.type} is not a valid resource!`;
break;
}
if (give.count <= 0) {
error = `${give.count} must be more than 0!`;
break;
}
if ((player as any)[give.type] < give.count) {
error = `${name} does do not have ${give.count} ${give.type}!`;
break;
}
if (((offer as any)["gets"] || []).find((get: any) => give.type === get.type)) {
error = `${name} can not give and get the same resource type!`;
break;
}
}
if (!error) {
for (const get of (offer as any)["gets"] || []) {
if (error) break;
if (get.count <= 0) {
error = `${get.count} must be more than 0!`;
break;
}
if (((offer as any)["gives"] || []).find((give: any) => get.type === give.type)) {
error = `${name} can not give and get the same resource type!`;
break;
}
}
}
return error;
};
const canMeetOffer = (player: Player, offer: Offer): boolean => {
for (const get of (offer as any)["gets"] || []) {
if (get.type === "bank") {
const giveType =
(player as any)["gives"] && (player as any)["gives"][0] ? (player as any)["gives"][0].type : undefined;
if (!giveType) return false;
if ((player as any)[giveType] < get.count || get.count <= 0) {
return false;
}
} else if ((player as any)[get.type] < get.count || get.count <= 0) {
return false;
}
}
return true;
};
const setGameFromSignature = (game: Game, border: string, pip: string, tile: string): boolean => {
const salt = 251;
const borders = [],
pips = [],
tiles = [];
for (let i = 0; i < 6; i++) {
const parsed = parseInt(border.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(parsed)) return false;
borders[i] = parsed ^ salt;
if (borders[i]! > 6) {
return false;
}
}
for (let i = 0; i < staticData.tiles.length; i++) {
const parsed = parseInt(pip.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(parsed)) return false;
// Ensure we decode to a single byte explicitly. Parenthesize to avoid
// surprises from operator precedence (% vs ^) and mask to 0..255.
pips[i] = (parsed ^ salt ^ (salt * i)) & 0xff;
if (pips[i]! > staticData.tiles.length - 1) {
return false;
}
}
for (let i = 0; i < staticData.tiles.length; i++) {
const parsed = parseInt(tile.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(parsed)) return false;
// As above, decode unambiguously into a single byte value.
tiles[i] = (parsed ^ salt ^ (salt * i)) & 0xff;
if (tiles[i]! > staticData.tiles.length - 1) {
return false;
}
}
game.borderOrder = borders;
game.pipOrder = pips;
game.tileOrder = tiles;
return true;
};
const offerToString = (offer: Offer): string => {
return (
(offer.gives || []).map((item) => `${item.count} ${item.type}`).join(", ") +
" in exchange for " +
(offer.gets || []).map((item) => `${item.count} ${item.type}`).join(", ")
);
};
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: Game, session: Session): string | undefined => {
/* 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) {
const p = game.players[key];
if (!p) continue;
(p as any)["gives"] = [];
(p as any)["gets"] = [];
delete (p as any)["offerRejected"];
}
addActivity(game, session, `${session.name} requested to begin trading negotiations.`);
return undefined;
};
const cancelTrade = (game: Game, session: Session): string | undefined => {
/* 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.`);
return undefined;
};
const processOffer = (game: Game, session: Session, offer: Offer): string | undefined => {
const player = session.player as Player;
let warning = checkPlayerOffer(game, player, offer);
if (warning) {
return warning;
}
if (isSameOffer(player, offer)) {
console.log(player);
return `You already have a pending offer submitted for ${offerToString(offer)}.`;
}
(player as any)["gives"] = (offer as any)["gives"];
(player as any)["gets"] = (offer as any)["gets"];
(player as any)["offerRejected"] = {};
if (game.turn.color === session.color) {
game.turn.offer = offer;
}
/* If this offer matches what another player wants, clear rejection on that other player's offer */
for (const color in game.players) {
if (color === session.color) continue;
const other = game.players[color];
if (!other) continue;
if ((other as any)["status"] !== "Active") continue;
/* Comparison reverses give/get order */
if (isSameOffer(other, { gives: (offer as any)["gets"], gets: (offer as any)["gives"] } as Offer)) {
if ((other as any)["offerRejected"]) {
delete (other as any)["offerRejected"][session.color as string];
}
}
}
addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`);
return undefined;
};
const rejectOffer = (game: Game, session: Session, offer: Offer): void => {
/* If the active player rejected an offer, they rejected another player */
const other = game.players[(offer as any)["color"] as string];
if (!other) return;
if (!(other as any)["offerRejected"]) {
(other as any)["offerRejected"] = {};
}
(other as any)["offerRejected"][session.color as string] = true;
if (!session.player) session.player = {} as Player;
if (!(session.player as any)["offerRejected"]) {
(session.player as any)["offerRejected"] = {};
}
(session.player as any)["offerRejected"][(offer as any)["color"] as string] = true;
addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`);
};
const acceptOffer = (game: Game, session: Session, offer: Offer): string | undefined => {
const name = session.name,
player = session.player as Player;
if (game.turn.name !== name) {
return `Only the active player can accept an offer.`;
}
let target: any = undefined;
console.log({ description: offerToString(offer) });
let warning = checkPlayerOffer(game, player, offer);
if (warning) {
return warning;
}
if (
!isCompatibleOffer(player, {
name: (offer as any)["name"],
gives: (offer as any)["gets"],
gets: (offer as any)["gives"],
} as Offer)
) {
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 as any)["name"] || (offer as any)["name"] !== "The bank") {
target = game.players[(offer as any)["color"] as string];
if (!target) return `Invalid trade target.`;
if ((target as any)["offerRejected"] && (offer as any)["color"] in (target as any)["offerRejected"]) {
return `${target.name} rejected this offer.`;
}
if (!isCompatibleOffer(target as Player, offer)) {
return `Unfortunately, trades were re-negotiated in transit and ` + `the deal is invalid!`;
}
warning = checkPlayerOffer(
game,
target as Player,
{
gives: offer.gets,
gets: offer.gives,
} as Offer
);
if (warning) {
return warning;
}
if (!isSameOffer(target as Player, { gives: (offer as any)["gets"], gets: (offer as any)["gives"] } as Offer)) {
console.log({ target, offer });
return `These terms were not agreed to by ${target.name}!`;
}
if (!canMeetOffer(target as Player, player as any)) {
return `${target.name} cannot meet the terms.`;
}
} else {
target = offer;
}
debugChat(game, "Before trade");
/* Transfer goods */
for (const item of (offer as any)["gets"] || []) {
if ((target as any)["name"] !== "The bank") {
(target as any)[item.type] -= item.count;
(target as any).resources -= item.count;
}
(player as any)[item.type] += item.count;
(player as any).resources += item.count;
}
for (const item of (offer as any)["gives"] || []) {
if ((target as any)["name"] !== "The bank") {
(target as any)[item.type] += item.count;
(target as any).resources += item.count;
}
(player as any)[item.type] -= item.count;
(player as any).resources -= item.count;
}
const from = (offer as any)["name"] === "The bank" ? "the bank" : (offer as any)["name"];
addChatMessage(game, session, `${session.name} traded ` + ` ${offerToString(offer)} ` + `from ${from}.`);
addActivity(game, session, `${session.name} accepted a trade from ${from}.`);
delete (game.turn as any)["offer"];
if (target) {
delete (target as any).gives;
delete (target as any).gets;
}
if (session.player) {
delete (session.player as any)["gives"];
delete (session.player as any)["gets"];
}
delete (game.turn as any)["offer"];
debugChat(game, "After trade");
/* Debug!!! */
for (const key in game.players) {
const p = game.players[key];
if (!p) continue;
if ((p as any)["state"] !== "Active") {
continue;
}
types.forEach((type) => {
if ((p as any)[type] < 0) {
throw new Error(`Player resources are below zero! BUG BUG BUG!`);
}
});
}
game.turn.actions = [];
return undefined;
};
const trade = (game: Game, session: Session, action: string, offer?: Offer): string | undefined => {
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 as Offer);
}
/* Any player can reject an offer */
if (action === "reject") {
rejectOffer(game, session, offer as Offer);
return undefined;
}
/* Only the active player can accept an offer */
if (action === "accept") {
if (offer && (offer as any)["name"] === "The bank") {
if (!session.player) session.player = {} as Player;
(session.player as any)["gets"] = (offer as any)["gets"];
(session.player as any)["gives"] = (offer as any)["gives"];
}
return acceptOffer(game, session, offer as Offer);
}
return undefined;
};
const clearTimeNotice = (game: Game, session: Session): string | undefined => {
if (!session.player || !session.player.turnNotice) {
/* benign state; don't alert the user */
//return `You have not been idle.`;
}
if (session.player) session.player.turnNotice = "";
sendUpdateToPlayer(game, session, {
private: session.player,
});
return undefined;
};
const pass = (game: any, session: any): string | undefined => {
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);
if (!next) {
return `Unable to find the next player to pass to.`;
}
session.player.totalTime += Date.now() - session.player.turnStart;
session.player.turnNotice = "";
game.turn = {
name: next.name,
color: next.color,
};
if (next.player) {
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,
});
return undefined;
};
const placeRobber = (game: Game, session: Session, robber: number | string): string | undefined => {
const name = session.name;
let robberIdx = typeof robber === "string" ? parseInt(robber) : 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 (const color in game.players) {
const p = game.players[color];
if (!p) continue;
if (p.status === "Not active") continue;
if ((p.mustDiscard || 0) > 0) {
return `You cannot place the robber until everyone has discarded!`;
}
}
if (game.robber === robberIdx) {
return `You must move the robber to a new location!`;
}
game.robber = robberIdx as number;
(game.turn as any).placedRobber = true;
pickRobber(game);
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
const targets: Array<{ color: string; name: string }> = [];
layout.tiles?.[robberIdx as number]?.corners?.forEach((cornerIndex: number) => {
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 } as any;
} 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,
});
return undefined;
};
const stealResource = (game: Game, session: Session, color: string): string | undefined => {
if (!game.turn.actions || game.turn.actions.indexOf("steal-resource") === -1) {
return `You can only steal a resource when it is valid to do so!`;
}
const playersLimit = (game.turn.limits as any)?.players || [];
if (playersLimit.findIndex((item: any) => item.color === color) === -1) {
return `You can only steal a resource from a player on this terrain!`;
}
const victimSession = sessionFromColor(game, color);
if (!victimSession || !victimSession.player) {
return `You sent a weird color for the target to steal from.`;
}
const victimPlayer = victimSession.player as Player;
const sessionPlayer = session.player as Player;
const cards: string[] = [];
["wheat", "brick", "sheep", "stone", "wood"].forEach((field: string) => {
for (let i = 0; i < ((victimPlayer as any)[field] || 0); i++) {
cards.push(field);
}
});
debugChat(game, "Before steal");
if (cards.length === 0) {
addChatMessage(game, session, `${victimSession.name} did not have any cards for ${session.name} to steal.`);
game.turn.actions = [];
game.turn.limits = {} as any;
} else {
const idx = Math.floor(Math.random() * cards.length);
const type = cards[idx];
if (!type) {
game.turn.actions = [];
game.turn.limits = {} as any;
return undefined;
}
const t = String(type);
// adjust typed resource counts via helper
adjustResources(victimPlayer as Player, { [t]: -1 });
adjustResources(sessionPlayer as Player, { [t]: 1 });
game.turn.actions = [];
game.turn.limits = {} as any;
trackTheft(game, (victimSession as any).color || "", session.color, type, 1);
addChatMessage(game, session, `${session.name} randomly stole 1 ${type} from ${victimSession.name}.`);
sendUpdateToPlayer(game, victimSession, {
private: victimSession.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),
});
return undefined;
};
const buyDevelopment = (game: Game, session: Session): string | undefined => {
const player = session.player as 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 || game.developmentCards.length < 1) {
return `There are no more development cards!`;
}
if ((player.stone || 0) < 1 || (player.wheat || 0) < 1 || (player.sheep || 0) < 1) {
return `You have insufficient resources to purchase a development card.`;
}
if ((game.turn as any).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.stone || 0) - 1;
player.wheat = (player.wheat || 0) - 1;
player.sheep = (player.sheep || 0) - 1;
player.resources = 0;
player.developmentCards = (player.developmentCards || 0) + 1;
["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => {
player.resources = (player.resources || 0) + ((player as any)[resource] || 0);
});
debugChat(game, "After development purchase");
const card = (game.developmentCards || []).pop();
if (card) {
(card as any).turn = game.turns ? game.turns - 1 : 0;
if (!player.development) player.development = [] as any;
(player.development as any).push(card as any);
}
if (isRuleEnabled(game, "most-developed")) {
if (
(player.development?.length || 0) >= 5 &&
(!(game as any)["mostDeveloped"] ||
(player.developmentCards || 0) > (game.players[(game as any)["mostDeveloped"] as string]?.developmentCards || 0))
) {
if ((game as any)["mostDeveloped"] !== session.color) {
(game as any)["mostDeveloped"] = session.color;
(game as any)["mostPortCount"] = 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 as any)["mostDeveloped"],
players: getFilteredPlayers(game),
});
return undefined;
};
const playCard = (game: Game, session: Session, card: any): string | undefined => {
const name = session.name;
const player = session.player as 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: any) => item.type == card.type && item.card == card.card && !(item.card as any).played
);
if (!card) {
return `The card you want to play was not found in your hand!`;
}
if ((player as any)["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 || 0;
(player.development || []).forEach((item: any) => {
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 || 0, 2);
if (!allowed) {
addChatMessage(game, session, `${session.name} played a Road Building card, but has no roads to build.`);
break;
}
const roads = getValidRoads(game, session.color as string);
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" as any;
(game.turn as any).free = true;
(game.turn as any).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" as any;
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" as any;
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 as any).played = true;
(player as any)["playedCard"] = game.turns;
if (card.type === "army") {
(player as any)["army"] = ((player as any)["army"] || 0) + 1;
addChatMessage(game, session, `${session.name} played a Knight and must move the robber!`);
if (
(player as any)["army"] > 2 &&
(!(game as any)["largestArmy"] ||
((game.players as any)[(game as any)["largestArmy"]]?.army || 0) < (player as any)["army"])
) {
if ((game as any)["largestArmy"] !== session.color) {
(game as any)["largestArmy"] = session.color;
(game as any)["largestArmySize"] = (player as any)["army"];
addChatMessage(game, session, `${session.name} now has the largest army (${(player as any)["army"]})!`);
}
}
game.turn.robberInAction = true;
delete (game.turn as any).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: [] } as any;
for (let i = 0; i < staticData.tiles.length; i++) {
if (i === game.robber) continue;
(game.turn.limits as any).pips.push(i);
}
}
sendUpdateToPlayer(game, session, {
private: session.player,
});
sendUpdateToPlayers(game, {
chat: game.chat,
activities: game.activities,
largestArmy: (game as any)["largestArmy"],
largestArmySize: (game as any)["largestArmySize"],
turn: game.turn,
players: getFilteredPlayers(game),
});
return undefined;
};
const placeSettlement = (game: Game, session: Session, index: number | string): string | undefined => {
if (!session.player) return `You are not playing a player.`;
const player: any = session.player;
const anyGame: any = game as any;
if (typeof index === "string") 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 (
!anyGame.placements ||
anyGame.placements.corners === undefined ||
anyGame.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 = anyGame.placements.corners[index];
if (corner.color) {
const owner = game.players && game.players[corner.color];
const ownerName = owner ? owner.name : "unknown";
return `This location already has a settlement belonging to ${ownerName}!`;
}
if (!player.banks) {
player.banks = [];
}
if (game.state === "normal") {
if (!game.turn.free) {
if ((player.brick || 0) < 1 || (player.wood || 0) < 1 || (player.wheat || 0) < 1 || (player.sheep || 0) < 1) {
return `You have insufficient resources to build a settlement.`;
}
}
if ((player.settlements || 0) < 1) {
return `You have already built all of your settlements.`;
}
player.settlements = (player.settlements || 0) - 1;
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.brick || 0) - 1;
player.wood = (player.wood || 0) - 1;
player.wheat = (player.wheat || 0) - 1;
player.sheep = (player.sheep || 0) - 1;
player.resources = 0;
["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => {
player.resources += player[resource] || 0;
});
}
delete game.turn.free;
corner.color = session.color;
corner.type = "settlement";
let bankType = undefined;
const banks = layout.corners?.[index]?.banks;
if (banks && banks.length) {
banks.forEach((bank: any) => {
const border = anyGame.borderOrder[Math.floor(bank / 3)],
type = anyGame.borders?.[border]?.[bank % 3];
console.log(`${session.short}: Bank ${bank} = ${type}`);
if (!type) {
console.log(`${session.short}: 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, anyGame.mostPorts);
if (player.ports >= 3 && (!anyGame.mostPorts || player.ports > anyGame.mostPortCount)) {
if (anyGame.mostPorts !== session.color) {
anyGame.mostPorts = session.color;
anyGame.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 (anyGame.direction && anyGame.direction === "backward") {
(session as any).initialSettlement = index;
}
corner.color = session.color || "";
corner.type = "settlement";
let bankType = undefined;
const banks2 = layout.corners?.[index]?.banks;
if (banks2 && banks2.length) {
banks2.forEach((bank: any) => {
const border = anyGame.borderOrder[Math.floor(bank / 3)],
type = anyGame.borders?.[border]?.[bank % 3];
console.log(`${session.short}: 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 = (player.settlements || 0) - 1;
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,
turn: game.turn,
chat: game.chat,
state: game.state,
longestRoad: game.longestRoad,
longestRoadLength: game.longestRoadLength,
players: getFilteredPlayers(game),
});
return undefined;
};
const placeRoad = (game: Game, session: Session, index: number): string | undefined => {
if (!session.player) {
return `You are not playing a player.`;
}
const player: Player = session.player;
if (!game || !game.turn) {
return `Invalid game state.`;
}
if (session.color !== game.turn.color) {
return `It is not your turn! It is ${game.turn.name}'s turn.`;
}
if (game.placements.roads[index] === undefined) {
return `You have requested to place a road illegally!`;
}
if (!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.`;
}
if (!game.turn.free) {
addChatMessage(game, session, `${session.name} spent 1 brick and 1 wood to build a road.`);
player.brick--;
player.wood--;
player.resources = 0;
["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => {
player.resources += player[resource];
});
}
delete game.turn.free;
}
road.color = session.color;
road.type = "road";
player.roads--;
/* During initial placement, placing a road advances the initial-placement
* sequence. In forward direction we move to the next player; when the
* last player places their road we flip to backward and begin the reverse
* settlement placements. In backward direction we move to the previous
* player and when the first player finishes, initial placement is done
* and normal play begins. */
if (game.state === "initial-placement") {
const order: PlayerColor[] = game.playerOrder;
const idx = order.indexOf(session.color);
// defensive: if player not found, just clear actions and continue
if (idx === -1 || order.length === 0) {
game.turn.actions = [];
game.turn.limits = {};
} else {
const direction = game.direction || "forward";
if (direction === "forward") {
if (idx === order.length - 1) {
// Last player in forward pass: switch to backward and allow that
// same last player to place a settlement to begin reverse pass.
game.direction = "backward";
const nextColor = order[order.length - 1];
if (nextColor && game.players && game.players[nextColor]) {
const limits = getValidCorners(game);
console.log(
`${info}: initial-placement - ${
session.name
} placed road; direction=forward; next=${nextColor}; nextName=${game.players[nextColor].name}; corners=${
limits ? limits.length : 0
}`
);
game.turn = {
name: game.players[nextColor].name,
color: nextColor,
} as unknown as Turn;
// During initial placement, settlements may be placed on any valid corner
setForSettlementPlacement(game, limits);
}
addChatMessage(
game,
null,
`Initial placement now proceeds in reverse order. It is ${game.turn.name}'s turn to place a settlement.`
);
} else {
const nextColor = order[idx + 1];
if (nextColor && game.players && game.players[nextColor]) {
game.turn = {
name: game.players[nextColor].name,
color: nextColor,
} as unknown as Turn;
setForSettlementPlacement(game, getValidCorners(game, nextColor));
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
}
}
} else {
// backward
if (idx === 0) {
// Finished reverse initial placement; move to normal play and first player's turn
game.state = "normal";
const firstColor = order[0];
if (firstColor && game.players && game.players[firstColor]) {
game.turn = {
name: game.players[firstColor].name,
color: firstColor,
} as unknown as Turn;
}
addChatMessage(game, null, `Initial placement complete. It is ${game.turn.name}'s turn.`);
} else {
const nextColor = order[idx - 1];
if (nextColor && game.players && game.players[nextColor]) {
const limits = getValidCorners(game);
console.log(
`${info}: initial-placement - ${
session.name
} placed road; direction=backward; next=${nextColor}; nextName=${game.players[nextColor].name}; corners=${
limits ? limits.length : 0
}`
);
game.turn = {
name: game.players[nextColor].name,
color: nextColor,
} as unknown as Turn;
// During initial placement, settlements may be placed on any valid corner
setForSettlementPlacement(game, limits);
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
}
}
}
}
} else {
game.turn.actions = [];
game.turn.limits = {};
}
calculateRoadLengths(game, session);
sendUpdateToPlayer(game, session, {
private: session.player,
});
sendUpdateToPlayers(game, {
placements: game.placements,
turn: game.turn,
chat: game.chat,
activities: game.activities,
players: getFilteredPlayers(game),
state: game.state,
direction: (game as any)["direction"],
});
return undefined;
};
const discard = (game: any, session: any, discards: Record<string, any>): string | undefined => {
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) {
const val = discards[type];
const parsed = typeof val === "string" ? parseInt(val) : Number(val);
if (player[type] < parsed) {
return `You have requested to discard more ${type} than you have.`;
}
sum += parsed;
}
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 < staticData.tiles.length; 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,
});
return undefined;
};
const buyRoad = (game: any, session: any): string | undefined => {
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,
});
return undefined;
};
const selectResources = (game: any, session: any, cards: string[]): string | undefined => {
const player = session.player;
void 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: string): boolean => {
switch (type.trim()) {
case "wheat":
case "brick":
case "sheep":
case "stone":
case "wood":
return true;
default:
return false;
}
};
const selected: Record<string, number> = {};
for (const card of cards) {
if (!isValidCard(card)) {
return `Invalid resource type!`;
}
selected[card] = (selected[card] || 0) + 1;
}
const display: string[] = [];
for (let card in selected) {
display.push(`${selected[card]} ${card}`);
}
switch (game.turn.active) {
case "monopoly":
const gave: string[] = [],
type = String(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 as any)[type]) {
gave.push(`${player.name} gave ${(player as any)[type]} ${type}`);
(session.player as any)[type] += (player as any)[type];
session.resources += (player as any)[type];
total += (player as any)[type];
(player as any)[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),
});
return undefined;
};
const buySettlement = (game: any, session: any): string | undefined => {
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,
});
return undefined;
};
const buyCity = (game: any, session: any): string | undefined => {
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,
});
return undefined;
};
const placeCity = (game: any, session: any, index: any): string | undefined => {
const player = session.player;
if (typeof index === "string") 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 !== "unassigned" && 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) {
delete game.turn.free;
}
debugChat(game, "Before city placement");
corner.color = session.color;
corner.type = "city";
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 placement");
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),
});
return undefined;
};
const ping = (session: 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(`${session.short}: Sending ping to ${session.name}`);
try {
session.ws.send(JSON.stringify({ type: "ping", ping: session.ping }));
} catch (e) {
console.error(`${session.id}: Failed to send ping:`, e);
// If send fails, the socket is likely dead - clean up
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
return;
}
// Clear any existing timeout
if (session.keepAlive) {
clearTimeout(session.keepAlive);
}
// Set timeout to disconnect if no pong received within 20 seconds
session.keepAlive = setTimeout(() => {
console.warn(`${session.id}: No pong received from ${session.name} within 20s, closing connection`);
if (session.ws) {
try {
session.ws.close();
} catch (e) {
console.error(`${session.id}: Error closing socket:`, e);
}
}
session.ws = undefined;
session.keepAlive = undefined;
}, 20000);
};
// Add new function to schedule recurring pings
const schedulePing = (session: Session) => {
// Diagnostic logging to help detect multiple intervals being created
try {
console.log(
`${session.short}: schedulePing called for ${getName(session)} - existing pingInterval? ${!!session.pingInterval}`
);
} catch (e) {
/* ignore logging errors */
}
if (session.pingInterval) {
// Clear any previous interval before creating a new one
try {
clearInterval(session.pingInterval);
} catch (e) {
console.warn(`${session.short}: Failed to clear previous pingInterval:`, e);
}
}
// Send ping every 10 seconds
session.pingInterval = setInterval(() => {
ping(session);
}, 10000);
try {
console.log(`${session.short}: pingInterval started for ${getName(session)}`);
} catch (e) {
/* ignore logging errors */
}
};
// wsInactive not present in this refactor; no-op placeholder removed
const setGame = (game: any, session: any, state: any): string | undefined => {
if (!state) {
return `Invalid state.`;
}
if (session.color === "unassigned") {
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;
}
return undefined;
};
const resetDisconnectCheck = (_game: any, req: any): void => {
void _game;
if (req.disconnectCheck) {
clearTimeout(req.disconnectCheck);
}
//req.disconnectCheck = setTimeout(() => { wsInactive(game, req) }, 20000);
};
// WebRTC join/part handling moved to server/routes/webrtc-signaling.ts
// use webrtcJoin(audio[gameId], session, config) and webrtcPart(audio[gameId], session)
const departLobby = (game: any, session: any, _color?: string): void => {
const update: any = {};
update.unselected = getFilteredUnselected(game);
if (session.player) {
session.player.live = false;
update.players = game.players;
}
if (session.name) {
if (session.color !== "unassigned") {
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.short}: 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];
transientState.clearSession(game.id, id);
break;
}
}
}
sendUpdateToPlayers(game, update);
};
const sendGameToPlayer = (game: any, session: any): void => {
console.log(`${session.short}: -> sendGamePlayer:${getName(session)} - full game`);
if (!session.ws) {
console.log(`${session.short}: -> sendGamePlayer:: Currently no connection`);
return;
}
let update: any;
/* Only send empty name data to unnamed players */
if (!session.name) {
console.log(`${session.short}: -> 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: any): void => {
console.log(`${all}: -> sendGamePlayers - full game`);
for (let key in game.sessions) {
sendGameToPlayer(game, game.sessions[key]);
}
};
const getFilteredUnselected = (game: any): string[] => {
if (!game.unselected) {
return [];
}
return game.unselected.filter((session: any) => session.live).map((session: any) => session.name);
};
const parseChatCommands = (game: any, message: string): void => {
/* Chat messages can set game flags and fields */
const partsRaw = message.match(/^set +([^ ]*) +(.*)$/i) as RegExpMatchArray | null;
if (!partsRaw || partsRaw.length !== 3) {
return;
}
const parts = partsRaw as RegExpMatchArray;
const key = parts[1] || "";
switch (key.toLowerCase()) {
case "game":
if (parts[2] && parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) {
setBeginnerGame(game);
addChatMessage(game, null, `Game board set to the Beginner's Layout.`);
break;
}
const signature = parts[2] ? parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i) : null;
if (signature) {
if (setGameFromSignature(game, signature[1] || "", signature[2] || "", signature[3] || "")) {
game.signature = parts[2];
addChatMessage(game, null, `Game board set to ${parts[2]}.`);
} else {
addChatMessage(game, null, `Requested an invalid game board.`);
}
}
break;
}
};
const sendError = (session: Session, error: string): void => {
console.error(`${session.short}: Error: ${error}`);
try {
session.ws.send(JSON.stringify({ type: "error", data: error }));
} catch (e) {
/* ignore */
}
};
const sendWarning = (session: Session, warning: string): void => {
console.warn(`${session.short}: Warning: ${warning}`);
try {
session?.ws?.send(JSON.stringify({ type: "warning", warning }));
} catch (e) {
/* ignore */
}
};
/**
* Get participants list for the game room
* Uses the reusable room helper and adds game-specific data (color)
*
* This demonstrates how to extend the base participant list with app-specific data
*/
const getParticipants = (game: any): any[] => {
// Use the reusable room helper for base participant data
// If you were using the new architecture, this would be:
// import { getParticipants as getBaseParticipants } from './room/helpers';
// const baseParticipants = getBaseParticipants(game.sessions);
const participants: any[] = [];
for (let id in game.sessions) {
const session = game.sessions[id];
if (!session) continue;
// Base participant data (reusable across any application)
const baseParticipant = {
name: session.name || null,
session_id: session.id,
live: session.live || false,
protected: session.protected || false,
has_media: session.has_media !== false,
bot_run_id: session.bot_run_id || null,
bot_provider_id: session.bot_provider_id || null,
bot_instance_id: session.bot_instance_id || null,
muted: session.muted || false,
video_on: session.video_on !== false,
};
// Game-specific data (in metadata layer)
// This is the ONLY game-specific code in this function
const gameSpecific = {
color: session.color || null, // Game-specific: player color
// In the new architecture, this would be: session.metadata?.color
};
participants.push({ ...baseParticipant, ...gameSpecific });
}
return participants;
};
const clearGame = (game: any, _session: any): string | undefined => {
void _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);
return undefined;
};
const gotoLobby = (game: any, session: any): string | undefined => {
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,
});
return undefined;
};
router.ws("/ws/:id", async (ws, req) => {
console.log("New WebSocket connection");
if (!req.cookies || !(req.cookies as any)["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;
if (!gameId) {
console.log("Missing game id");
try {
ws.send(JSON.stringify({ type: "error", error: "Missing game id" }));
} catch (e) {}
try {
console.log("Missing game id");
ws.close && ws.close(1008, "Missing game id");
} catch (e) {}
return;
}
const playerCookie = req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "";
const short = playerCookie ? `[${playerCookie.substring(0, 8)}]` : "[unknown]";
(ws as any).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 (!(gameId in audio)) {
audio[gameId] = {}; /* List of peer sockets using session.name as index. */
console.log(`${short}: Game ${gameId} - New Game Audio`);
} else {
console.log(`${short}: Game ${gameId} - 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 && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : ""
);
if (!_session) return;
const session = _session;
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) {
// Clear ping interval
if (session.pingInterval) {
clearInterval(session.pingInterval);
session.pingInterval = undefined;
}
// Clear keepAlive timeout
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
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 && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : ""
);
if (!_session) return;
const session = _session;
if (session.player) {
session.player.live = false;
}
session.live = false;
session.initialSnapshotSent = 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) {
// Clear ping interval
if (session.pingInterval) {
clearInterval(session.pingInterval);
session.pingInterval = undefined;
}
// Clear keepAlive timeout
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
/* Cleanup any voice channels */
if (gameId in audio) {
try {
webrtcPart(audio[gameId], 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.short}: 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];
transientState.clearSession(game.id, id);
}
}
delete audio[gameId];
delete games[gameId];
transientState.clearGame(gameId);
try {
if (!gameDB || !gameDB.deleteGame) {
console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`);
} else {
await gameDB.deleteGame(gameId);
}
} catch (error) {
console.error(`${session.id}: Unable to remove game ${id} via gameDB.deleteGame`, error);
}
}
}
});
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 as any) || {};
const game = await loadGame(gameId);
const _session = getSession(
game,
req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : ""
);
if (!_session) return;
const session = _session;
// Keep track of any previously attached websocket so we can detect
// first-time attaches and websocket replacements (reconnects).
const previousWs = session.ws;
// 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) {
// Clear any existing ping/keepAlive timers associated with the old socket
try {
if (session.pingInterval) {
clearInterval(session.pingInterval);
session.pingInterval = undefined;
console.log(`${short}: Cleared old pingInterval during reconnection for ${getName(session)}`);
}
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
console.log(`${short}: Cleared old keepAlive during reconnection for ${getName(session)}`);
}
} catch (e) {
console.warn(`${short}: Error clearing old timers during reconnection:`, e);
}
// Clean up peer from audio registry before replacing WebSocket
if (gameId in audio) {
try {
webrtcPart(audio[gameId], session);
console.log(`${short}: Cleaned up peer ${session.name} from audio registry during reconnection`);
} catch (e) {
console.warn(`${short}: Error cleaning up peer during reconnection:`, e);
}
}
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();
session.short = short;
let error: string | undefined;
let warning: string | void | undefined;
let processed = true;
// The initial-game snapshot is sent from the connection attach path to
// ensure it is only sent once per websocket lifecycle. Avoid sending it
// here from the message handler to prevent duplicate snapshots when a
// client sends messages during the attach/reconnect sequence.
switch (incoming.type) {
case "join":
// Accept either legacy `config`, newer `data`, or flat payloads where
// the client sent fields at the top level (normalizeIncoming will
// populate `data` with the parsed object in that case).
webrtcJoin(audio[gameId], session, data.config || data.data || data || {});
break;
case "part":
webrtcPart(audio[gameId], session);
break;
case "relayICECandidate":
{
// Delegate to the webrtc signaling helper (it performs its own checks)
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
handleRelayICECandidate(gameId, cfg, session, undefined, debug);
}
break;
case "relaySessionDescription":
{
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
handleRelaySessionDescription(gameId, cfg, session, undefined, debug);
}
break;
case "pong":
// Clear the keepAlive timeout since we got a response
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
// Calculate latency if ping timestamp was sent
// if (session.ping) {
// session.lastPong = Date.now();
// const latency = session.lastPong - session.ping;
// console.log(`${short}: Received pong from ${getName(session)}. Latency: ${latency}ms`);
// } else {
// console.log(`${short}: Received pong from ${getName(session)}.`);
// }
// No need to resetDisconnectCheck since it's non-functional
break;
case "game-update":
console.log(`${short}: <- game-update ${getName(session)} - full game update.`);
sendGameToPlayer(game, session);
break;
case "peer_state_update":
{
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
broadcastPeerStateUpdate(gameId, cfg, session, undefined);
}
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 {
gameDB.saveGame(game);
}
break;
case "set":
console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`);
switch (data.field) {
case "state":
warning = setGame(game, session, data.value);
if (warning) {
sendWarning(session, warning);
} else {
gameDB.saveGame(game);
}
break;
case "color":
warning = setPlayerColor(game, session, data.value);
if (warning) {
sendWarning(session, warning);
} else {
gameDB.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: string[] = Array.isArray(data.fields)
? (data.fields as string[])
: data.data && Array.isArray(data.data.fields)
? (data.data.fields as string[])
: [];
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<string>(), timer: undefined };
}
// Merge requested fields into the batch set
requestedFields.forEach((f: string) => session._getBatch && 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 {
if (!session._getBatch) return;
const fieldsArray: string[] = Array.from(session._getBatch.fields) as string[];
const batchedUpdate: any = {};
fieldsArray.forEach((field: string) => {
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 "tileOrder":
case "active":
case "largestArmy":
case "mostDeveloped":
case "mostPorts":
case "longestRoad":
case "pipOrder":
case "signature":
case "borderOrder":
case "dice":
case "activities":
batchedUpdate[field] = game[field];
break;
case "tiles":
batchedUpdate.tiles = staticData.tiles;
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 "participants":
batchedUpdate.participants = getParticipants(game);
break;
case "color":
console.log(`${_session.short}: -> Returning color as ${session.color} for ${getName(session)}`);
batchedUpdate.color = session.color;
break;
case "timestamp":
batchedUpdate.timestamp = Date.now();
break;
default:
function hasKey<T>(obj: T, key: PropertyKey): key is keyof T {
return key in (obj as unknown as Record<string, unknown>);
}
if (hasKey(game, field)) {
console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`);
batchedUpdate[field] = game[field];
} else if (hasKey(session, field)) {
console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`);
batchedUpdate[field as keyof typeof game] = 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
if (session._getBatch) {
session._getBatch.fields.clear();
clearTimeout(session._getBatch.timer as any);
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 });
gameDB.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?.color === "unassigned") {
error = `Player must have an active color.`;
sendError(session, error);
return;
}
processed = true;
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) {
sendWarning(session, warning);
}
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, parseInt(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) {
gameDB.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 _session2 = getSession(
game,
req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : ""
);
if (!_session2) return;
const session = _session2;
session.ws = ws;
if (session.player) {
session.player.live = true;
}
session.live = true;
session.lastActive = Date.now();
session.short = short;
// 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.
sendInitialGameSnapshot(game, session);
if (session.name) {
sendUpdateToPlayers(game, {
players: getFilteredPlayers(game),
participants: getParticipants(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 !== "unassigned") {
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)}`);
/* Start recurring ping mechanism */
console.log(`${short}: Starting ping interval for ${getName(session)}`);
schedulePing(session);
});
const debugChat = (game: any, preamble: any) => {
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: any, session: any) => {
/* 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 a flurry of incremental game-update events.
*/
const sendInitialGameSnapshot = (game: Game, session: Session) => {
if (session.initialSnapshotSent) {
return;
}
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.short}: sending initial-game snapshot keys: ${topKeys}`);
} catch (e) {
/* ignore logging errors */
}
if (session && session.ws && session.ws.send) {
session.ws.send(message);
session.initialSnapshotSent = true;
} 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: any, from: any, to: any, type: any, count: any) => {
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;
};
/* 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: any = {
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 as any);
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({
id: playerId,
player: playerId,
name: null,
lobbies: [],
has_media: true, // Default to true for regular users
});
});
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: any = {
httpOnly: false,
sameSite: secure ? "none" : "lax",
secure: !!secure,
};
cookieOpts.path = basePath || "/";
res.cookie("player", playerId, cookieOpts as any);
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 });
});
export default router;