1
0

Lots of refactoring

This commit is contained in:
James Ketr 2025-10-10 16:55:20 -07:00
parent e68e49bf82
commit 0818145a81
20 changed files with 1234 additions and 1314 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
original/
test-output/ test-output/
certs/ certs/
**/node_modules/ **/node_modules/

View File

@ -53,7 +53,7 @@ const App = () => {
setTimeout(() => { setTimeout(() => {
setError(null); setError(null);
}, 5000); }, 5000);
console.error(`App - error`, error); console.error(`app - error`, error);
} }
}, [error]); }, [error]);
@ -61,13 +61,13 @@ const App = () => {
if (!session) { if (!session) {
return; return;
} }
console.log(`App - sessionId`, session.id); console.log(`app - sessionId`, session.id);
}, [session]); }, [session]);
const getSession = useCallback(async () => { const getSession = useCallback(async () => {
try { try {
const session = await sessionApi.getCurrent(); const session = await sessionApi.getCurrent();
console.log(`App - got sessionId`, session.id); console.log(`app - got sessionId`, session.id);
setSession(session); setSession(session);
setSessionRetryAttempt(0); setSessionRetryAttempt(0);
} catch (err) { } catch (err) {

View File

@ -351,10 +351,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard <Placard
sx={{ sx={{
float: "left", float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */, marginRight: "0.5rem",
clipPath: "inset(0)" /* Ensures proper wrapping area */, marginBottom: "0.5rem",
marginRight: "1rem",
marginBottom: "1rem",
}} }}
type="most-developed" type="most-developed"
/> />
@ -364,6 +362,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
fields. Picture yourself snagging this beautifully illustrated cardfeaturing hardworking villagers and a fields. Picture yourself snagging this beautifully illustrated cardfeaturing hardworking villagers and a
majestic castle! majestic castle!
</Typography> </Typography>
<Box sx={{ clear: "both" }}></Box>
</Box> </Box>
), ),
}, },
@ -378,10 +377,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard <Placard
sx={{ sx={{
float: "left", float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */, marginRight: "0.5rem",
clipPath: "inset(0)" /* Ensures proper wrapping area */, marginBottom: "0.5rem",
marginRight: "1rem",
marginBottom: "1rem",
}} }}
type="port-of-call" type="port-of-call"
/> />
@ -392,6 +389,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
moment someone else builds a larger network of harbors, theyll steal both the card and the glory right moment someone else builds a larger network of harbors, theyll steal both the card and the glory right
from under your nose. Keep those ships moving and never let your rivals toast to your downfall! from under your nose. Keep those ships moving and never let your rivals toast to your downfall!
</Typography> </Typography>
<Box sx={{ clear: "both" }}></Box>
</Box> </Box>
), ),
}, },
@ -406,10 +404,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard <Placard
sx={{ sx={{
float: "left", float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */, marginRight: "0.5rem",
clipPath: "inset(0)" /* Ensures proper wrapping area */, marginBottom: "0.5rem",
marginRight: "1rem",
marginBottom: "1rem",
}} }}
type="longest-turn" type="longest-turn"
/> />
@ -418,6 +414,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
handed this charming cardfeaturing industrious villagers raking hay with a castle looming in the handed this charming cardfeaturing industrious villagers raking hay with a castle looming in the
backgrounduntil someone even slower takes it from you with a sheepish grin! backgrounduntil someone even slower takes it from you with a sheepish grin!
</Typography> </Typography>
<Box sx={{ clear: "both" }}></Box>
</Box> </Box>
), ),
}, },

View File

