Lots of refactoring
This commit is contained in:
parent
e68e49bf82
commit
0818145a81
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
original/
|
||||
test-output/
|
||||
certs/
|
||||
**/node_modules/
|
||||
|
@ -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) {
|
||||
|
@ -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 card—featuring 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, they’ll 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 card—featuring industrious villagers raking hay with a castle looming in the
|
||||
background—until someone even slower takes it from you with a sheepish grin!
|
||||
</Typography>
|
||||
<Box sx={{ clear: "both" }}></Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
@ -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();
|
||||
|
@ -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
304
server/routes/games/gameFactory.ts
Normal file
304
server/routes/games/gameFactory.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
};
|
||||
|
17
server/routes/games/robber.ts
Normal file
17
server/routes/games/robber.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
139
server/routes/games/rules.ts
Normal file
139
server/routes/games/rules.ts
Normal 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;
|
||||
};
|
@ -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));
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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> = {};
|
||||
|
@ -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 };
|
||||
|
@ -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: {
|
||||
|
@ -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,34 +87,18 @@ 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.");
|
||||
server.listen(serverConfig.port, () => {
|
||||
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) {
|
||||
server.on("error", function (error: any) {
|
||||
if (error.syscall !== "listen") {
|
||||
throw error;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,8 +10,8 @@ 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]) {
|
||||
res.gameId = String(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);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
import { Tile } from "../routes/games/types";
|
||||
import { ResourceType } from "../routes/games/types";
|
||||
|
||||
/* Board Tiles:
|
||||
* 0 1 2
|
||||
|
@ -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 };
|
||||
|
Loading…
x
Reference in New Issue
Block a user