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/
certs/
**/node_modules/

View File

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

View File

@ -351,10 +351,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard
sx={{
float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
clipPath: "inset(0)" /* Ensures proper wrapping area */,
marginRight: "1rem",
marginBottom: "1rem",
marginRight: "0.5rem",
marginBottom: "0.5rem",
}}
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
majestic castle!
</Typography>
<Box sx={{ clear: "both" }}></Box>
</Box>
),
},
@ -378,10 +377,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard
sx={{
float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
clipPath: "inset(0)" /* Ensures proper wrapping area */,
marginRight: "1rem",
marginBottom: "1rem",
marginRight: "0.5rem",
marginBottom: "0.5rem",
}}
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
from under your nose. Keep those ships moving and never let your rivals toast to your downfall!
</Typography>
<Box sx={{ clear: "both" }}></Box>
</Box>
),
},
@ -406,10 +404,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard
sx={{
float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
clipPath: "inset(0)" /* Ensures proper wrapping area */,
marginRight: "1rem",
marginBottom: "1rem",
marginRight: "0.5rem",
marginBottom: "0.5rem",
}}
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
backgrounduntil someone even slower takes it from you with a sheepish grin!
</Typography>
<Box sx={{ clear: "both" }}></Box>
</Box>
),
},

View File