@ -1518,22 +1518,6 @@ const MediaControl: React.FC<MediaControlProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]); }, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]);
// Debug target element
useEffect(() => {
console.log("Target ref current:", targetRef.current, "for peer:", peer?.session_id);
if (targetRef.current) {
console.log("Target element rect:", targetRef.current.getBoundingClientRect());
console.log("Target element computed style:", {
position: getComputedStyle(targetRef.current).position,
left: getComputedStyle(targetRef.current).left,
top: getComputedStyle(targetRef.current).top,
transform: getComputedStyle(targetRef.current).transform,
width: getComputedStyle(targetRef.current).width,
height: getComputedStyle(targetRef.current).height,
});
}
}, [peer?.session_id]);
const toggleMute = useCallback( const toggleMute = useCallback(
(e: React.MouseEvent | React.TouchEvent) => { (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); e.stopPropagation();

View File

@ -121,7 +121,7 @@ const PlayerList: React.FC = () => {
break; break;
} }
default: default:
console.log(`player-list - ignoring message: ${data.type}`); // console.log(`player-list - ignoring message: ${data.type}`);
break; break;
} }
}, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]); }, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,304 @@
import randomWords from "random-words";
import { games, gameDB } from "./store";
import { PLAYER_COLORS, type Game } from "./types";
import { addChatMessage, clearPlayer, stopTurnTimer } from "./helpers";
import newPlayer from "./playerFactory";
import { info } from "./constants";
import { layout, staticData } from "../../util/layout";
import { shuffleArray } from "./utils";
import { pickRobber } from "./robber";
import { audio } from "../webrtc-signaling";
export const resetGame = (game: any) => {
Object.assign(game, {
startTime: Date.now(),
state: "lobby",
turns: 0,
step: 0 /* used for the suffix # in game backups */,
turn: {},
sheep: 19,
ore: 19,
wool: 19,
brick: 19,
wheat: 19,
placements: {
corners: [],
roads: [],
},
developmentCards: [],
chat: [],
activities: [],
pipOrder: game.pipOrder,
borderOrder: game.borderOrder,
tileOrder: game.tileOrder,
signature: game.signature,
players: game.players,
stolen: {
robber: {
stole: {
total: 0,
},
},
total: 0,
},
longestRoad: "",
longestRoadLength: 0,
largestArmy: "",
largestArmySize: 0,
mostDeveloped: "",
mostDevelopmentCards: 0,
mostPorts: "",
mostPortCount: 0,
winner: undefined,
active: 0,
});
stopTurnTimer(game);
/* Populate the game corner and road placement data as cleared */
for (let i = 0; i < layout.corners.length; i++) {
game.placements.corners[i] = {
color: undefined,
type: undefined,
};
}
for (let i = 0; i < layout.roads.length; i++) {
game.placements.roads[i] = {
color: undefined,
longestRoad: undefined,
};
}
/* Put the robber back on the Desert */
for (let i = 0; i < game.pipOrder.length; i++) {
if (game.pipOrder[i] === 18) {
game.robber = i;
break;
}
}
/* Populate the game development cards with a fresh deck */
for (let i = 1; i <= 14; i++) {
game.developmentCards.push({
type: "army",
card: i,
});
}
["monopoly", "monopoly", "road-1", "road-2", "year-of-plenty", "year-of-plenty"].forEach((card) =>
game.developmentCards.push({
type: "progress",
card: card,
})
);
["market", "library", "palace", "university"].forEach((card) =>
game.developmentCards.push({
type: "vp",
card: card,
})
);
shuffleArray(game.developmentCards);
/* Reset all player data, and add in any missing colors */
PLAYER_COLORS.forEach((color) => {
if (color in game.players) {
clearPlayer(game.players[color]);
} else {
game.players[color] = newPlayer(color);
}
});
/* Ensure sessions are connected to player objects */
for (let key in game.sessions) {
const session = game.sessions[key];
if (session.color !== "unassigned") {
game.active++;
session.player = game.players[session.color];
session.player.status = "Active";
session.player.lastActive = Date.now();
session.player.live = session.live;
session.player.name = session.name;
session.player.color = session.color;
}
}
game.animationSeeds = [];
for (let i = 0; i < game.tileOrder.length; i++) {
game.animationSeeds.push(Math.random());
}
};
export const setBeginnerGame = (game: Game) => {
pickRobber(game);
shuffleArray(game.developmentCards);
game.borderOrder = [];
for (let i = 0; i < 6; i++) {
game.borderOrder.push(i);
}
game.tileOrder = [9, 12, 1, 5, 16, 13, 17, 6, 2, 0, 3, 10, 4, 11, 7, 14, 18, 8, 15];
game.robber = 9;
game.animationSeeds = [];
for (let i = 0; i < game.tileOrder.length; i++) {
game.animationSeeds.push(Math.random());
}
game.pipOrder = [5, 1, 6, 7, 2, 9, 11, 12, 8, 18, 3, 4, 10, 16, 13, 0, 14, 15, 17];
game.signature = gameSignature(game);
};
export const shuffleBoard = (game: any): void => {
pickRobber(game);
const seq = [];
for (let i = 0; i < 6; i++) {
seq.push(i);
}
shuffleArray(seq);
game.borderOrder = seq.slice();
for (let i = 6; i < 19; i++) {
seq.push(i);
}
shuffleArray(seq);
game.tileOrder = seq.slice();
/* Pip order is from one of the random corners, then rotate around
* and skip over the desert (robber) */
/* Board:
* 0 1 2
* 3 4 5 6
* 7 8 9 10 11
* 12 13 14 15
* 16 17 18
*/
const order = [
[0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9],
[2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 5, 10, 14, 13, 8, 4, 9],
[11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 10, 14, 13, 8, 4, 5, 9],
[18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 14, 13, 8, 4, 5, 10, 9],
[16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 13, 8, 4, 5, 10, 14, 9],
[7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 8, 4, 5, 10, 14, 13, 9],
];
const sequence = order[Math.floor(Math.random() * order.length)];
if (!sequence || !Array.isArray(sequence)) {
// Defensive: should not happen, but guard for TS strictness
return;
}
game.pipOrder = [];
game.animationSeeds = [];
for (let i = 0, p = 0; i < sequence.length; i++) {
const target: number = sequence[i]!;
/* If the target tile is the desert (18), then set the
* pip value to the robber (18) otherwise set
* the target pip value to the currently incremeneting
* pip value. */
const tileIdx = game.tileOrder[target];
const tileType = staticData.tiles[tileIdx]?.type;
if (!game.pipOrder) game.pipOrder = [];
if (tileType === "desert") {
game.robber = target;
game.pipOrder[target] = 18;
} else {
game.pipOrder[target] = p++;
}
game.animationSeeds.push(Math.random());
}
shuffleArray(game.developmentCards);
game.signature = gameSignature(game);
};
export const createGame = async (id: string | null = null) => {
/* Look for a new game with random words that does not already exist */
while (!id) {
id = randomWords(4).join("-");
try {
/* If a game with this id exists in the DB, look for a new name */
if (!gameDB || !gameDB.getGameById) {
throw new Error("Game DB not available for uniqueness check");
}
let exists = false;
try {
const g = await gameDB.getGameById(id);
if (g) exists = true;
} catch (e) {
// if DB check fails treat as non-existent and continue searching
}
if (exists) {
id = "";
}
} catch (error) {
break;
}
}
console.log(`${info}: creating ${id}`);
const game: Game = {
id: id,
developmentCards: [],
playerOrder: [],
turns: 0,
players: {
O: newPlayer("O"),
R: newPlayer("R"),
B: newPlayer("B"),
W: newPlayer("W"),
},
sessions: {},
unselected: [],
placements: {
corners: [],
roads: [],
},
turn: {
name: "",
color: "unassigned",
actions: [],
limits: {},
roll: 0,
},
rules: {
"victory-points": {
points: 10,
},
},
pipOrder: [],
borderOrder: [],
tileOrder: [],
step: 0 /* used for the suffix # in game backups */,
};
setBeginnerGame(game);
resetGame(game);
addChatMessage(game, null, `New game created with Beginner's Layout: ${game.id}`);
games[game.id] = game;
audio[game.id] = {};
return game;
};
const gameSignature = (game: Game): string => {
if (!game) {
return "";
}
const salt = 251;
const signature =
(game.borderOrder || []).map((border: any) => `00${(Number(border) ^ salt).toString(16)}`.slice(-2)).join("") +
"-" +
(game.pipOrder || [])
.map((pip: any, index: number) => `00${(Number(pip) ^ salt ^ (salt * index)).toString(16)}`.slice(-2))
.join("") +
"-" +
(game.tileOrder || [])
.map((tile: any, index: number) => `00${(Number(tile) ^ salt ^ (salt * index)).toString(16)}`.slice(-2))
.join("");
return signature;
};

View File

