1
0

545 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 = [] as any[];
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 = [] as any[];
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) => {
const color = player.color;
for (let key in player) {
// delete all runtime fields
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (player as any)[key];
}
// Use shared factory to ensure a single source of defaults
Object.assign(player, newPlayer(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((k) => {
const v = deltas[k] || 0;
// update named resource slot if present
try {
const current = (player as any)[k] || 0;
(player as any)[k] = current + v;
total += v;
} 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) => {
player.resources += player[resource];
delete player[resource];
});
player.development = [];
}
return filtered;
};