@ -1518,22 +1518,6 @@ const MediaControl: React.FC<MediaControlProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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(
(e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();

View File

@ -121,7 +121,7 @@ const PlayerList: React.FC = () => {
break;
}
default:
console.log(`player-list - ignoring message: ${data.type}`);
// console.log(`player-list - ignoring message: ${data.type}`);
break;
}
}, [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 { 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();
@ -80,7 +83,7 @@ export const getFirstPlayerName = (game: Game): string => {
};
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
let color: string | undefined;
let color: PlayerColor | undefined;
for (let id in game.sessions) {
const s = game.sessions[id];
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 => {
let color: string | undefined;
let color: PlayerColor | undefined;
for (let id in game.sessions) {
const s = game.sessions[id];
if (s && s.name === name) {
@ -164,7 +167,7 @@ export const setForCityPlacement = (game: Game, limits: any): void => {
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.limits = { corners: limits };
};
@ -187,3 +190,355 @@ export const adjustResources = (player: Player, deltas: Partial<Record<string, n
});
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
return JSON.stringify(game);
}
};
export function deserializeGame(serialized: string): GameState {
export const deserializeGame = async (serialized: string): Promise<Game> => {
try {
return JSON.parse(serialized) as GameState;
return JSON.parse(serialized) as Game;
} catch (e) {
// 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));
}
};

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,68 +1,67 @@
import type { GameState } from './state.js';
import { promises as fsp } from 'fs';
import path from 'path';
import type { Game } from "./types";
import { promises as fsp } from "fs";
import path from "path";
import { transientState } from "./sessionState";
export interface GameDB {
sequelize?: any;
Sequelize?: any;
getGameById(id: string | number): Promise<GameState | null>;
saveGameState(id: string | number, state: GameState): Promise<void>;
deleteGame?(id: string | number): Promise<void>;
[k: string]: any;
interface GameDB {
db: any | null;
init(): Promise<void>;
getGameById(id: string): Promise<Game | null>;
saveGame(game: Game): Promise<void>;
deleteGame?(id: string): Promise<void>;
}
/**
* Thin game DB initializer / accessor.
* This currently returns the underlying db module (for runtime compatibility)
* and is the single place to add typed helper methods for game persistence.
*/
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.
export const gameDB: GameDB = {
db: null,
init: async () => {
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');
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));
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));
let db = mod.default || mod;
// If the required module returned a Promise (the db initializer may), await it.
if (db && typeof db.then === 'function') {
if (db && typeof db.then === "function") {
try {
db = await db;
} catch (e) {
throw new Error('Game DB initializer promise rejected: ' + String(e));
throw new Error("Game DB initializer promise rejected: " + String(e));
}
}
// attach typed helper placeholders (will be implemented incrementally)
if (!db.getGameById) {
db.getGameById = async (id: string | number): Promise<GameState | null> => {
gameDB.db = db;
return db;
},
getGameById: async (id: string | number): Promise<Game | null> => {
if (!gameDB.db) {
await gameDB.init();
}
const db = gameDB.db;
// fallback: try to query by id using raw SQL if sequelize is available
if (db && db.sequelize) {
try {
const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
replacements: { id },
type: db.Sequelize.QueryTypes.SELECT
type: db.Sequelize.QueryTypes.SELECT,
});
if (rows && rows.length) {
const r = rows[0] as any;
// state may be stored as text or JSON
if (typeof r.state === 'string') {
if (typeof r.state === "string") {
try {
return JSON.parse(r.state) as GameState;
return JSON.parse(r.state) as Game;
} catch (e) {
return null;
}
}
return r.state as GameState;
return r.state as Game;
}
} catch (e) {
// ignore and fallthrough
@ -71,12 +70,12 @@ export async function initGameDB(): Promise<GameDB> {
// 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.
try {
const gamesDir = path.resolve(__dirname, '../../../db/games');
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')];
const gamesDir = path.resolve(__dirname, "../../../db/games");
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")];
for (const filePath of candidates) {
try {
const raw = await fsp.readFile(filePath, 'utf8');
return JSON.parse(raw) as GameState;
const raw = await fsp.readFile(filePath, "utf8");
return JSON.parse(raw) as Game;
} catch (e) {
// try next candidate
}
@ -85,64 +84,92 @@ export async function initGameDB(): Promise<GameDB> {
} 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);
}
if (!db.saveGameState) {
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
// 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');
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 filePath = path.join(gamesDir, reducedGame.id);
const tmpPath = `${filePath}.tmp`;
await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
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', id, err);
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(state);
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, state: payload }
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 },
type: db.Sequelize.QueryTypes.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, state: payload }
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
replacements: { id: reducedGame.id, state: payload },
});
}
} catch (e: any) {
const msg = String(e && e.message ? e.message : e);
// If the column doesn't exist (SQLite: no such column: state), add it.
if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) {
if (
/no such column: state/i.test(msg) ||
/has no column named state/i.test(msg) ||
/unknown column/i.test(msg)
) {
try {
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
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, state: payload }
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 },
type: db.Sequelize.QueryTypes.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, state: payload }
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
replacements: { id: reducedGame.id, state: payload },
});
}
} catch (inner) {
@ -151,8 +178,8 @@ export async function initGameDB(): Promise<GameDB> {
} 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 }
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
replacements: { id: reducedGame.id, state: payload },
});
} catch (err) {
// swallow; callers should handle missing persistence
@ -163,22 +190,22 @@ export async function initGameDB(): Promise<GameDB> {
// swallow; we don't want persistence errors to crash the server
}
}
};
},
deleteGame: async (id: string | number): Promise<void> => {
if (!gameDB.db) {
await gameDB.init();
}
if (!db.deleteGame) {
db.deleteGame = async (id: string | number): Promise<void> => {
const db = gameDB.db;
if (db && db.sequelize) {
try {
await db.sequelize.query('DELETE FROM games WHERE id=:id', {
replacements: { id }
await db.sequelize.query("DELETE FROM games WHERE id=:id", {
replacements: { id },
});
} 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
}
export type CornerType = "settlement" | "city" | "none";
export const CORNER_TYPES: CornerType[] = ["settlement", "city", "none"];
export interface CornerPlacement {
color: PlayerColor;
type: "settlement" | "city" | "none";
@ -46,6 +49,9 @@ export interface CornerPlacement {
[key: string]: any;
}
export type RoadType = "road" | "ship";
export const ROAD_TYPES: RoadType[] = ["road", "ship"];
export interface RoadPlacement {
color?: PlayerColor;
walking?: boolean;
@ -60,7 +66,7 @@ export interface Placements {
export interface Turn {
name?: string;
color?: PlayerColor;
color: PlayerColor;
actions?: string[];
limits?: any;
roll?: number;
@ -119,7 +125,7 @@ export interface Offer {
}
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 {
robber: boolean;
@ -153,11 +159,11 @@ export interface Game {
dice?: number[];
chat?: any[];
activities?: any[];
playerOrder?: string[];
playerOrder: PlayerColor[];
state?: string;
robber?: number;
robberName?: string;
turns?: number;
turns: number;
longestRoad?: string | false;
longestRoadLength?: number;
borderOrder?: number[];
@ -173,6 +179,10 @@ export interface Game {
startTime?: number;
direction?: "forward" | "backward";
winner?: string | false;
history?: any[];
createdAt?: string;
}
export type GameId = string;
export type IncomingMessage = { type: string | null; data: any };

View File

@ -32,7 +32,7 @@ export const join = (
const ws = session.ws;
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, {
type: "join_status",
status: "Error",
@ -41,13 +41,13 @@ export const join = (
return;
}
console.log(`${session.id}: <- join - ${session.name}`);
console.log(`${session.short}: <- join - ${session.name}`);
// Determine media capability - prefer has_media if provided
const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio;
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 {
const prev = peers[session.name] && peers[session.name].ws;
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 => {
const ws = session.ws;
const send = safeSend
? safeSend
: defaultSend;
const send = safeSend ? safeSend : defaultSend;
if (!session.name) {
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)) {
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;
}
console.log(`${session.id}: <- ${session.name} - Audio part.`);
console.log(`-> removePeer - ${session.name}`);
console.log(`${session.short}: <- ${session.name} - Audio part.`);
console.log(`${session.short}: -> removePeer - ${session.name}`);
delete peers[session.name];
@ -247,7 +245,11 @@ export const handleRelaySessionDescription = (
send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } });
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({
type: "sessionDescription",
data: {

View File

@ -1,11 +1,11 @@
import type { Request, Response, NextFunction } from 'express';
import express from 'express';
import bodyParser from 'body-parser';
import config from 'config';
import basePath from '../basepath';
import cookieParser from 'cookie-parser';
import http from 'http';
import expressWs from 'express-ws';
import type { Request, Response, NextFunction } from "express";
import express from "express";
import bodyParser from "body-parser";
import config from "config";
import basePath from "../basepath";
import cookieParser from "cookie-parser";
import http from "http";
import expressWs from "express-ws";
process.env.TZ = "Etc/GMT";
@ -29,7 +29,7 @@ try {
const debugRouter = require("../routes/debug").default || require("../routes/debug");
app.use(basePath, debugRouter);
} 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(/\/$/, "") + "/",
@ -87,32 +87,16 @@ app.use(basePath, index);
*/
app.set("port", serverConfig.port);
process.on('SIGINT', () => {
process.on("SIGINT", () => {
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
setTimeout(() => process.exit(-1), 2000);
server.close(() => process.exit(1));
});
// database initializers
// eslint-disable-next-line @typescript-eslint/no-var-requires
import { initGameDB } from '../routes/games/store';
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.");
console.log("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) {
if (error.syscall !== "listen") {

View File

@ -1,67 +1,67 @@
#!/usr/bin/env ts-node
import path from 'path';
import fs from 'fs/promises';
import { initGameDB } from '../routes/games/store';
import { gameDB } from "../routes/games/store";
async function main() {
const gamesDir = path.resolve(__dirname, '../../db/games');
const gamesDir = path.resolve(__dirname, "../../db/games");
let files: string[] = [];
try {
files = await fs.readdir(gamesDir);
} catch (e) {
console.error('Failed to read games dir', gamesDir, e);
console.error("Failed to read games dir", gamesDir, e);
process.exit(2);
}
let db: any;
try {
db = await initGameDB();
} catch (e) {
console.error('Failed to initialize DB', e);
process.exit(3);
if (!gameDB.db) {
await gameDB.init();
}
if (!db || !db.sequelize) {
console.error('DB did not expose sequelize; cannot proceed.');
let db = gameDB.db;
if (!db.sequelize) {
console.error("DB did not expose sequelize; cannot proceed.");
process.exit(4);
}
for (const f of files) {
// 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);
try {
const stat = await fs.stat(full);
if (!stat.isFile()) continue;
const raw = await fs.readFile(full, 'utf8');
const state = JSON.parse(raw);
const raw = await fs.readFile(full, "utf8");
const game = JSON.parse(raw);
// 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);
// derive a friendly name from the saved state when present
const nameCandidate = (state && (state.name || state.id)) ? String(state.name || state.id) : undefined;
// derive a friendly name from the saved game when present
const nameCandidate = game && (game.name || game.id) ? String(game.name || game.id) : undefined;
try {
if (typeof id === 'number') {
if (typeof id === "number") {
// numeric filename: use the typed helper
await db.saveGameState(id, state);
await db.saveGame(game);
console.log(`Saved game id=${id}`);
if (nameCandidate) {
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 (_) {
// ignore name update failure
}
}
} else {
// 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[] = [];
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 },
type: db.Sequelize.QueryTypes.SELECT
type: db.Sequelize.QueryTypes.SELECT,
});
} catch (qe) {
found = [];
@ -69,11 +69,13 @@ async function main() {
if (found && found.length) {
const foundId = found[0].id;
await db.saveGameState(foundId, state);
await db.saveGame(game);
console.log(`Saved game path=${idStr} -> id=${foundId}`);
if (nameCandidate) {
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 (_) {
// ignore
}
@ -81,28 +83,32 @@ async function main() {
} else {
// ensure state column exists before inserting a new row
try {
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
} catch (_) {
// ignore
}
const payload = JSON.stringify(state);
const payload = JSON.stringify(game);
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 {
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}`);
}
}
} catch (e) {
console.error('Failed to save game', idStr, e);
console.error("Failed to save game", idStr, 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);
}

View File

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

View File

@ -1,6 +1,7 @@
"use strict";
import { Tile } from "../routes/games/types";
import { ResourceType } from "../routes/games/types";
/* Board Tiles:
* 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 => {
return rule in game.rules && game.rules[rule].enabled;
@ -62,13 +63,14 @@ const getValidRoads = (game: any, color: string): number[] => {
});
return limits;
}
};
const getValidCorners = (game: any, color: string, type?: string): number[] => {
const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: CornerType): number[] => {
const limits: number[] = [];
console.log("getValidCorners", color, 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.
*
* If we are limiting based on active player, a corner is only valid
@ -83,7 +85,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
* Volcano is enabled, verify the tile is not the Volcano.
*/
layout.corners.forEach((corner, cornerIndex) => {
const placement = game.placements.corners[cornerIndex];
const placement = game.placements.corners[cornerIndex]!;
if (type) {
if (placement.color === color && placement.type === type) {
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
// "unassigned" then it's occupied and should be skipped.
if (placement.color && placement.color !== "unassigned") {
if (placement.color !== "unassigned") {
return;
}
@ -107,7 +109,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
if (rr == null) {
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);
}
}
@ -116,11 +118,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
if (!corner.roads) {
break;
}
const ridx = corner.roads[r] as number;
if (ridx == null || (layout as any).roads[ridx] == null) {
const ridx = corner.roads[r];
if (ridx == null || layout.roads[ridx] == null) {
continue;
}
const road = (layout as any).roads[ridx];
const road = layout.roads[ridx];
for (let c = 0; valid && c < (road.corners || []).length; c++) {
/* This side of the road is pointing to the corner being validated.
* Skip it. */
@ -130,13 +132,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
/* There is a settlement within one segment from this
* corner, so it is invalid for settlement placement */
const cc = road.corners[c] as number;
if (
(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"
) {
if (game.placements.corners[cc]!.color !== "unassigned") {
valid = false;
}
}
@ -149,10 +145,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
!(
game.state === "initial-placement" &&
isRuleEnabled(game, "volcano") &&
(layout as any).tiles &&
(layout as any).tiles[(game as any).robber] &&
Array.isArray((layout as any).tiles[(game as any).robber].corners) &&
(layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1
game.robber &&
layout.tiles &&
layout.tiles[game.robber] &&
Array.isArray(layout.tiles[game.robber]?.corners) &&
layout.tiles[game.robber]?.corners.indexOf(cornerIndex as number) !== -1
)
) {
limits.push(cornerIndex);
@ -161,10 +158,6 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
});
return limits;
}
export {
getValidCorners,
getValidRoads,
isRuleEnabled
};
export { getValidCorners, getValidRoads, isRuleEnabled };