@ -1,5 +1,8 @@
import type { Game, Session, Player } from "./types"; import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types";
import { newPlayer } from "./playerFactory"; 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 => { export const addActivity = (game: Game, session: Session | null, message: string): void => {
let date = Date.now(); let date = Date.now();
@ -80,7 +83,7 @@ export const getFirstPlayerName = (game: Game): string => {
}; };
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => { export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
let color: string | undefined; let color: PlayerColor | undefined;
for (let id in game.sessions) { for (let id in game.sessions) {
const s = game.sessions[id]; const s = game.sessions[id];
if (s && s.name === name) { if (s && s.name === name) {
@ -107,7 +110,7 @@ export const getNextPlayerSession = (game: Game, name: string): Session | undefi
}; };
export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => { export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => {
let color: string | undefined; let color: PlayerColor | undefined;
for (let id in game.sessions) { for (let id in game.sessions) {
const s = game.sessions[id]; const s = game.sessions[id];
if (s && s.name === name) { if (s && s.name === name) {
@ -164,7 +167,7 @@ export const setForCityPlacement = (game: Game, limits: any): void => {
game.turn.limits = { corners: limits }; game.turn.limits = { corners: limits };
}; };
export const setForSettlementPlacement = (game: Game, limits: number[] | undefined, _extra?: any): void => { export const setForSettlementPlacement = (game: Game, limits: number[]): void => {
game.turn.actions = ["place-settlement"]; game.turn.actions = ["place-settlement"];
game.turn.limits = { corners: limits }; game.turn.limits = { corners: limits };
}; };
@ -187,3 +190,355 @@ export const adjustResources = (player: Player, deltas: Partial<Record<string, n
}); });
player.resources = total; 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;
};

View File

@ -0,0 +1,17 @@
import { Game } from "./types";
export const pickRobber = (game: Game): void => {
const selection = Math.floor(Math.random() * 3);
switch (selection) {
case 0:
game.robberName = "Robert";
break;
case 1:
game.robberName = "Roberta";
break;
case 2:
game.robberName = "Velocirobber";
break;
}
};

View File

@ -0,0 +1,139 @@
import equal from "fast-deep-equal";
import { isRuleEnabled } from "../../util/validLocations";
import { addChatMessage, getName, sendUpdateToPlayers, shuffle } from "./helpers";
export const getVictoryPointRule = (game: any): number => {
const minVP = 10;
if (!isRuleEnabled(game, "victory-points") || !("points" in game.rules["victory-points"])) {
return minVP;
}
return game.rules["victory-points"].points;
};
export const supportedRules: Record<string, (game: any, session: any, rule: any, rules: any) => string | void | undefined> = {
"victory-points": (game: any, session: any, rule: any, rules: any) => {
if (!("points" in rules[rule])) {
return `No points specified for victory-points`;
}
if (!rules[rule].enabled) {
addChatMessage(game, null, `${getName(session)} has disabled the Victory Point ` + `house rule.`);
} else {
addChatMessage(game, null, `${getName(session)} set the minimum Victory Points to ` + `${rules[rule].points}`);
}
return undefined;
},
"roll-double-roll-again": (game: any, session: any, rule: any, rules: any) => {
addChatMessage(
game,
null,
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Roll Double, Roll Again house rule.`
);
return undefined;
},
volcano: (game: any, session: any, rule: any, rules: any) => {
if (!rules[rule].enabled) {
addChatMessage(game, null, `${getName(session)} has disabled the Volcano ` + `house rule.`);
} else {
if (!(rule in game.rules) || !game.rules[rule].enabled) {
addChatMessage(
game,
null,
`${getName(session)} enabled the Volcano ` +
`house rule with roll set to ` +
`${rules[rule].number} and 'Volanoes have gold' mode ` +
`${rules[rule].gold ? "en" : "dis"}abled.`
);
} else {
if (game.rules[rule].number !== rules[rule].number) {
addChatMessage(game, null, `${getName(session)} set the Volcano roll to ` + `${rules[rule].number}`);
}
if (game.rules[rule].gold !== rules[rule].gold) {
addChatMessage(
game,
null,
`${getName(session)} has ` + `${rules[rule].gold ? "en" : "dis"}abled the ` + `'Volcanoes have gold' mode.`
);
}
}
}
},
"twelve-and-two-are-synonyms": (game: any, session: any, rule: any, rules: any) => {
addChatMessage(
game,
null,
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Twelve and Two are Synonyms house rule.`
);
game.rules[rule] = rules[rule];
},
"most-developed": (game: any, session: any, rule: any, rules: any) => {
addChatMessage(
game,
null,
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Most Developed house rule.`
);
},
"port-of-call": (game: any, session: any, rule: any, rules: any) => {
addChatMessage(
game,
null,
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Another Round of Port house rule.`
);
},
"slowest-turn": (game: any, session: any, rule: any, rules: any) => {
addChatMessage(
game,
null,
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Slowest Turn house rule.`
);
},
"tiles-start-facing-down": (game: any, session: any, rule: any, rules: any) => {
addChatMessage(
game,
null,
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Tiles Start Facing Down house rule.`
);
if (rules[rule].enabled) {
shuffle(game, session);
}
},
"robin-hood-robber": (game: any, session: any, rule: any, rules: any) => {
addChatMessage(
game,
null,
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Robin Hood Robber house rule.`
);
},
};
export const setRules = (game: any, session: any, rules: any): string | undefined => {
if (game.state !== "lobby") {
return `You can not modify House Rules once the game has started.`;
}
for (let rule in rules) {
if (equal(game.rules[rule], rules[rule])) {
continue;
}
if (rule in supportedRules) {
const handler = supportedRules[rule];
if (handler) {
const warning = handler(game, session, rule, rules);
if (warning) {
return warning;
}
}
game.rules[rule] = rules[rule];
} else {
return `Rule ${rule} not recognized.`;
}
}
sendUpdateToPlayers(game, {
rules: game.rules,
chat: game.chat,
});
return undefined;
};

View File

@ -1,19 +1,20 @@
import type { GameState } from './state'; import { createGame } from "./gameFactory";
import type { Game } from "./types";
export function serializeGame(game: GameState): string { export const serializeGame = (game: Game): string => {
// Use a deterministic JSON serializer for snapshots; currently use JSON.stringify // Use a deterministic JSON serializer for snapshots; currently use JSON.stringify
return JSON.stringify(game); return JSON.stringify(game);
} };
export function deserializeGame(serialized: string): GameState { export const deserializeGame = async (serialized: string): Promise<Game> => {
try { try {
return JSON.parse(serialized) as GameState; return JSON.parse(serialized) as Game;
} catch (e) { } catch (e) {
// If parsing fails, return a minimal empty game state to avoid crashes // If parsing fails, return a minimal empty game state to avoid crashes
return { players: [], placements: { corners: [], roads: [] } } as GameState; return await createGame();
} }
} };
export function cloneGame(game: GameState): GameState { export const cloneGame = async (game: Game): Promise<Game> => {
return deserializeGame(serializeGame(game)); return deserializeGame(serializeGame(game));
} };

View File

@ -1,34 +0,0 @@
import { Player } from './types';
export interface PlacementCorner {
color?: string | null;
type?: string | null; // settlement/city
data?: any;
}
export interface PlacementRoad {
color?: string | null;
data?: any;
}
export interface Placements {
corners: PlacementCorner[];
roads: PlacementRoad[];
}
export interface GameState {
id?: string | number;
name?: string;
players: Player[];
placements: Placements;
rules?: Record<string, any>;
state?: string;
robber?: number;
turn?: number;
history?: any[];
createdAt?: string;
[key: string]: any;
}
export type GameId = string | number;

View File

@ -1,184 +1,211 @@
import type { GameState } from './state.js'; import type { Game } from "./types";
import { promises as fsp } from 'fs'; import { promises as fsp } from "fs";
import path from 'path'; import path from "path";
import { transientState } from "./sessionState";
export interface GameDB { interface GameDB {
sequelize?: any; db: any | null;
Sequelize?: any; init(): Promise<void>;
getGameById(id: string | number): Promise<GameState | null>; getGameById(id: string): Promise<Game | null>;
saveGameState(id: string | number, state: GameState): Promise<void>; saveGame(game: Game): Promise<void>;
deleteGame?(id: string | number): Promise<void>; deleteGame?(id: string): Promise<void>;
[k: string]: any;
} }
/** export const gameDB: GameDB = {
* Thin game DB initializer / accessor. db: null,
* This currently returns the underlying db module (for runtime compatibility) init: async () => {
* and is the single place to add typed helper methods for game persistence. let mod: any;
*/
export async function initGameDB(): Promise<GameDB> {
// dynamic import to preserve original runtime ordering
// path is relative to this file (routes/games)
// Prefer synchronous require at runtime when available to avoid TS module resolution
// issues during type-checking. Declare require to keep TypeScript happy.
let mod: any;
try {
// Use runtime require to load the DB module. This runs under Node (ts-node)
// so a direct require is appropriate and avoids relying on globalThis.
// eslint-disable-next-line @typescript-eslint/no-var-requires
mod = require('../../db/games');
} catch (e) {
// DB-only mode: fail fast so callers know persistence is required.
throw new Error('Game DB module could not be loaded: ' + String(e));
}
// If the module uses default export, prefer it
let db: any = (mod && (mod.default || mod));
// If the required module returned a Promise (the db initializer may), await it.
if (db && typeof db.then === 'function') {
try { try {
db = await db; // Use runtime require to load the DB module. This runs under Node (ts-node)
// so a direct require is appropriate and avoids relying on globalThis.
// eslint-disable-next-line @typescript-eslint/no-var-requires
mod = require("../../db/games");
} catch (e) { } catch (e) {
throw new Error('Game DB initializer promise rejected: ' + String(e)); // DB-only mode: fail fast so callers know persistence is required.
throw new Error("Game DB module could not be loaded: " + String(e));
}
// If the module uses default export, prefer it
let db = mod.default || mod;
// If the required module returned a Promise (the db initializer may), await it.
if (db && typeof db.then === "function") {
try {
db = await db;
} catch (e) {
throw new Error("Game DB initializer promise rejected: " + String(e));
}
} }
}
// attach typed helper placeholders (will be implemented incrementally) gameDB.db = db;
if (!db.getGameById) { return db;
db.getGameById = async (id: string | number): Promise<GameState | null> => { },
// fallback: try to query by id using raw SQL if sequelize is available
if (db && db.sequelize) { getGameById: async (id: string | number): Promise<Game | null> => {
try { if (!gameDB.db) {
const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', { await gameDB.init();
replacements: { id }, }
type: db.Sequelize.QueryTypes.SELECT const db = gameDB.db;
}); // fallback: try to query by id using raw SQL if sequelize is available
if (rows && rows.length) { if (db && db.sequelize) {
const r = rows[0] as any; try {
// state may be stored as text or JSON const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
if (typeof r.state === 'string') { replacements: { id },
try { type: db.Sequelize.QueryTypes.SELECT,
return JSON.parse(r.state) as GameState; });
} catch (e) { if (rows && rows.length) {
return null; const r = rows[0] as any;
} // state may be stored as text or JSON
if (typeof r.state === "string") {
try {
return JSON.parse(r.state) as Game;
} catch (e) {
return null;
} }
return r.state as GameState;
} }
} catch (e) { return r.state as Game;
// ignore and fallthrough
} }
} catch (e) {
// ignore and fallthrough
} }
// If DB didn't have a state or query failed, attempt to read from the }
// filesystem copy at db/games/<id> or <id>.json so the state remains editable. // If DB didn't have a state or query failed, attempt to read from the
try { // filesystem copy at db/games/<id> or <id>.json so the state remains editable.
const gamesDir = path.resolve(__dirname, '../../../db/games'); try {
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')]; const gamesDir = path.resolve(__dirname, "../../../db/games");
for (const filePath of candidates) { const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")];
try { for (const filePath of candidates) {
const raw = await fsp.readFile(filePath, 'utf8');
return JSON.parse(raw) as GameState;
} catch (e) {
// try next candidate
}
}
return null;
} catch (err) {
return null;
}
};
}
if (!db.saveGameState) {
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
// Always persist a JSON file so game state is inspectable/editable.
try {
const gamesDir = path.resolve(__dirname, '../../../db/games');
await fsp.mkdir(gamesDir, { recursive: true });
// Write extensionless filename to match existing files
const filePath = path.join(gamesDir, String(id));
const tmpPath = `${filePath}.tmp`;
await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
await fsp.rename(tmpPath, filePath);
} catch (err) {
// Log but continue to attempt DB persistence
// eslint-disable-next-line no-console
console.error('Failed to write game JSON file for', id, err);
}
// Now attempt DB persistence if sequelize is present.
if (db && db.sequelize) {
const payload = JSON.stringify(state);
// Try an UPDATE; if it errors due to missing column, try to add the
// column and retry. If update affects no rows, try INSERT.
try { try {
try { const raw = await fsp.readFile(filePath, "utf8");
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { return JSON.parse(raw) as Game;
replacements: { id, state: payload } } catch (e) {
// try next candidate
}
}
return null;
} catch (err) {
return null;
}
},
saveGame: async (game: Game): Promise<void> => {
if (!gameDB.db) {
await gameDB.init();
}
const db = gameDB.db;
/* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then
* delete the player field from them */
const reducedGame = Object.assign({}, game, { sessions: {} }),
reducedSessions = [];
for (let id in game.sessions) {
const reduced = Object.assign({}, game.sessions[id]);
// Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth)
transientState.stripSessionTransients(reduced);
reducedGame.sessions[id] = reduced;
/* Do not send session-id as those are secrets */
reducedSessions.push(reduced);
}
// Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA)
transientState.stripGameTransients(reducedGame);
/* Save per turn while debugging... */
game.step = game.step ? game.step : 0;
// Always persist a JSON file so game state is inspectable/editable.
try {
const gamesDir = path.resolve(__dirname, "../../../db/games");
await fsp.mkdir(gamesDir, { recursive: true });
// Write extensionless filename to match existing files
const filePath = path.join(gamesDir, reducedGame.id);
const tmpPath = `${filePath}.tmp`;
await fsp.writeFile(tmpPath, JSON.stringify(reducedGame, null, 2), "utf8");
await fsp.rename(tmpPath, filePath);
} catch (err) {
// Log but continue to attempt DB persistence
// eslint-disable-next-line no-console
console.error("Failed to write game JSON file for", reducedGame.id, err);
}
// Now attempt DB persistence if sequelize is present.
if (db && db.sequelize) {
const payload = JSON.stringify(reducedGame);
// Try an UPDATE; if it errors due to missing column, try to add the
// column and retry. If update affects no rows, try INSERT.
try {
try {
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
replacements: { id: reducedGame.id, state: payload },
});
// Some dialects don't return affectedRows consistently; we'll
// still attempt insert if no row exists by checking select.
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
replacements: { id: reducedGame.id },
type: db.Sequelize.QueryTypes.SELECT,
});
if (!check || check.length === 0) {
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
replacements: { id: reducedGame.id, state: payload },
}); });
// Some dialects don't return affectedRows consistently; we'll }
// still attempt insert if no row exists by checking select. } catch (e: any) {
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', { const msg = String(e && e.message ? e.message : e);
replacements: { id }, // If the column doesn't exist (SQLite: no such column: state), add it.
type: db.Sequelize.QueryTypes.SELECT if (
}); /no such column: state/i.test(msg) ||
if (!check || check.length === 0) { /has no column named state/i.test(msg) ||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { /unknown column/i.test(msg)
replacements: { id, state: payload } ) {
try {
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
// retry insert/update after adding column
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
replacements: { id: reducedGame.id, state: payload },
}); });
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
replacements: { id: reducedGame.id },
type: db.Sequelize.QueryTypes.SELECT,
});
if (!check || check.length === 0) {
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
replacements: { id: reducedGame.id, state: payload },
});
}
} catch (inner) {
// swallow; callers should handle missing persistence
} }
} catch (e: any) { } else {
const msg = String(e && e.message ? e.message : e); // For other errors, attempt insert as a fallback
// If the column doesn't exist (SQLite: no such column: state), add it. try {
if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) { await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
try { replacements: { id: reducedGame.id, state: payload },
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT'); });
// retry insert/update after adding column } catch (err) {
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { // swallow; callers should handle missing persistence
replacements: { id, state: payload }
});
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
replacements: { id },
type: db.Sequelize.QueryTypes.SELECT
});
if (!check || check.length === 0) {
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { id, state: payload }
});
}
} catch (inner) {
// swallow; callers should handle missing persistence
}
} else {
// For other errors, attempt insert as a fallback
try {
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { id, state: payload }
});
} catch (err) {
// swallow; callers should handle missing persistence
}
} }
} }
} catch (finalErr) {
// swallow; we don't want persistence errors to crash the server
} }
} catch (finalErr) {
// swallow; we don't want persistence errors to crash the server
} }
}; }
} },
deleteGame: async (id: string | number): Promise<void> => {
if (!db.deleteGame) { if (!gameDB.db) {
db.deleteGame = async (id: string | number): Promise<void> => { await gameDB.init();
if (db && db.sequelize) { }
try { const db = gameDB.db;
await db.sequelize.query('DELETE FROM games WHERE id=:id', { if (db && db.sequelize) {
replacements: { id } try {
}); await db.sequelize.query("DELETE FROM games WHERE id=:id", {
} catch (e) { replacements: { id },
// swallow; callers should handle missing persistence });
} } catch (e) {
// swallow; callers should handle missing persistence
} }
}; }
} },
};
return db as GameDB; export const games: Record<string, Game> = {};
}

View File

@ -38,6 +38,9 @@ export interface Player {
[key: string]: any; // allow incremental fields until fully typed [key: string]: any; // allow incremental fields until fully typed
} }
export type CornerType = "settlement" | "city" | "none";
export const CORNER_TYPES: CornerType[] = ["settlement", "city", "none"];
export interface CornerPlacement { export interface CornerPlacement {
color: PlayerColor; color: PlayerColor;
type: "settlement" | "city" | "none"; type: "settlement" | "city" | "none";
@ -46,6 +49,9 @@ export interface CornerPlacement {
[key: string]: any; [key: string]: any;
} }
export type RoadType = "road" | "ship";
export const ROAD_TYPES: RoadType[] = ["road", "ship"];
export interface RoadPlacement { export interface RoadPlacement {
color?: PlayerColor; color?: PlayerColor;
walking?: boolean; walking?: boolean;
@ -60,7 +66,7 @@ export interface Placements {
export interface Turn { export interface Turn {
name?: string; name?: string;
color?: PlayerColor; color: PlayerColor;
actions?: string[]; actions?: string[];
limits?: any; limits?: any;
roll?: number; roll?: number;
@ -119,7 +125,7 @@ export interface Offer {
} }
export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert"; export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert";
export const RESOURCE_TYPES = ["wood", "brick", "sheep", "wheat", "stone", "desert"] as ResourceType[]; export const RESOURCE_TYPES: ResourceType[] = ["wood", "brick", "sheep", "wheat", "stone", "desert"];
export interface Tile { export interface Tile {
robber: boolean; robber: boolean;
@ -153,11 +159,11 @@ export interface Game {
dice?: number[]; dice?: number[];
chat?: any[]; chat?: any[];
activities?: any[]; activities?: any[];
playerOrder?: string[]; playerOrder: PlayerColor[];
state?: string; state?: string;
robber?: number; robber?: number;
robberName?: string; robberName?: string;
turns?: number; turns: number;
longestRoad?: string | false; longestRoad?: string | false;
longestRoadLength?: number; longestRoadLength?: number;
borderOrder?: number[]; borderOrder?: number[];
@ -173,6 +179,10 @@ export interface Game {
startTime?: number; startTime?: number;
direction?: "forward" | "backward"; direction?: "forward" | "backward";
winner?: string | false; winner?: string | false;
history?: any[];
createdAt?: string;
} }
export type GameId = string;
export type IncomingMessage = { type: string | null; data: any }; export type IncomingMessage = { type: string | null; data: any };

View File

@ -32,7 +32,7 @@ export const join = (
const ws = session.ws; const ws = session.ws;
if (!session.name) { if (!session.name) {
console.error(`${session.id}: <- join - No name set yet. Audio not available.`); console.error(`${session.short}: <- join - No name set yet. Audio not available.`);
send(ws, { send(ws, {
type: "join_status", type: "join_status",
status: "Error", status: "Error",
@ -41,13 +41,13 @@ export const join = (
return; return;
} }
console.log(`${session.id}: <- join - ${session.name}`); console.log(`${session.short}: <- join - ${session.name}`);
// Determine media capability - prefer has_media if provided // Determine media capability - prefer has_media if provided
const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio; const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio;
if (session.name in peers) { if (session.name in peers) {
console.log(`${session.id}:${session.name} - Already joined to Audio, updating WebSocket reference.`); console.log(`${session.short}:${session.name} - Already joined to Audio, updating WebSocket reference.`);
try { try {
const prev = peers[session.name] && peers[session.name].ws; const prev = peers[session.name] && peers[session.name].ws;
if (prev && prev._pingInterval) { if (prev && prev._pingInterval) {
@ -144,9 +144,7 @@ export const join = (
export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => { export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => {
const ws = session.ws; const ws = session.ws;
const send = safeSend const send = safeSend ? safeSend : defaultSend;
? safeSend
: defaultSend;
if (!session.name) { if (!session.name) {
console.error(`${session.id}: <- part - No name set yet. Audio not available.`); console.error(`${session.id}: <- part - No name set yet. Audio not available.`);
@ -154,12 +152,12 @@ export const part = (peers: any, session: any, safeSend?: (targetOrSession: any,
} }
if (!(session.name in peers)) { if (!(session.name in peers)) {
console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`); console.log(`${session.short}: <- ${session.name} - Does not exist in game audio.`);
return; return;
} }
console.log(`${session.id}: <- ${session.name} - Audio part.`); console.log(`${session.short}: <- ${session.name} - Audio part.`);
console.log(`-> removePeer - ${session.name}`); console.log(`${session.short}: -> removePeer - ${session.name}`);
delete peers[session.name]; delete peers[session.name];
@ -247,7 +245,11 @@ export const handleRelaySessionDescription = (
send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } }); send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } });
return; return;
} }
if (debug && debug.audio) console.log(`${session.id}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`, session_description); if (debug && debug.audio)
console.log(
`${session.short}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`,
session_description
);
const message = JSON.stringify({ const message = JSON.stringify({
type: "sessionDescription", type: "sessionDescription",
data: { data: {

View File

@ -1,11 +1,11 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from "express";
import express from 'express'; import express from "express";
import bodyParser from 'body-parser'; import bodyParser from "body-parser";
import config from 'config'; import config from "config";
import basePath from '../basepath'; import basePath from "../basepath";
import cookieParser from 'cookie-parser'; import cookieParser from "cookie-parser";
import http from 'http'; import http from "http";
import expressWs from 'express-ws'; import expressWs from "express-ws";
process.env.TZ = "Etc/GMT"; process.env.TZ = "Etc/GMT";
@ -29,7 +29,7 @@ try {
const debugRouter = require("../routes/debug").default || require("../routes/debug"); const debugRouter = require("../routes/debug").default || require("../routes/debug");
app.use(basePath, debugRouter); app.use(basePath, debugRouter);
} catch (e: any) { } catch (e: any) {
console.error('Failed to mount debug routes (src):', e && e.stack || e); console.error("Failed to mount debug routes (src):", (e && e.stack) || e);
} }
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/", const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
@ -87,34 +87,18 @@ app.use(basePath, index);
*/ */
app.set("port", serverConfig.port); app.set("port", serverConfig.port);
process.on('SIGINT', () => { process.on("SIGINT", () => {
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds"); console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
setTimeout(() => process.exit(-1), 2000); setTimeout(() => process.exit(-1), 2000);
server.close(() => process.exit(1)); server.close(() => process.exit(1));
}); });
// database initializers console.log("Opening server.");
// eslint-disable-next-line @typescript-eslint/no-var-requires server.listen(serverConfig.port, () => {
import { initGameDB } from '../routes/games/store'; console.log(`http/ws server listening on ${serverConfig.port}`);
initGameDB().then(function(_db: any) {
// games DB initialized via store facade
}).then(function() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return Promise.resolve((require("../db/users") as any).default || require("../db/users")).then(function(_db: any) {
// users DB initialized
});
}).then(function() {
console.log("DB connected. Opening server.");
server.listen(serverConfig.port, () => {
console.log(`http/ws server listening on ${serverConfig.port}`);
});
}).catch(function(error: any) {
console.error(error);
process.exit(-1);
}); });
server.on("error", function(error: any) { server.on("error", function (error: any) {
if (error.syscall !== "listen") { if (error.syscall !== "listen") {
throw error; throw error;
} }

View File

@ -1,67 +1,67 @@
#!/usr/bin/env ts-node #!/usr/bin/env ts-node
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { initGameDB } from '../routes/games/store'; import { gameDB } from "../routes/games/store";
async function main() { async function main() {
const gamesDir = path.resolve(__dirname, '../../db/games'); const gamesDir = path.resolve(__dirname, "../../db/games");
let files: string[] = []; let files: string[] = [];
try { try {
files = await fs.readdir(gamesDir); files = await fs.readdir(gamesDir);
} catch (e) { } catch (e) {
console.error('Failed to read games dir', gamesDir, e); console.error("Failed to read games dir", gamesDir, e);
process.exit(2); process.exit(2);
} }
let db: any; if (!gameDB.db) {
try { await gameDB.init();
db = await initGameDB();
} catch (e) {
console.error('Failed to initialize DB', e);
process.exit(3);
} }
if (!db || !db.sequelize) { let db = gameDB.db;
console.error('DB did not expose sequelize; cannot proceed.');
if (!db.sequelize) {
console.error("DB did not expose sequelize; cannot proceed.");
process.exit(4); process.exit(4);
} }
for (const f of files) { for (const f of files) {
// ignore dotfiles and .bk backup files (we don't want to import backups) // ignore dotfiles and .bk backup files (we don't want to import backups)
if (f.startsWith('.') || f.endsWith('.bk')) continue; if (f.startsWith(".") || f.endsWith(".bk")) continue;
const full = path.join(gamesDir, f); const full = path.join(gamesDir, f);
try { try {
const stat = await fs.stat(full); const stat = await fs.stat(full);
if (!stat.isFile()) continue; if (!stat.isFile()) continue;
const raw = await fs.readFile(full, 'utf8'); const raw = await fs.readFile(full, "utf8");
const state = JSON.parse(raw); const game = JSON.parse(raw);
// Derive id from filename (strip .json if present) // Derive id from filename (strip .json if present)
const idStr = f.endsWith('.json') ? f.slice(0, -5) : f; const idStr = f.endsWith(".json") ? f.slice(0, -5) : f;
const id = isNaN(Number(idStr)) ? idStr : Number(idStr); const id = isNaN(Number(idStr)) ? idStr : Number(idStr);
// derive a friendly name from the saved state when present // derive a friendly name from the saved game when present
const nameCandidate = (state && (state.name || state.id)) ? String(state.name || state.id) : undefined; const nameCandidate = game && (game.name || game.id) ? String(game.name || game.id) : undefined;
try { try {
if (typeof id === 'number') { if (typeof id === "number") {
// numeric filename: use the typed helper // numeric filename: use the typed helper
await db.saveGameState(id, state); await db.saveGame(game);
console.log(`Saved game id=${id}`); console.log(`Saved game id=${id}`);
if (nameCandidate) { if (nameCandidate) {
try { try {
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id, name: nameCandidate } }); await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", {
replacements: { id, name: nameCandidate },
});
} catch (_) { } catch (_) {
// ignore name update failure // ignore name update failure
} }
} }
} else { } else {
// string filename: try to find an existing row by path and save via id; // string filename: try to find an existing row by path and save via id;
// otherwise insert a new row with path and the JSON state. // otherwise insert a new row with path and the JSON game.
let found: any[] = []; let found: any[] = [];
try { try {
found = await db.sequelize.query('SELECT id FROM games WHERE path=:path', { found = await db.sequelize.query("SELECT id FROM games WHERE path=:path", {
replacements: { path: idStr }, replacements: { path: idStr },
type: db.Sequelize.QueryTypes.SELECT type: db.Sequelize.QueryTypes.SELECT,
}); });
} catch (qe) { } catch (qe) {
found = []; found = [];
@ -69,11 +69,13 @@ async function main() {
if (found && found.length) { if (found && found.length) {
const foundId = found[0].id; const foundId = found[0].id;
await db.saveGameState(foundId, state); await db.saveGame(game);
console.log(`Saved game path=${idStr} -> id=${foundId}`); console.log(`Saved game path=${idStr} -> id=${foundId}`);
if (nameCandidate) { if (nameCandidate) {
try { try {
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id: foundId, name: nameCandidate } }); await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", {
replacements: { id: foundId, name: nameCandidate },
});
} catch (_) { } catch (_) {
// ignore // ignore
} }
@ -81,28 +83,32 @@ async function main() {
} else { } else {
// ensure state column exists before inserting a new row // ensure state column exists before inserting a new row
try { try {
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT'); await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
} catch (_) { } catch (_) {
// ignore // ignore
} }
const payload = JSON.stringify(state); const payload = JSON.stringify(game);
if (nameCandidate) { if (nameCandidate) {
await db.sequelize.query('INSERT INTO games (path, state, name) VALUES(:path, :state, :name)', { replacements: { path: idStr, state: payload, name: nameCandidate } }); await db.sequelize.query("INSERT INTO games (path, state, name) VALUES(:path, :state, :name)", {
replacements: { path: idStr, state: payload, name: nameCandidate },
});
} else { } else {
await db.sequelize.query('INSERT INTO games (path, state) VALUES(:path, :state)', { replacements: { path: idStr, state: payload } }); await db.sequelize.query("INSERT INTO games (path, state) VALUES(:path, :state)", {
replacements: { path: idStr, state: payload },
});
} }
console.log(`Inserted game path=${idStr}`); console.log(`Inserted game path=${idStr}`);
} }
} }
} catch (e) { } catch (e) {
console.error('Failed to save game', idStr, e); console.error("Failed to save game", idStr, e);
} }
} catch (e) { } catch (e) {
console.error('Failed to read/parse', full, e); console.error("Failed to read/parse", full, e);
} }
} }
console.log('Import complete'); console.log("Import complete");
process.exit(0); process.exit(0);
} }

View File

@ -1,5 +1,5 @@
#!/usr/bin/env ts-node #!/usr/bin/env ts-node
import { initGameDB } from '../routes/games/store'; import { gameDB } from "../routes/games/store";
type Args = { type Args = {
gameId?: string; gameId?: string;
@ -10,8 +10,8 @@ function parseArgs(): Args {
const res: Args = {}; const res: Args = {};
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const a = args[i]; const a = args[i];
if ((a === '-g' || a === '--game') && args[i+1]) { if ((a === "-g" || a === "--game") && args[i + 1]) {
res.gameId = String(args[i+1]); res.gameId = String(args[i + 1]);
i++; i++;
} }
} }
@ -21,57 +21,56 @@ function parseArgs(): Args {
async function main() { async function main() {
const { gameId } = parseArgs(); const { gameId } = parseArgs();
let db: any; if (!gameDB.db) {
try { await gameDB.init();
db = await initGameDB();
} catch (e) {
console.error('Failed to initialize game DB:', e);
process.exit(1);
} }
let db = gameDB.db;
if (!db || !db.sequelize) { if (!db.sequelize) {
console.error('DB does not expose sequelize; cannot run queries.'); console.error("DB does not expose sequelize; cannot run queries.");
process.exit(1); process.exit(1);
} }
if (!gameId) { if (!gameId) {
// List all game ids // List all game ids
try { try {
const rows: any[] = await db.sequelize.query('SELECT id, name FROM games', { type: db.Sequelize.QueryTypes.SELECT }); const rows: any[] = await db.sequelize.query("SELECT id, name FROM games", {
type: db.Sequelize.QueryTypes.SELECT,
});
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
console.log('No games found.'); console.log("No games found.");
return; return;
} }
console.log('Games:'); console.log("Games:");
rows.forEach(r => console.log(`${r.id} - ${r.name}`)); rows.forEach((r) => console.log(`${r.id} - ${r.name}`));
} catch (e) { } catch (e) {
console.error('Failed to list games:', e); console.error("Failed to list games:", e);
process.exit(1); process.exit(1);
} }
} else { } else {
// For a given game ID, try to print the turns history from the state // For a given game ID, try to print the turns history from the state
try { try {
const rows: any[] = await db.sequelize.query('SELECT state FROM games WHERE id=:id', { const rows: any[] = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
replacements: { id: gameId }, replacements: { id: gameId },
type: db.Sequelize.QueryTypes.SELECT type: db.Sequelize.QueryTypes.SELECT,
}); });
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
console.error('Game not found:', gameId); console.error("Game not found:", gameId);
process.exit(2); process.exit(2);
} }
const r = rows[0] as any; const r = rows[0] as any;
let state = r.state; let state = r.state;
if (typeof state === 'string') { if (typeof state === "string") {
try { try {
state = JSON.parse(state); state = JSON.parse(state);
} catch (e) { } catch (e) {
console.error('Failed to parse stored state JSON:', e); console.error("Failed to parse stored state JSON:", e);
process.exit(3); process.exit(3);
} }
} }
if (!state) { if (!state) {
console.error('Empty state for game', gameId); console.error("Empty state for game", gameId);
process.exit(4); process.exit(4);
} }
@ -79,21 +78,21 @@ async function main() {
console.log(` - turns: ${state.turns || 0}`); console.log(` - turns: ${state.turns || 0}`);
if (state.turnHistory || state.turnsData || state.turns_list) { if (state.turnHistory || state.turnsData || state.turns_list) {
const turns = state.turnHistory || state.turnsData || state.turns_list; const turns = state.turnHistory || state.turnsData || state.turns_list;
console.log('Turns:'); console.log("Turns:");
turns.forEach((t: any, idx: number) => { turns.forEach((t: any, idx: number) => {
console.log(`${idx}: ${JSON.stringify(t)}`); console.log(`${idx}: ${JSON.stringify(t)}`);
}); });
} else if (state.turns && state.turns > 0) { } else if (state.turns && state.turns > 0) {
console.log('No explicit turn history found inside state; showing snapshot metadata.'); console.log("No explicit turn history found inside state; showing snapshot metadata.");
// Print limited snapshot details per turn if available // Print limited snapshot details per turn if available
if (state.turnsData) { if (state.turnsData) {
state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`)); state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`));
} }
} else { } else {
console.log('No turn history recorded in state.'); console.log("No turn history recorded in state.");
} }
} catch (e) { } catch (e) {
console.error('Failed to load game state for', gameId, e); console.error("Failed to load game state for", gameId, e);
process.exit(1); process.exit(1);
} }
} }

