554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types";
|
|
import { newPlayer } from "./playerFactory";
|
|
import { debug, info, MAX_CITIES, MAX_SETTLEMENTS, SEND_THROTTLE_MS } from "./constants";
|
|
import { shuffleBoard } from "./gameFactory";
|
|
import { getVictoryPointRule } from "./rules";
|
|
|
|
export const addActivity = (game: Game, session: Session | null, message: string): void => {
|
|
let date = Date.now();
|
|
if (!game.activities) game.activities = [];
|
|
if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
|
|
date++;
|
|
}
|
|
const actColor = session && session.color && session.color !== "unassigned" ? session.color : "";
|
|
game.activities.push({ color: actColor, message, date });
|
|
if (game.activities.length > 30) {
|
|
game.activities.splice(0, game.activities.length - 30);
|
|
}
|
|
};
|
|
|
|
export const addChatMessage = (game: Game, session: Session | null, message: string, isNormalChat?: boolean) => {
|
|
let now = Date.now();
|
|
let lastTime = 0;
|
|
if (!game.chat) game.chat = [];
|
|
if (game.chat.length) {
|
|
lastTime = game.chat[game.chat.length - 1].date;
|
|
}
|
|
if (now <= lastTime) {
|
|
now = lastTime + 1;
|
|
}
|
|
|
|
const entry: any = {
|
|
date: now,
|
|
message: message,
|
|
};
|
|
if (isNormalChat) {
|
|
entry.normalChat = true;
|
|
}
|
|
if (session && session.name) {
|
|
entry.from = session.name;
|
|
}
|
|
if (session && session.color && session.color !== "unassigned") {
|
|
entry.color = session.color;
|
|
}
|
|
game.chat.push(entry);
|
|
if (game.chat.length > 50) {
|
|
game.chat.splice(0, game.chat.length - 50);
|
|
}
|
|
};
|
|
|
|
export const getColorFromName = (game: Game, name: string): string => {
|
|
for (let id in game.sessions) {
|
|
const s = game.sessions[id];
|
|
if (s && s.name === name) {
|
|
return s.color && s.color !== "unassigned" ? s.color : "";
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
export const getLastPlayerName = (game: Game): string => {
|
|
const index = (game.playerOrder || []).length - 1;
|
|
const color = (game.playerOrder || [])[index];
|
|
if (!color) return "";
|
|
for (let id in game.sessions) {
|
|
const s = game.sessions[id];
|
|
if (s && s.color === color) {
|
|
return s.name || "";
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
export const getFirstPlayerName = (game: Game): string => {
|
|
const color = (game.playerOrder || [])[0];
|
|
if (!color) return "";
|
|
for (let id in game.sessions) {
|
|
const s = game.sessions[id];
|
|
if (s && s.color === color) {
|
|
return s.name || "";
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
|
|
let color: PlayerColor | undefined;
|
|
for (let id in game.sessions) {
|
|
const s = game.sessions[id];
|
|
if (s && s.name === name) {
|
|
color = s.color;
|
|
break;
|
|
}
|
|
}
|
|
if (!color) return undefined;
|
|
|
|
const order = game.playerOrder || [];
|
|
let index = order.indexOf(color);
|
|
if (index === -1) return undefined;
|
|
index = (index + 1) % order.length;
|
|
const nextColor = order[index];
|
|
for (let id in game.sessions) {
|
|
const s = game.sessions[id];
|
|
if (s && s.color === nextColor) {
|
|
return s;
|
|
}
|
|
}
|
|
console.error(`getNextPlayerSession -- no player found!`);
|
|
console.log(game.players);
|
|
return undefined;
|
|
};
|
|
|
|
export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => {
|
|
let color: PlayerColor | undefined;
|
|
for (let id in game.sessions) {
|
|
const s = game.sessions[id];
|
|
if (s && s.name === name) {
|
|
color = s.color;
|
|
break;
|
|
}
|
|
}
|
|
if (!color) return undefined;
|
|
const order = game.playerOrder || [];
|
|
let index = order.indexOf(color);
|
|
if (index === -1) return undefined;
|
|
index = (index - 1 + order.length) % order.length;
|
|
const prevColor = order[index];
|
|
for (let id in game.sessions) {
|
|
const s = game.sessions[id];
|
|
if (s && s.color === prevColor) {
|
|
return s;
|
|
}
|
|
}
|
|
console.error(`getPrevPlayerSession -- no player found!`);
|
|
console.log(game.players);
|
|
return undefined;
|
|
};
|
|
|
|
export const clearPlayer = (player: Player) => {
|
|
// Use shared factory to ensure a single source of defaults
|
|
Object.assign(player, newPlayer(player.color));
|
|
};
|
|
|
|
export const canGiveBuilding = (game: Game): string | undefined => {
|
|
if (!game.turn.roll) {
|
|
return `Admin cannot give a building until the dice have been rolled.`;
|
|
}
|
|
if (game.turn.actions && game.turn.actions.length !== 0) {
|
|
return `Admin cannot give a building while other actions in play: ${game.turn.actions.join(", ")}.`;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
export const setForRoadPlacement = (game: Game, limits: any): void => {
|
|
game.turn.actions = ["place-road"];
|
|
game.turn.limits = { roads: limits };
|
|
};
|
|
|
|
export const setForCityPlacement = (game: Game, limits: any): void => {
|
|
game.turn.actions = ["place-city"];
|
|
game.turn.limits = { corners: limits };
|
|
};
|
|
|
|
export const setForSettlementPlacement = (game: Game, limits: number[]): void => {
|
|
game.turn.actions = ["place-settlement"];
|
|
game.turn.limits = { corners: limits };
|
|
};
|
|
|
|
// Adjust a player's resource counts by a deltas map. Deltas may be negative.
|
|
export const adjustResources = (player: Player, deltas: Partial<Record<string, number>>): void => {
|
|
if (!player) return;
|
|
let total = player.resources || 0;
|
|
const keys = Object.keys(deltas);
|
|
keys.forEach((type) => {
|
|
const v = deltas[type] || 0;
|
|
// update named resource slot if present
|
|
try {
|
|
switch (type) {
|
|
case "wood":
|
|
case "brick":
|
|
case "sheep":
|
|
case "wheat":
|
|
case "stone":
|
|
const current = player[type] || 0;
|
|
player[type] = current + v;
|
|
total += v;
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
// ignore unexpected keys
|
|
}
|
|
});
|
|
player.resources = total;
|
|
};
|
|
|
|
export const startTurnTimer = (game: Game, session: Session) => {
|
|
const timeout = 90;
|
|
if (!session.ws) {
|
|
console.log(`${session.short}: Aborting turn timer as ${session.name} is disconnected.`);
|
|
} else {
|
|
console.log(`${session.short}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`);
|
|
}
|
|
if (game.turnTimer) {
|
|
clearTimeout(game.turnTimer);
|
|
}
|
|
if (!session.connected) {
|
|
game.turnTimer = 0;
|
|
return;
|
|
}
|
|
game.turnTimer = setTimeout(() => {
|
|
console.log(`${session.short}: Turn timer expired for ${session.name}`);
|
|
if (session.player) {
|
|
session.player.turnNotice = "It is still your turn.";
|
|
}
|
|
sendUpdateToPlayer(game, session, {
|
|
private: session.player,
|
|
});
|
|
resetTurnTimer(game, session);
|
|
}, timeout * 1000);
|
|
};
|
|
|
|
const calculatePoints = (game: any, update: any): void => {
|
|
if (game.state === "winner") {
|
|
return;
|
|
}
|
|
/* Calculate points and determine if there is a winner */
|
|
for (let key in game.players) {
|
|
const player = game.players[key];
|
|
if (player.status === "Not active") {
|
|
continue;
|
|
}
|
|
const currentPoints = player.points;
|
|
|
|
player.points = 0;
|
|
if (key === game.longestRoad) {
|
|
player.points += 2;
|
|
}
|
|
if (key === game.largestArmy) {
|
|
player.points += 2;
|
|
}
|
|
if (key === game.mostPorts) {
|
|
player.points += 2;
|
|
}
|
|
if (key === game.mostDeveloped) {
|
|
player.points += 2;
|
|
}
|
|
player.points += MAX_SETTLEMENTS - player.settlements;
|
|
player.points += 2 * (MAX_CITIES - player.cities);
|
|
|
|
player.unplayed = 0;
|
|
player.potential = 0;
|
|
player.development.forEach((card: any) => {
|
|
if (card.type === "vp") {
|
|
if (card.played) {
|
|
player.points++;
|
|
} else {
|
|
player.potential++;
|
|
}
|
|
}
|
|
if (!card.played) {
|
|
player.unplayed++;
|
|
}
|
|
});
|
|
|
|
if (player.points === currentPoints) {
|
|
continue;
|
|
}
|
|
|
|
if (player.points < getVictoryPointRule(game)) {
|
|
update.players = getFilteredPlayers(game);
|
|
continue;
|
|
}
|
|
|
|
/* This player has enough points! Check if they are the current
|
|
* player and if so, declare victory! */
|
|
console.log(`${info}: Whoa! ${player.name} has ${player.points}!`);
|
|
for (let key in game.sessions) {
|
|
if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") {
|
|
continue;
|
|
}
|
|
const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`;
|
|
addChatMessage(game, null, message);
|
|
console.log(`${info}: ${message}`);
|
|
update.winner = Object.assign({}, player, {
|
|
state: "winner",
|
|
stolen: game.stolen,
|
|
chat: game.chat,
|
|
turns: game.turns,
|
|
players: game.players,
|
|
elapsedTime: Date.now() - game.startTime,
|
|
});
|
|
game.winner = update.winner;
|
|
game.state = "winner";
|
|
game.waiting = [];
|
|
stopTurnTimer(game);
|
|
sendUpdateToPlayers(game, {
|
|
state: game.state,
|
|
winner: game.winner,
|
|
players: game.players /* unfiltered */,
|
|
});
|
|
}
|
|
}
|
|
|
|
/* If the game isn't in a win state, do not share development card information
|
|
* with other players */
|
|
if (game.state !== "winner") {
|
|
for (let key in game.players) {
|
|
const player = game.players[key];
|
|
if (player.status === "Not active") {
|
|
continue;
|
|
}
|
|
delete player.potential;
|
|
}
|
|
}
|
|
};
|
|
|
|
export const resetTurnTimer = (game: Game, session: Session): void => {
|
|
startTurnTimer(game, session);
|
|
};
|
|
|
|
export const stopTurnTimer = (game: Game): void => {
|
|
if (game.turnTimer) {
|
|
console.log(`${info}: Stopping turn timer.`);
|
|
try {
|
|
clearTimeout(game.turnTimer);
|
|
} catch (e) {
|
|
/* ignore if not a real timeout */
|
|
}
|
|
game.turnTimer = 0;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
export const sendUpdateToPlayers = async (game: any, update: any): Promise<void> => {
|
|
/* Ensure clearing of a field actually gets sent by setting
|
|
* undefined to 'false'
|
|
*/
|
|
for (let key in update) {
|
|
if (update[key] === undefined) {
|
|
update[key] = false;
|
|
}
|
|
}
|
|
|
|
calculatePoints(game, update);
|
|
|
|
if (debug.update) {
|
|
console.log(`[ all ]: -> sendUpdateToPlayers - `, update);
|
|
} else {
|
|
const keys = Object.getOwnPropertyNames(update);
|
|
console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`);
|
|
}
|
|
|
|
const message = JSON.stringify({
|
|
type: "game-update",
|
|
update,
|
|
});
|
|
for (let key in game.sessions) {
|
|
const session = game.sessions[key];
|
|
/* Only send player and game data to named players */
|
|
if (!session.name) {
|
|
console.log(`${session.short}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`);
|
|
if (session.ws) {
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "game-update",
|
|
update: { name: "" },
|
|
})
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (!session.ws) {
|
|
console.log(`${session.short}: -> sendUpdateToPlayers: ` + `Currently no connection.`);
|
|
} else {
|
|
queueSend(session, message);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise<void> => {
|
|
/* If this player does not have a name, *ONLY* send the name, regardless
|
|
* of what is requested */
|
|
if (!session.name) {
|
|
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`);
|
|
update = { name: "" };
|
|
}
|
|
|
|
/* Ensure clearing of a field actually gets sent by setting
|
|
* undefined to 'false'
|
|
*/
|
|
for (let key in update) {
|
|
if (update[key] === undefined) {
|
|
update[key] = false;
|
|
}
|
|
}
|
|
|
|
calculatePoints(game, update);
|
|
|
|
if (debug.update) {
|
|
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - `, update);
|
|
} else {
|
|
const keys = Object.getOwnPropertyNames(update);
|
|
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`);
|
|
}
|
|
|
|
const message = JSON.stringify({
|
|
type: "game-update",
|
|
update,
|
|
});
|
|
|
|
if (!session.ws) {
|
|
console.log(`${session.short}: -> sendUpdateToPlayer: ` + `Currently no connection.`);
|
|
} else {
|
|
queueSend(session, message);
|
|
}
|
|
};
|
|
|
|
export const queueSend = (session: any, message: any): void => {
|
|
if (!session || !session.ws) return;
|
|
try {
|
|
// Ensure we compare a stable serialization: if message is JSON text,
|
|
// parse it and re-serialize with sorted keys so semantically-equal
|
|
// objects compare equal even when property order differs.
|
|
const stableStringify = (msg: any): string => {
|
|
try {
|
|
const obj = typeof msg === "string" ? JSON.parse(msg) : msg;
|
|
const ordered = (v: any): any => {
|
|
if (v === null || typeof v !== "object") return v;
|
|
if (Array.isArray(v)) return v.map(ordered);
|
|
const keys = Object.keys(v).sort();
|
|
const out: any = {};
|
|
for (const k of keys) out[k] = ordered(v[k]);
|
|
return out;
|
|
};
|
|
return JSON.stringify(ordered(obj));
|
|
} catch (e) {
|
|
// If parsing fails, fall back to original string representation
|
|
return typeof msg === "string" ? msg : JSON.stringify(msg);
|
|
}
|
|
};
|
|
const stableMessage = stableStringify(message);
|
|
const now = Date.now();
|
|
if (!session._lastSent) session._lastSent = 0;
|
|
const elapsed = now - session._lastSent;
|
|
// If the exact same message (in stable form) was sent last time and
|
|
// nothing is pending, skip sending to avoid pointless duplicate
|
|
// traffic.
|
|
if (!session._pendingTimeout && session._lastMessage === stableMessage) {
|
|
return;
|
|
}
|
|
|
|
// If we haven't sent recently and there's no pending timer, send now
|
|
if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) {
|
|
try {
|
|
session.ws.send(typeof message === "string" ? message : JSON.stringify(message));
|
|
session._lastSent = Date.now();
|
|
session._lastMessage = stableMessage;
|
|
} catch (e) {
|
|
console.warn(`${session.id}: queueSend immediate send failed:`, e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Otherwise, store latest message and schedule a send
|
|
// If the pending message would equal the last-sent message, don't bother
|
|
// storing/scheduling it.
|
|
if (session._lastMessage === stableMessage) {
|
|
return;
|
|
}
|
|
session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message);
|
|
if (session._pendingTimeout) {
|
|
// already scheduled; newest message will be sent when timer fires
|
|
return;
|
|
}
|
|
const delay = Math.max(1, SEND_THROTTLE_MS - elapsed);
|
|
session._pendingTimeout = setTimeout(() => {
|
|
try {
|
|
if (session.ws && session._pendingMessage) {
|
|
session.ws.send(session._pendingMessage);
|
|
session._lastSent = Date.now();
|
|
// compute stable form of what we actually sent
|
|
try {
|
|
session._lastMessage = stableStringify(session._pendingMessage);
|
|
} catch (e) {
|
|
session._lastMessage = session._pendingMessage;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(`${session.id}: queueSend delayed send failed:`, e);
|
|
}
|
|
// clear pending fields
|
|
session._pendingMessage = undefined;
|
|
clearTimeout(session._pendingTimeout);
|
|
session._pendingTimeout = undefined;
|
|
}, delay);
|
|
} catch (e) {
|
|
console.warn(`${session.id}: queueSend exception:`, e);
|
|
}
|
|
};
|
|
|
|
export const shuffle = (game: Game, session: Session): string | undefined => {
|
|
if (game.state !== "lobby") {
|
|
return `Game no longer in lobby (${game.state}). Can not shuffle board.`;
|
|
}
|
|
if (game.turns > 0) {
|
|
return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`;
|
|
}
|
|
shuffleBoard(game);
|
|
console.log(`${session.short}: Shuffled to new signature: ${game.signature}`);
|
|
|
|
sendUpdateToPlayers(game, {
|
|
pipOrder: game.pipOrder,
|
|
tileOrder: game.tileOrder,
|
|
borderOrder: game.borderOrder,
|
|
robber: game.robber,
|
|
robberName: game.robberName,
|
|
signature: game.signature,
|
|
animationSeeds: game.animationSeeds,
|
|
});
|
|
return undefined;
|
|
};
|
|
|
|
export const getName = (session: Session): string => {
|
|
return session ? (session.name ? session.name : session.id) : "Admin";
|
|
};
|
|
|
|
export const getFilteredPlayers = (game: Game): Record<string, Player> => {
|
|
const filtered: Record<string, Player> = {};
|
|
for (let color in game.players) {
|
|
const player = Object.assign({}, game.players[color]);
|
|
filtered[color] = player;
|
|
if (player.status === "Not active") {
|
|
if (game.state !== "lobby") {
|
|
delete filtered[color];
|
|
}
|
|
continue;
|
|
}
|
|
player.resources = 0;
|
|
RESOURCE_TYPES.forEach((resource) => {
|
|
switch (resource) {
|
|
case "wood":
|
|
case "brick":
|
|
case "sheep":
|
|
case "wheat":
|
|
case "stone":
|
|
player.resources += player[resource];
|
|
player[resource] = 0;
|
|
break;
|
|
}
|
|
});
|
|
player.development = [];
|
|
}
|
|
return filtered;
|
|
};
|