View File

@ -1,6 +1,7 @@
"use strict"; "use strict";
import { Tile } from "../routes/games/types"; import { Tile } from "../routes/games/types";
import { ResourceType } from "../routes/games/types";
/* Board Tiles: /* Board Tiles:
* 0 1 2 * 0 1 2

View File

@ -1,4 +1,5 @@
import { layout } from './layout'; import { CornerType, Game, PlayerColor } from "../routes/games/types";
import { layout } from "./layout";
const isRuleEnabled = (game: any, rule: string): boolean => { const isRuleEnabled = (game: any, rule: string): boolean => {
return rule in game.rules && game.rules[rule].enabled; return rule in game.rules && game.rules[rule].enabled;
@ -62,28 +63,29 @@ const getValidRoads = (game: any, color: string): number[] => {
}); });
return limits; return limits;
} };
const getValidCorners = (game: any, color: string, type?: string): number[] => { const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: CornerType): number[] => {
const limits: number[] = []; const limits: number[] = [];
console.log("getValidCorners", color, type);
/* For each corner, if the corner already has a color set, skip it if type /* For each corner, if the corner already has a color set, skip it if type
* isn't set. If type is set, if it is a match, and the color is a match, * isn't set. If type is set and is a match, and the color is a match,
* add it to the list. * add it to the list.
* *
* If we are limiting based on active player, a corner is only valid * If we are limiting based on active player, a corner is only valid
* if it connects to a road that is owned by that player. * if it connects to a road that is owned by that player.
* *
* If no color is set, walk each road that leaves that corner and * If no color is set, walk each road that leaves that corner and
* check to see if there is a settlement placed at the end of that road * check to see if there is a settlement placed at the end of that road
* *
* If so, this location cannot have a settlement. * If so, this location cannot have a settlement.
* *
* If still valid, and we are in initial settlement placement, and if * If still valid, and we are in initial settlement placement, and if
* Volcano is enabled, verify the tile is not the Volcano. * Volcano is enabled, verify the tile is not the Volcano.
*/ */
layout.corners.forEach((corner, cornerIndex) => { layout.corners.forEach((corner, cornerIndex) => {
const placement = game.placements.corners[cornerIndex]; const placement = game.placements.corners[cornerIndex]!;
if (type) { if (type) {
if (placement.color === color && placement.type === type) { if (placement.color === color && placement.type === type) {
limits.push(cornerIndex); limits.push(cornerIndex);
@ -93,7 +95,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
// If the corner has a color set and it's not the explicit sentinel // If the corner has a color set and it's not the explicit sentinel
// "unassigned" then it's occupied and should be skipped. // "unassigned" then it's occupied and should be skipped.
if (placement.color && placement.color !== "unassigned") { if (placement.color !== "unassigned") {
return; return;
} }
@ -107,7 +109,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
if (rr == null) { if (rr == null) {
continue; continue;
} }
const placementsRoads = (game as any).placements && (game as any).placements.roads; const placementsRoads = game.placements && game.placements.roads;
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color); valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
} }
} }
@ -116,11 +118,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
if (!corner.roads) { if (!corner.roads) {
break; break;
} }
const ridx = corner.roads[r] as number; const ridx = corner.roads[r];
if (ridx == null || (layout as any).roads[ridx] == null) { if (ridx == null || layout.roads[ridx] == null) {
continue; continue;
} }
const road = (layout as any).roads[ridx]; const road = layout.roads[ridx];
for (let c = 0; valid && c < (road.corners || []).length; c++) { for (let c = 0; valid && c < (road.corners || []).length; c++) {
/* This side of the road is pointing to the corner being validated. /* This side of the road is pointing to the corner being validated.
* Skip it. */ * Skip it. */
@ -130,13 +132,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
/* There is a settlement within one segment from this /* There is a settlement within one segment from this
* corner, so it is invalid for settlement placement */ * corner, so it is invalid for settlement placement */
const cc = road.corners[c] as number; const cc = road.corners[c] as number;
if ( if (game.placements.corners[cc]!.color !== "unassigned") {
(game as any).placements &&
(game as any).placements.corners &&
(game as any).placements.corners[cc] &&
(game as any).placements.corners[cc].color &&
(game as any).placements.corners[cc].color !== "unassigned"
) {
valid = false; valid = false;
} }
} }
@ -149,10 +145,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
!( !(
game.state === "initial-placement" && game.state === "initial-placement" &&
isRuleEnabled(game, "volcano") && isRuleEnabled(game, "volcano") &&
(layout as any).tiles && game.robber &&
(layout as any).tiles[(game as any).robber] && layout.tiles &&
Array.isArray((layout as any).tiles[(game as any).robber].corners) && layout.tiles[game.robber] &&
(layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1 Array.isArray(layout.tiles[game.robber]?.corners) &&
layout.tiles[game.robber]?.corners.indexOf(cornerIndex as number) !== -1
) )
) { ) {
limits.push(cornerIndex); limits.push(cornerIndex);
@ -161,10 +158,6 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
}); });
return limits; return limits;
} };
export { export { getValidCorners, getValidRoads, isRuleEnabled };
getValidCorners,
getValidRoads,
isRuleEnabled
};