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/
|
test-output/
|
||||||
certs/
|
certs/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
@ -53,7 +53,7 @@ const App = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
console.error(`App - error`, error);
|
console.error(`app - error`, error);
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
@ -61,13 +61,13 @@ const App = () => {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`App - sessionId`, session.id);
|
console.log(`app - sessionId`, session.id);
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
const getSession = useCallback(async () => {
|
const getSession = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const session = await sessionApi.getCurrent();
|
const session = await sessionApi.getCurrent();
|
||||||
console.log(`App - got sessionId`, session.id);
|
console.log(`app - got sessionId`, session.id);
|
||||||
setSession(session);
|
setSession(session);
|
||||||
setSessionRetryAttempt(0);
|
setSessionRetryAttempt(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -351,10 +351,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
|||||||
<Placard
|
<Placard
|
||||||
sx={{
|
sx={{
|
||||||
float: "left",
|
float: "left",
|
||||||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
marginRight: "0.5rem",
|
||||||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
marginBottom: "0.5rem",
|
||||||
marginRight: "1rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
}}
|
||||||
type="most-developed"
|
type="most-developed"
|
||||||
/>
|
/>
|
||||||
@ -364,6 +362,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
|||||||
fields. Picture yourself snagging this beautifully illustrated card—featuring hardworking villagers and a
|
fields. Picture yourself snagging this beautifully illustrated card—featuring hardworking villagers and a
|
||||||
majestic castle!
|
majestic castle!
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ clear: "both" }}></Box>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -378,10 +377,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
|||||||
<Placard
|
<Placard
|
||||||
sx={{
|
sx={{
|
||||||
float: "left",
|
float: "left",
|
||||||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
marginRight: "0.5rem",
|
||||||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
marginBottom: "0.5rem",
|
||||||
marginRight: "1rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
}}
|
||||||
type="port-of-call"
|
type="port-of-call"
|
||||||
/>
|
/>
|
||||||
@ -392,6 +389,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
|||||||
moment someone else builds a larger network of harbors, they’ll steal both the card and the glory right
|
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!
|
from under your nose. Keep those ships moving and never let your rivals toast to your downfall!
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ clear: "both" }}></Box>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -406,10 +404,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
|||||||
<Placard
|
<Placard
|
||||||
sx={{
|
sx={{
|
||||||
float: "left",
|
float: "left",
|
||||||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
marginRight: "0.5rem",
|
||||||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
marginBottom: "0.5rem",
|
||||||
marginRight: "1rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
}}
|
||||||
type="longest-turn"
|
type="longest-turn"
|
||||||
/>
|
/>
|
||||||
@ -418,6 +414,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
|||||||
handed this charming card—featuring industrious villagers raking hay with a castle looming in the
|
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!
|
background—until someone even slower takes it from you with a sheepish grin!
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ clear: "both" }}></Box>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -1518,22 +1518,6 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]);
|
}, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]);
|
||||||
|
|
||||||
// Debug target element
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("Target ref current:", targetRef.current, "for peer:", peer?.session_id);
|
|
||||||
if (targetRef.current) {
|
|
||||||
console.log("Target element rect:", targetRef.current.getBoundingClientRect());
|
|
||||||
console.log("Target element computed style:", {
|
|
||||||
position: getComputedStyle(targetRef.current).position,
|
|
||||||
left: getComputedStyle(targetRef.current).left,
|
|
||||||
top: getComputedStyle(targetRef.current).top,
|
|
||||||
transform: getComputedStyle(targetRef.current).transform,
|
|
||||||
width: getComputedStyle(targetRef.current).width,
|
|
||||||
height: getComputedStyle(targetRef.current).height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [peer?.session_id]);
|
|
||||||
|
|
||||||
const toggleMute = useCallback(
|
const toggleMute = useCallback(
|
||||||
(e: React.MouseEvent | React.TouchEvent) => {
|
(e: React.MouseEvent | React.TouchEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -121,7 +121,7 @@ const PlayerList: React.FC = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.log(`player-list - ignoring message: ${data.type}`);
|
// console.log(`player-list - ignoring message: ${data.type}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]);
|
}, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]);
|
||||||
|
File diff suppressed because it is too large
Load Diff
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 { newPlayer } from "./playerFactory";
|
||||||
|
import { debug, info, MAX_CITIES, MAX_SETTLEMENTS, SEND_THROTTLE_MS } from "./constants";
|
||||||
|
import { shuffleBoard } from "./gameFactory";
|
||||||
|
import { getVictoryPointRule } from "./rules";
|
||||||
|
|
||||||
export const addActivity = (game: Game, session: Session | null, message: string): void => {
|
export const addActivity = (game: Game, session: Session | null, message: string): void => {
|
||||||
let date = Date.now();
|
let date = Date.now();
|
||||||
@ -80,7 +83,7 @@ export const getFirstPlayerName = (game: Game): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
|
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
|
||||||
let color: string | undefined;
|
let color: PlayerColor | undefined;
|
||||||
for (let id in game.sessions) {
|
for (let id in game.sessions) {
|
||||||
const s = game.sessions[id];
|
const s = game.sessions[id];
|
||||||
if (s && s.name === name) {
|
if (s && s.name === name) {
|
||||||
@ -107,7 +110,7 @@ export const getNextPlayerSession = (game: Game, name: string): Session | undefi
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => {
|
export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => {
|
||||||
let color: string | undefined;
|
let color: PlayerColor | undefined;
|
||||||
for (let id in game.sessions) {
|
for (let id in game.sessions) {
|
||||||
const s = game.sessions[id];
|
const s = game.sessions[id];
|
||||||
if (s && s.name === name) {
|
if (s && s.name === name) {
|
||||||
@ -164,7 +167,7 @@ export const setForCityPlacement = (game: Game, limits: any): void => {
|
|||||||
game.turn.limits = { corners: limits };
|
game.turn.limits = { corners: limits };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setForSettlementPlacement = (game: Game, limits: number[] | undefined, _extra?: any): void => {
|
export const setForSettlementPlacement = (game: Game, limits: number[]): void => {
|
||||||
game.turn.actions = ["place-settlement"];
|
game.turn.actions = ["place-settlement"];
|
||||||
game.turn.limits = { corners: limits };
|
game.turn.limits = { corners: limits };
|
||||||
};
|
};
|
||||||
@ -187,3 +190,355 @@ export const adjustResources = (player: Player, deltas: Partial<Record<string, n
|
|||||||
});
|
});
|
||||||
player.resources = total;
|
player.resources = total;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const startTurnTimer = (game: Game, session: Session) => {
|
||||||
|
const timeout = 90;
|
||||||
|
if (!session.ws) {
|
||||||
|
console.log(`${session.short}: Aborting turn timer as ${session.name} is disconnected.`);
|
||||||
|
} else {
|
||||||
|
console.log(`${session.short}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`);
|
||||||
|
}
|
||||||
|
if (game.turnTimer) {
|
||||||
|
clearTimeout(game.turnTimer);
|
||||||
|
}
|
||||||
|
if (!session.connected) {
|
||||||
|
game.turnTimer = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
game.turnTimer = setTimeout(() => {
|
||||||
|
console.log(`${session.short}: Turn timer expired for ${session.name}`);
|
||||||
|
if (session.player) {
|
||||||
|
session.player.turnNotice = "It is still your turn.";
|
||||||
|
}
|
||||||
|
sendUpdateToPlayer(game, session, {
|
||||||
|
private: session.player,
|
||||||
|
});
|
||||||
|
resetTurnTimer(game, session);
|
||||||
|
}, timeout * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculatePoints = (game: any, update: any): void => {
|
||||||
|
if (game.state === "winner") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Calculate points and determine if there is a winner */
|
||||||
|
for (let key in game.players) {
|
||||||
|
const player = game.players[key];
|
||||||
|
if (player.status === "Not active") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currentPoints = player.points;
|
||||||
|
|
||||||
|
player.points = 0;
|
||||||
|
if (key === game.longestRoad) {
|
||||||
|
player.points += 2;
|
||||||
|
}
|
||||||
|
if (key === game.largestArmy) {
|
||||||
|
player.points += 2;
|
||||||
|
}
|
||||||
|
if (key === game.mostPorts) {
|
||||||
|
player.points += 2;
|
||||||
|
}
|
||||||
|
if (key === game.mostDeveloped) {
|
||||||
|
player.points += 2;
|
||||||
|
}
|
||||||
|
player.points += MAX_SETTLEMENTS - player.settlements;
|
||||||
|
player.points += 2 * (MAX_CITIES - player.cities);
|
||||||
|
|
||||||
|
player.unplayed = 0;
|
||||||
|
player.potential = 0;
|
||||||
|
player.development.forEach((card: any) => {
|
||||||
|
if (card.type === "vp") {
|
||||||
|
if (card.played) {
|
||||||
|
player.points++;
|
||||||
|
} else {
|
||||||
|
player.potential++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!card.played) {
|
||||||
|
player.unplayed++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (player.points === currentPoints) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.points < getVictoryPointRule(game)) {
|
||||||
|
update.players = getFilteredPlayers(game);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* This player has enough points! Check if they are the current
|
||||||
|
* player and if so, declare victory! */
|
||||||
|
console.log(`${info}: Whoa! ${player.name} has ${player.points}!`);
|
||||||
|
for (let key in game.sessions) {
|
||||||
|
if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`;
|
||||||
|
addChatMessage(game, null, message);
|
||||||
|
console.log(`${info}: ${message}`);
|
||||||
|
update.winner = Object.assign({}, player, {
|
||||||
|
state: "winner",
|
||||||
|
stolen: game.stolen,
|
||||||
|
chat: game.chat,
|
||||||
|
turns: game.turns,
|
||||||
|
players: game.players,
|
||||||
|
elapsedTime: Date.now() - game.startTime,
|
||||||
|
});
|
||||||
|
game.winner = update.winner;
|
||||||
|
game.state = "winner";
|
||||||
|
game.waiting = [];
|
||||||
|
stopTurnTimer(game);
|
||||||
|
sendUpdateToPlayers(game, {
|
||||||
|
state: game.state,
|
||||||
|
winner: game.winner,
|
||||||
|
players: game.players /* unfiltered */,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If the game isn't in a win state, do not share development card information
|
||||||
|
* with other players */
|
||||||
|
if (game.state !== "winner") {
|
||||||
|
for (let key in game.players) {
|
||||||
|
const player = game.players[key];
|
||||||
|
if (player.status === "Not active") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
delete player.potential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetTurnTimer = (game: Game, session: Session): void => {
|
||||||
|
startTurnTimer(game, session);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopTurnTimer = (game: Game): void => {
|
||||||
|
if (game.turnTimer) {
|
||||||
|
console.log(`${info}: Stopping turn timer.`);
|
||||||
|
try {
|
||||||
|
clearTimeout(game.turnTimer);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore if not a real timeout */
|
||||||
|
}
|
||||||
|
game.turnTimer = 0;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendUpdateToPlayers = async (game: any, update: any): Promise<void> => {
|
||||||
|
/* Ensure clearing of a field actually gets sent by setting
|
||||||
|
* undefined to 'false'
|
||||||
|
*/
|
||||||
|
for (let key in update) {
|
||||||
|
if (update[key] === undefined) {
|
||||||
|
update[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculatePoints(game, update);
|
||||||
|
|
||||||
|
if (debug.update) {
|
||||||
|
console.log(`[ all ]: -> sendUpdateToPlayers - `, update);
|
||||||
|
} else {
|
||||||
|
const keys = Object.getOwnPropertyNames(update);
|
||||||
|
console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: "game-update",
|
||||||
|
update,
|
||||||
|
});
|
||||||
|
for (let key in game.sessions) {
|
||||||
|
const session = game.sessions[key];
|
||||||
|
/* Only send player and game data to named players */
|
||||||
|
if (!session.name) {
|
||||||
|
console.log(`${session.short}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`);
|
||||||
|
if (session.ws) {
|
||||||
|
session.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "game-update",
|
||||||
|
update: { name: "" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!session.ws) {
|
||||||
|
console.log(`${session.short}: -> sendUpdateToPlayers: ` + `Currently no connection.`);
|
||||||
|
} else {
|
||||||
|
queueSend(session, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise<void> => {
|
||||||
|
/* If this player does not have a name, *ONLY* send the name, regardless
|
||||||
|
* of what is requested */
|
||||||
|
if (!session.name) {
|
||||||
|
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`);
|
||||||
|
update = { name: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure clearing of a field actually gets sent by setting
|
||||||
|
* undefined to 'false'
|
||||||
|
*/
|
||||||
|
for (let key in update) {
|
||||||
|
if (update[key] === undefined) {
|
||||||
|
update[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculatePoints(game, update);
|
||||||
|
|
||||||
|
if (debug.update) {
|
||||||
|
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - `, update);
|
||||||
|
} else {
|
||||||
|
const keys = Object.getOwnPropertyNames(update);
|
||||||
|
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: "game-update",
|
||||||
|
update,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session.ws) {
|
||||||
|
console.log(`${session.short}: -> sendUpdateToPlayer: ` + `Currently no connection.`);
|
||||||
|
} else {
|
||||||
|
queueSend(session, message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const queueSend = (session: any, message: any): void => {
|
||||||
|
if (!session || !session.ws) return;
|
||||||
|
try {
|
||||||
|
// Ensure we compare a stable serialization: if message is JSON text,
|
||||||
|
// parse it and re-serialize with sorted keys so semantically-equal
|
||||||
|
// objects compare equal even when property order differs.
|
||||||
|
const stableStringify = (msg: any): string => {
|
||||||
|
try {
|
||||||
|
const obj = typeof msg === "string" ? JSON.parse(msg) : msg;
|
||||||
|
const ordered = (v: any): any => {
|
||||||
|
if (v === null || typeof v !== "object") return v;
|
||||||
|
if (Array.isArray(v)) return v.map(ordered);
|
||||||
|
const keys = Object.keys(v).sort();
|
||||||
|
const out: any = {};
|
||||||
|
for (const k of keys) out[k] = ordered(v[k]);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return JSON.stringify(ordered(obj));
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, fall back to original string representation
|
||||||
|
return typeof msg === "string" ? msg : JSON.stringify(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stableMessage = stableStringify(message);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!session._lastSent) session._lastSent = 0;
|
||||||
|
const elapsed = now - session._lastSent;
|
||||||
|
// If the exact same message (in stable form) was sent last time and
|
||||||
|
// nothing is pending, skip sending to avoid pointless duplicate
|
||||||
|
// traffic.
|
||||||
|
if (!session._pendingTimeout && session._lastMessage === stableMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't sent recently and there's no pending timer, send now
|
||||||
|
if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) {
|
||||||
|
try {
|
||||||
|
session.ws.send(typeof message === "string" ? message : JSON.stringify(message));
|
||||||
|
session._lastSent = Date.now();
|
||||||
|
session._lastMessage = stableMessage;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${session.id}: queueSend immediate send failed:`, e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, store latest message and schedule a send
|
||||||
|
// If the pending message would equal the last-sent message, don't bother
|
||||||
|
// storing/scheduling it.
|
||||||
|
if (session._lastMessage === stableMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message);
|
||||||
|
if (session._pendingTimeout) {
|
||||||
|
// already scheduled; newest message will be sent when timer fires
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.max(1, SEND_THROTTLE_MS - elapsed);
|
||||||
|
session._pendingTimeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (session.ws && session._pendingMessage) {
|
||||||
|
session.ws.send(session._pendingMessage);
|
||||||
|
session._lastSent = Date.now();
|
||||||
|
// compute stable form of what we actually sent
|
||||||
|
try {
|
||||||
|
session._lastMessage = stableStringify(session._pendingMessage);
|
||||||
|
} catch (e) {
|
||||||
|
session._lastMessage = session._pendingMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${session.id}: queueSend delayed send failed:`, e);
|
||||||
|
}
|
||||||
|
// clear pending fields
|
||||||
|
session._pendingMessage = undefined;
|
||||||
|
clearTimeout(session._pendingTimeout);
|
||||||
|
session._pendingTimeout = undefined;
|
||||||
|
}, delay);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${session.id}: queueSend exception:`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shuffle = (game: Game, session: Session): string | undefined => {
|
||||||
|
if (game.state !== "lobby") {
|
||||||
|
return `Game no longer in lobby (${game.state}). Can not shuffle board.`;
|
||||||
|
}
|
||||||
|
if (game.turns > 0) {
|
||||||
|
return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`;
|
||||||
|
}
|
||||||
|
shuffleBoard(game);
|
||||||
|
console.log(`${session.short}: Shuffled to new signature: ${game.signature}`);
|
||||||
|
|
||||||
|
sendUpdateToPlayers(game, {
|
||||||
|
pipOrder: game.pipOrder,
|
||||||
|
tileOrder: game.tileOrder,
|
||||||
|
borderOrder: game.borderOrder,
|
||||||
|
robber: game.robber,
|
||||||
|
robberName: game.robberName,
|
||||||
|
signature: game.signature,
|
||||||
|
animationSeeds: game.animationSeeds,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getName = (session: Session): string => {
|
||||||
|
return session ? (session.name ? session.name : session.id) : "Admin";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFilteredPlayers = (game: Game): Record<string, Player> => {
|
||||||
|
const filtered: Record<string, Player> = {};
|
||||||
|
for (let color in game.players) {
|
||||||
|
const player = Object.assign({}, game.players[color]);
|
||||||
|
filtered[color] = player;
|
||||||
|
if (player.status === "Not active") {
|
||||||
|
if (game.state !== "lobby") {
|
||||||
|
delete filtered[color];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
player.resources = 0;
|
||||||
|
RESOURCE_TYPES.forEach((resource) => {
|
||||||
|
player.resources += player[resource];
|
||||||
|
delete player[resource];
|
||||||
|
});
|
||||||
|
player.development = [];
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
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
|
// Use a deterministic JSON serializer for snapshots; currently use JSON.stringify
|
||||||
return JSON.stringify(game);
|
return JSON.stringify(game);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function deserializeGame(serialized: string): GameState {
|
export const deserializeGame = async (serialized: string): Promise<Game> => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(serialized) as GameState;
|
return JSON.parse(serialized) as Game;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If parsing fails, return a minimal empty game state to avoid crashes
|
// If parsing fails, return a minimal empty game state to avoid crashes
|
||||||
return { players: [], placements: { corners: [], roads: [] } } as GameState;
|
return await createGame();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export function cloneGame(game: GameState): GameState {
|
export const cloneGame = async (game: Game): Promise<Game> => {
|
||||||
return deserializeGame(serializeGame(game));
|
return deserializeGame(serializeGame(game));
|
||||||
}
|
};
|
||||||
|
@ -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,184 +1,211 @@
|
|||||||
import type { GameState } from './state.js';
|
import type { Game } from "./types";
|
||||||
import { promises as fsp } from 'fs';
|
import { promises as fsp } from "fs";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
|
import { transientState } from "./sessionState";
|
||||||
|
|
||||||
export interface GameDB {
|
interface GameDB {
|
||||||
sequelize?: any;
|
db: any | null;
|
||||||
Sequelize?: any;
|
init(): Promise<void>;
|
||||||
getGameById(id: string | number): Promise<GameState | null>;
|
getGameById(id: string): Promise<Game | null>;
|
||||||
saveGameState(id: string | number, state: GameState): Promise<void>;
|
saveGame(game: Game): Promise<void>;
|
||||||
deleteGame?(id: string | number): Promise<void>;
|
deleteGame?(id: string): Promise<void>;
|
||||||
[k: string]: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const gameDB: GameDB = {
|
||||||
* Thin game DB initializer / accessor.
|
db: null,
|
||||||
* This currently returns the underlying db module (for runtime compatibility)
|
init: async () => {
|
||||||
* and is the single place to add typed helper methods for game persistence.
|
let mod: any;
|
||||||
*/
|
|
||||||
export async function initGameDB(): Promise<GameDB> {
|
|
||||||
// dynamic import to preserve original runtime ordering
|
|
||||||
// path is relative to this file (routes/games)
|
|
||||||
// Prefer synchronous require at runtime when available to avoid TS module resolution
|
|
||||||
// issues during type-checking. Declare require to keep TypeScript happy.
|
|
||||||
let mod: any;
|
|
||||||
try {
|
|
||||||
// Use runtime require to load the DB module. This runs under Node (ts-node)
|
|
||||||
// so a direct require is appropriate and avoids relying on globalThis.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
mod = require('../../db/games');
|
|
||||||
} catch (e) {
|
|
||||||
// DB-only mode: fail fast so callers know persistence is required.
|
|
||||||
throw new Error('Game DB module could not be loaded: ' + String(e));
|
|
||||||
}
|
|
||||||
// If the module uses default export, prefer it
|
|
||||||
let db: any = (mod && (mod.default || mod));
|
|
||||||
// If the required module returned a Promise (the db initializer may), await it.
|
|
||||||
if (db && typeof db.then === 'function') {
|
|
||||||
try {
|
try {
|
||||||
db = await db;
|
// Use runtime require to load the DB module. This runs under Node (ts-node)
|
||||||
|
// so a direct require is appropriate and avoids relying on globalThis.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
mod = require("../../db/games");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('Game DB initializer promise rejected: ' + String(e));
|
// DB-only mode: fail fast so callers know persistence is required.
|
||||||
|
throw new Error("Game DB module could not be loaded: " + String(e));
|
||||||
|
}
|
||||||
|
// If the module uses default export, prefer it
|
||||||
|
let db = mod.default || mod;
|
||||||
|
// If the required module returned a Promise (the db initializer may), await it.
|
||||||
|
if (db && typeof db.then === "function") {
|
||||||
|
try {
|
||||||
|
db = await db;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("Game DB initializer promise rejected: " + String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// attach typed helper placeholders (will be implemented incrementally)
|
gameDB.db = db;
|
||||||
if (!db.getGameById) {
|
return db;
|
||||||
db.getGameById = async (id: string | number): Promise<GameState | null> => {
|
},
|
||||||
// fallback: try to query by id using raw SQL if sequelize is available
|
|
||||||
if (db && db.sequelize) {
|
getGameById: async (id: string | number): Promise<Game | null> => {
|
||||||
try {
|
if (!gameDB.db) {
|
||||||
const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
|
await gameDB.init();
|
||||||
replacements: { id },
|
}
|
||||||
type: db.Sequelize.QueryTypes.SELECT
|
const db = gameDB.db;
|
||||||
});
|
// fallback: try to query by id using raw SQL if sequelize is available
|
||||||
if (rows && rows.length) {
|
if (db && db.sequelize) {
|
||||||
const r = rows[0] as any;
|
try {
|
||||||
// state may be stored as text or JSON
|
const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
|
||||||
if (typeof r.state === 'string') {
|
replacements: { id },
|
||||||
try {
|
type: db.Sequelize.QueryTypes.SELECT,
|
||||||
return JSON.parse(r.state) as GameState;
|
});
|
||||||
} catch (e) {
|
if (rows && rows.length) {
|
||||||
return null;
|
const r = rows[0] as any;
|
||||||
}
|
// state may be stored as text or JSON
|
||||||
|
if (typeof r.state === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(r.state) as Game;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return r.state as GameState;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
return r.state as Game;
|
||||||
// ignore and fallthrough
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and fallthrough
|
||||||
}
|
}
|
||||||
// If DB didn't have a state or query failed, attempt to read from the
|
}
|
||||||
// filesystem copy at db/games/<id> or <id>.json so the state remains editable.
|
// If DB didn't have a state or query failed, attempt to read from the
|
||||||
try {
|
// filesystem copy at db/games/<id> or <id>.json so the state remains editable.
|
||||||
const gamesDir = path.resolve(__dirname, '../../../db/games');
|
try {
|
||||||
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')];
|
const gamesDir = path.resolve(__dirname, "../../../db/games");
|
||||||
for (const filePath of candidates) {
|
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")];
|
||||||
try {
|
for (const filePath of candidates) {
|
||||||
const raw = await fsp.readFile(filePath, 'utf8');
|
|
||||||
return JSON.parse(raw) as GameState;
|
|
||||||
} catch (e) {
|
|
||||||
// try next candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db.saveGameState) {
|
|
||||||
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
|
|
||||||
// Always persist a JSON file so game state is inspectable/editable.
|
|
||||||
try {
|
|
||||||
const gamesDir = path.resolve(__dirname, '../../../db/games');
|
|
||||||
await fsp.mkdir(gamesDir, { recursive: true });
|
|
||||||
// Write extensionless filename to match existing files
|
|
||||||
const filePath = path.join(gamesDir, String(id));
|
|
||||||
const tmpPath = `${filePath}.tmp`;
|
|
||||||
await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
|
|
||||||
await fsp.rename(tmpPath, filePath);
|
|
||||||
} catch (err) {
|
|
||||||
// Log but continue to attempt DB persistence
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('Failed to write game JSON file for', id, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now attempt DB persistence if sequelize is present.
|
|
||||||
if (db && db.sequelize) {
|
|
||||||
const payload = JSON.stringify(state);
|
|
||||||
// Try an UPDATE; if it errors due to missing column, try to add the
|
|
||||||
// column and retry. If update affects no rows, try INSERT.
|
|
||||||
try {
|
try {
|
||||||
try {
|
const raw = await fsp.readFile(filePath, "utf8");
|
||||||
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
|
return JSON.parse(raw) as Game;
|
||||||
replacements: { id, state: payload }
|
} catch (e) {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveGame: async (game: Game): Promise<void> => {
|
||||||
|
if (!gameDB.db) {
|
||||||
|
await gameDB.init();
|
||||||
|
}
|
||||||
|
const db = gameDB.db;
|
||||||
|
/* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then
|
||||||
|
* delete the player field from them */
|
||||||
|
const reducedGame = Object.assign({}, game, { sessions: {} }),
|
||||||
|
reducedSessions = [];
|
||||||
|
|
||||||
|
for (let id in game.sessions) {
|
||||||
|
const reduced = Object.assign({}, game.sessions[id]);
|
||||||
|
|
||||||
|
// Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth)
|
||||||
|
transientState.stripSessionTransients(reduced);
|
||||||
|
|
||||||
|
reducedGame.sessions[id] = reduced;
|
||||||
|
|
||||||
|
/* Do not send session-id as those are secrets */
|
||||||
|
reducedSessions.push(reduced);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA)
|
||||||
|
transientState.stripGameTransients(reducedGame);
|
||||||
|
|
||||||
|
/* Save per turn while debugging... */
|
||||||
|
game.step = game.step ? game.step : 0;
|
||||||
|
|
||||||
|
// Always persist a JSON file so game state is inspectable/editable.
|
||||||
|
try {
|
||||||
|
const gamesDir = path.resolve(__dirname, "../../../db/games");
|
||||||
|
await fsp.mkdir(gamesDir, { recursive: true });
|
||||||
|
// Write extensionless filename to match existing files
|
||||||
|
const filePath = path.join(gamesDir, reducedGame.id);
|
||||||
|
const tmpPath = `${filePath}.tmp`;
|
||||||
|
await fsp.writeFile(tmpPath, JSON.stringify(reducedGame, null, 2), "utf8");
|
||||||
|
await fsp.rename(tmpPath, filePath);
|
||||||
|
} catch (err) {
|
||||||
|
// Log but continue to attempt DB persistence
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Failed to write game JSON file for", reducedGame.id, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now attempt DB persistence if sequelize is present.
|
||||||
|
if (db && db.sequelize) {
|
||||||
|
const payload = JSON.stringify(reducedGame);
|
||||||
|
// Try an UPDATE; if it errors due to missing column, try to add the
|
||||||
|
// column and retry. If update affects no rows, try INSERT.
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
|
||||||
|
replacements: { id: reducedGame.id, state: payload },
|
||||||
|
});
|
||||||
|
// Some dialects don't return affectedRows consistently; we'll
|
||||||
|
// still attempt insert if no row exists by checking select.
|
||||||
|
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
|
||||||
|
replacements: { id: reducedGame.id },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
if (!check || check.length === 0) {
|
||||||
|
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
|
||||||
|
replacements: { id: reducedGame.id, state: payload },
|
||||||
});
|
});
|
||||||
// Some dialects don't return affectedRows consistently; we'll
|
}
|
||||||
// still attempt insert if no row exists by checking select.
|
} catch (e: any) {
|
||||||
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
|
const msg = String(e && e.message ? e.message : e);
|
||||||
replacements: { id },
|
// If the column doesn't exist (SQLite: no such column: state), add it.
|
||||||
type: db.Sequelize.QueryTypes.SELECT
|
if (
|
||||||
});
|
/no such column: state/i.test(msg) ||
|
||||||
if (!check || check.length === 0) {
|
/has no column named state/i.test(msg) ||
|
||||||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
|
/unknown column/i.test(msg)
|
||||||
replacements: { id, state: payload }
|
) {
|
||||||
|
try {
|
||||||
|
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
|
||||||
|
// retry insert/update after adding column
|
||||||
|
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
|
||||||
|
replacements: { id: reducedGame.id, state: payload },
|
||||||
});
|
});
|
||||||
|
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
|
||||||
|
replacements: { id: reducedGame.id },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
if (!check || check.length === 0) {
|
||||||
|
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
|
||||||
|
replacements: { id: reducedGame.id, state: payload },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (inner) {
|
||||||
|
// swallow; callers should handle missing persistence
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} else {
|
||||||
const msg = String(e && e.message ? e.message : e);
|
// For other errors, attempt insert as a fallback
|
||||||
// If the column doesn't exist (SQLite: no such column: state), add it.
|
try {
|
||||||
if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) {
|
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
|
||||||
try {
|
replacements: { id: reducedGame.id, state: payload },
|
||||||
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
|
});
|
||||||
// retry insert/update after adding column
|
} catch (err) {
|
||||||
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
|
// swallow; callers should handle missing persistence
|
||||||
replacements: { id, state: payload }
|
|
||||||
});
|
|
||||||
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
|
|
||||||
replacements: { id },
|
|
||||||
type: db.Sequelize.QueryTypes.SELECT
|
|
||||||
});
|
|
||||||
if (!check || check.length === 0) {
|
|
||||||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
|
|
||||||
replacements: { id, state: payload }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (inner) {
|
|
||||||
// swallow; callers should handle missing persistence
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For other errors, attempt insert as a fallback
|
|
||||||
try {
|
|
||||||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
|
|
||||||
replacements: { id, state: payload }
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// swallow; callers should handle missing persistence
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (finalErr) {
|
|
||||||
// swallow; we don't want persistence errors to crash the server
|
|
||||||
}
|
}
|
||||||
|
} catch (finalErr) {
|
||||||
|
// swallow; we don't want persistence errors to crash the server
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
},
|
||||||
|
deleteGame: async (id: string | number): Promise<void> => {
|
||||||
if (!db.deleteGame) {
|
if (!gameDB.db) {
|
||||||
db.deleteGame = async (id: string | number): Promise<void> => {
|
await gameDB.init();
|
||||||
if (db && db.sequelize) {
|
}
|
||||||
try {
|
const db = gameDB.db;
|
||||||
await db.sequelize.query('DELETE FROM games WHERE id=:id', {
|
if (db && db.sequelize) {
|
||||||
replacements: { id }
|
try {
|
||||||
});
|
await db.sequelize.query("DELETE FROM games WHERE id=:id", {
|
||||||
} catch (e) {
|
replacements: { id },
|
||||||
// swallow; callers should handle missing persistence
|
});
|
||||||
}
|
} catch (e) {
|
||||||
|
// swallow; callers should handle missing persistence
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return db as GameDB;
|
export const games: Record<string, Game> = {};
|
||||||
}
|
|
||||||
|
@ -38,6 +38,9 @@ export interface Player {
|
|||||||
[key: string]: any; // allow incremental fields until fully typed
|
[key: string]: any; // allow incremental fields until fully typed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CornerType = "settlement" | "city" | "none";
|
||||||
|
export const CORNER_TYPES: CornerType[] = ["settlement", "city", "none"];
|
||||||
|
|
||||||
export interface CornerPlacement {
|
export interface CornerPlacement {
|
||||||
color: PlayerColor;
|
color: PlayerColor;
|
||||||
type: "settlement" | "city" | "none";
|
type: "settlement" | "city" | "none";
|
||||||
@ -46,6 +49,9 @@ export interface CornerPlacement {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RoadType = "road" | "ship";
|
||||||
|
export const ROAD_TYPES: RoadType[] = ["road", "ship"];
|
||||||
|
|
||||||
export interface RoadPlacement {
|
export interface RoadPlacement {
|
||||||
color?: PlayerColor;
|
color?: PlayerColor;
|
||||||
walking?: boolean;
|
walking?: boolean;
|
||||||
@ -60,7 +66,7 @@ export interface Placements {
|
|||||||
|
|
||||||
export interface Turn {
|
export interface Turn {
|
||||||
name?: string;
|
name?: string;
|
||||||
color?: PlayerColor;
|
color: PlayerColor;
|
||||||
actions?: string[];
|
actions?: string[];
|
||||||
limits?: any;
|
limits?: any;
|
||||||
roll?: number;
|
roll?: number;
|
||||||
@ -119,7 +125,7 @@ export interface Offer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert";
|
export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert";
|
||||||
export const RESOURCE_TYPES = ["wood", "brick", "sheep", "wheat", "stone", "desert"] as ResourceType[];
|
export const RESOURCE_TYPES: ResourceType[] = ["wood", "brick", "sheep", "wheat", "stone", "desert"];
|
||||||
|
|
||||||
export interface Tile {
|
export interface Tile {
|
||||||
robber: boolean;
|
robber: boolean;
|
||||||
@ -153,11 +159,11 @@ export interface Game {
|
|||||||
dice?: number[];
|
dice?: number[];
|
||||||
chat?: any[];
|
chat?: any[];
|
||||||
activities?: any[];
|
activities?: any[];
|
||||||
playerOrder?: string[];
|
playerOrder: PlayerColor[];
|
||||||
state?: string;
|
state?: string;
|
||||||
robber?: number;
|
robber?: number;
|
||||||
robberName?: string;
|
robberName?: string;
|
||||||
turns?: number;
|
turns: number;
|
||||||
longestRoad?: string | false;
|
longestRoad?: string | false;
|
||||||
longestRoadLength?: number;
|
longestRoadLength?: number;
|
||||||
borderOrder?: number[];
|
borderOrder?: number[];
|
||||||
@ -173,6 +179,10 @@ export interface Game {
|
|||||||
startTime?: number;
|
startTime?: number;
|
||||||
direction?: "forward" | "backward";
|
direction?: "forward" | "backward";
|
||||||
winner?: string | false;
|
winner?: string | false;
|
||||||
|
history?: any[];
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GameId = string;
|
||||||
|
|
||||||
export type IncomingMessage = { type: string | null; data: any };
|
export type IncomingMessage = { type: string | null; data: any };
|
||||||
|
@ -32,7 +32,7 @@ export const join = (
|
|||||||
const ws = session.ws;
|
const ws = session.ws;
|
||||||
|
|
||||||
if (!session.name) {
|
if (!session.name) {
|
||||||
console.error(`${session.id}: <- join - No name set yet. Audio not available.`);
|
console.error(`${session.short}: <- join - No name set yet. Audio not available.`);
|
||||||
send(ws, {
|
send(ws, {
|
||||||
type: "join_status",
|
type: "join_status",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
@ -41,13 +41,13 @@ export const join = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${session.id}: <- join - ${session.name}`);
|
console.log(`${session.short}: <- join - ${session.name}`);
|
||||||
|
|
||||||
// Determine media capability - prefer has_media if provided
|
// Determine media capability - prefer has_media if provided
|
||||||
const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio;
|
const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio;
|
||||||
|
|
||||||
if (session.name in peers) {
|
if (session.name in peers) {
|
||||||
console.log(`${session.id}:${session.name} - Already joined to Audio, updating WebSocket reference.`);
|
console.log(`${session.short}:${session.name} - Already joined to Audio, updating WebSocket reference.`);
|
||||||
try {
|
try {
|
||||||
const prev = peers[session.name] && peers[session.name].ws;
|
const prev = peers[session.name] && peers[session.name].ws;
|
||||||
if (prev && prev._pingInterval) {
|
if (prev && prev._pingInterval) {
|
||||||
@ -144,9 +144,7 @@ export const join = (
|
|||||||
|
|
||||||
export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => {
|
export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => {
|
||||||
const ws = session.ws;
|
const ws = session.ws;
|
||||||
const send = safeSend
|
const send = safeSend ? safeSend : defaultSend;
|
||||||
? safeSend
|
|
||||||
: defaultSend;
|
|
||||||
|
|
||||||
if (!session.name) {
|
if (!session.name) {
|
||||||
console.error(`${session.id}: <- part - No name set yet. Audio not available.`);
|
console.error(`${session.id}: <- part - No name set yet. Audio not available.`);
|
||||||
@ -154,12 +152,12 @@ export const part = (peers: any, session: any, safeSend?: (targetOrSession: any,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(session.name in peers)) {
|
if (!(session.name in peers)) {
|
||||||
console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`);
|
console.log(`${session.short}: <- ${session.name} - Does not exist in game audio.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${session.id}: <- ${session.name} - Audio part.`);
|
console.log(`${session.short}: <- ${session.name} - Audio part.`);
|
||||||
console.log(`-> removePeer - ${session.name}`);
|
console.log(`${session.short}: -> removePeer - ${session.name}`);
|
||||||
|
|
||||||
delete peers[session.name];
|
delete peers[session.name];
|
||||||
|
|
||||||
@ -247,7 +245,11 @@ export const handleRelaySessionDescription = (
|
|||||||
send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } });
|
send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (debug && debug.audio) console.log(`${session.id}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`, session_description);
|
if (debug && debug.audio)
|
||||||
|
console.log(
|
||||||
|
`${session.short}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`,
|
||||||
|
session_description
|
||||||
|
);
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: "sessionDescription",
|
type: "sessionDescription",
|
||||||
data: {
|
data: {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import express from 'express';
|
import express from "express";
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from "body-parser";
|
||||||
import config from 'config';
|
import config from "config";
|
||||||
import basePath from '../basepath';
|
import basePath from "../basepath";
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from "cookie-parser";
|
||||||
import http from 'http';
|
import http from "http";
|
||||||
import expressWs from 'express-ws';
|
import expressWs from "express-ws";
|
||||||
|
|
||||||
process.env.TZ = "Etc/GMT";
|
process.env.TZ = "Etc/GMT";
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ try {
|
|||||||
const debugRouter = require("../routes/debug").default || require("../routes/debug");
|
const debugRouter = require("../routes/debug").default || require("../routes/debug");
|
||||||
app.use(basePath, debugRouter);
|
app.use(basePath, debugRouter);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Failed to mount debug routes (src):', e && e.stack || e);
|
console.error("Failed to mount debug routes (src):", (e && e.stack) || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
|
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
|
||||||
@ -87,34 +87,18 @@ app.use(basePath, index);
|
|||||||
*/
|
*/
|
||||||
app.set("port", serverConfig.port);
|
app.set("port", serverConfig.port);
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on("SIGINT", () => {
|
||||||
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
|
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
|
||||||
setTimeout(() => process.exit(-1), 2000);
|
setTimeout(() => process.exit(-1), 2000);
|
||||||
server.close(() => process.exit(1));
|
server.close(() => process.exit(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
// database initializers
|
console.log("Opening server.");
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
server.listen(serverConfig.port, () => {
|
||||||
import { initGameDB } from '../routes/games/store';
|
console.log(`http/ws server listening on ${serverConfig.port}`);
|
||||||
|
|
||||||
initGameDB().then(function(_db: any) {
|
|
||||||
// games DB initialized via store facade
|
|
||||||
}).then(function() {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
return Promise.resolve((require("../db/users") as any).default || require("../db/users")).then(function(_db: any) {
|
|
||||||
// users DB initialized
|
|
||||||
});
|
|
||||||
}).then(function() {
|
|
||||||
console.log("DB connected. Opening server.");
|
|
||||||
server.listen(serverConfig.port, () => {
|
|
||||||
console.log(`http/ws server listening on ${serverConfig.port}`);
|
|
||||||
});
|
|
||||||
}).catch(function(error: any) {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(-1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("error", function(error: any) {
|
server.on("error", function (error: any) {
|
||||||
if (error.syscall !== "listen") {
|
if (error.syscall !== "listen") {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -1,67 +1,67 @@
|
|||||||
#!/usr/bin/env ts-node
|
#!/usr/bin/env ts-node
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { initGameDB } from '../routes/games/store';
|
import { gameDB } from "../routes/games/store";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const gamesDir = path.resolve(__dirname, '../../db/games');
|
const gamesDir = path.resolve(__dirname, "../../db/games");
|
||||||
let files: string[] = [];
|
let files: string[] = [];
|
||||||
try {
|
try {
|
||||||
files = await fs.readdir(gamesDir);
|
files = await fs.readdir(gamesDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to read games dir', gamesDir, e);
|
console.error("Failed to read games dir", gamesDir, e);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
let db: any;
|
if (!gameDB.db) {
|
||||||
try {
|
await gameDB.init();
|
||||||
db = await initGameDB();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to initialize DB', e);
|
|
||||||
process.exit(3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db || !db.sequelize) {
|
let db = gameDB.db;
|
||||||
console.error('DB did not expose sequelize; cannot proceed.');
|
|
||||||
|
if (!db.sequelize) {
|
||||||
|
console.error("DB did not expose sequelize; cannot proceed.");
|
||||||
process.exit(4);
|
process.exit(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
// ignore dotfiles and .bk backup files (we don't want to import backups)
|
// ignore dotfiles and .bk backup files (we don't want to import backups)
|
||||||
if (f.startsWith('.') || f.endsWith('.bk')) continue;
|
if (f.startsWith(".") || f.endsWith(".bk")) continue;
|
||||||
const full = path.join(gamesDir, f);
|
const full = path.join(gamesDir, f);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(full);
|
const stat = await fs.stat(full);
|
||||||
if (!stat.isFile()) continue;
|
if (!stat.isFile()) continue;
|
||||||
const raw = await fs.readFile(full, 'utf8');
|
const raw = await fs.readFile(full, "utf8");
|
||||||
const state = JSON.parse(raw);
|
const game = JSON.parse(raw);
|
||||||
// Derive id from filename (strip .json if present)
|
// Derive id from filename (strip .json if present)
|
||||||
const idStr = f.endsWith('.json') ? f.slice(0, -5) : f;
|
const idStr = f.endsWith(".json") ? f.slice(0, -5) : f;
|
||||||
const id = isNaN(Number(idStr)) ? idStr : Number(idStr);
|
const id = isNaN(Number(idStr)) ? idStr : Number(idStr);
|
||||||
|
|
||||||
// derive a friendly name from the saved state when present
|
// derive a friendly name from the saved game when present
|
||||||
const nameCandidate = (state && (state.name || state.id)) ? String(state.name || state.id) : undefined;
|
const nameCandidate = game && (game.name || game.id) ? String(game.name || game.id) : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (typeof id === 'number') {
|
if (typeof id === "number") {
|
||||||
// numeric filename: use the typed helper
|
// numeric filename: use the typed helper
|
||||||
await db.saveGameState(id, state);
|
await db.saveGame(game);
|
||||||
console.log(`Saved game id=${id}`);
|
console.log(`Saved game id=${id}`);
|
||||||
if (nameCandidate) {
|
if (nameCandidate) {
|
||||||
try {
|
try {
|
||||||
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id, name: nameCandidate } });
|
await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", {
|
||||||
|
replacements: { id, name: nameCandidate },
|
||||||
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore name update failure
|
// ignore name update failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// string filename: try to find an existing row by path and save via id;
|
// string filename: try to find an existing row by path and save via id;
|
||||||
// otherwise insert a new row with path and the JSON state.
|
// otherwise insert a new row with path and the JSON game.
|
||||||
let found: any[] = [];
|
let found: any[] = [];
|
||||||
try {
|
try {
|
||||||
found = await db.sequelize.query('SELECT id FROM games WHERE path=:path', {
|
found = await db.sequelize.query("SELECT id FROM games WHERE path=:path", {
|
||||||
replacements: { path: idStr },
|
replacements: { path: idStr },
|
||||||
type: db.Sequelize.QueryTypes.SELECT
|
type: db.Sequelize.QueryTypes.SELECT,
|
||||||
});
|
});
|
||||||
} catch (qe) {
|
} catch (qe) {
|
||||||
found = [];
|
found = [];
|
||||||
@ -69,11 +69,13 @@ async function main() {
|
|||||||
|
|
||||||
if (found && found.length) {
|
if (found && found.length) {
|
||||||
const foundId = found[0].id;
|
const foundId = found[0].id;
|
||||||
await db.saveGameState(foundId, state);
|
await db.saveGame(game);
|
||||||
console.log(`Saved game path=${idStr} -> id=${foundId}`);
|
console.log(`Saved game path=${idStr} -> id=${foundId}`);
|
||||||
if (nameCandidate) {
|
if (nameCandidate) {
|
||||||
try {
|
try {
|
||||||
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id: foundId, name: nameCandidate } });
|
await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", {
|
||||||
|
replacements: { id: foundId, name: nameCandidate },
|
||||||
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -81,28 +83,32 @@ async function main() {
|
|||||||
} else {
|
} else {
|
||||||
// ensure state column exists before inserting a new row
|
// ensure state column exists before inserting a new row
|
||||||
try {
|
try {
|
||||||
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
|
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
const payload = JSON.stringify(state);
|
const payload = JSON.stringify(game);
|
||||||
if (nameCandidate) {
|
if (nameCandidate) {
|
||||||
await db.sequelize.query('INSERT INTO games (path, state, name) VALUES(:path, :state, :name)', { replacements: { path: idStr, state: payload, name: nameCandidate } });
|
await db.sequelize.query("INSERT INTO games (path, state, name) VALUES(:path, :state, :name)", {
|
||||||
|
replacements: { path: idStr, state: payload, name: nameCandidate },
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await db.sequelize.query('INSERT INTO games (path, state) VALUES(:path, :state)', { replacements: { path: idStr, state: payload } });
|
await db.sequelize.query("INSERT INTO games (path, state) VALUES(:path, :state)", {
|
||||||
|
replacements: { path: idStr, state: payload },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
console.log(`Inserted game path=${idStr}`);
|
console.log(`Inserted game path=${idStr}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save game', idStr, e);
|
console.error("Failed to save game", idStr, e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to read/parse', full, e);
|
console.error("Failed to read/parse", full, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Import complete');
|
console.log("Import complete");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env ts-node
|
#!/usr/bin/env ts-node
|
||||||
import { initGameDB } from '../routes/games/store';
|
import { gameDB } from "../routes/games/store";
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
@ -10,8 +10,8 @@ function parseArgs(): Args {
|
|||||||
const res: Args = {};
|
const res: Args = {};
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const a = args[i];
|
const a = args[i];
|
||||||
if ((a === '-g' || a === '--game') && args[i+1]) {
|
if ((a === "-g" || a === "--game") && args[i + 1]) {
|
||||||
res.gameId = String(args[i+1]);
|
res.gameId = String(args[i + 1]);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -21,57 +21,56 @@ function parseArgs(): Args {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const { gameId } = parseArgs();
|
const { gameId } = parseArgs();
|
||||||
|
|
||||||
let db: any;
|
if (!gameDB.db) {
|
||||||
try {
|
await gameDB.init();
|
||||||
db = await initGameDB();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to initialize game DB:', e);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
let db = gameDB.db;
|
||||||
|
|
||||||
if (!db || !db.sequelize) {
|
if (!db.sequelize) {
|
||||||
console.error('DB does not expose sequelize; cannot run queries.');
|
console.error("DB does not expose sequelize; cannot run queries.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!gameId) {
|
if (!gameId) {
|
||||||
// List all game ids
|
// List all game ids
|
||||||
try {
|
try {
|
||||||
const rows: any[] = await db.sequelize.query('SELECT id, name FROM games', { type: db.Sequelize.QueryTypes.SELECT });
|
const rows: any[] = await db.sequelize.query("SELECT id, name FROM games", {
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT,
|
||||||
|
});
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
console.log('No games found.');
|
console.log("No games found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('Games:');
|
console.log("Games:");
|
||||||
rows.forEach(r => console.log(`${r.id} - ${r.name}`));
|
rows.forEach((r) => console.log(`${r.id} - ${r.name}`));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to list games:', e);
|
console.error("Failed to list games:", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For a given game ID, try to print the turns history from the state
|
// For a given game ID, try to print the turns history from the state
|
||||||
try {
|
try {
|
||||||
const rows: any[] = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
|
const rows: any[] = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
|
||||||
replacements: { id: gameId },
|
replacements: { id: gameId },
|
||||||
type: db.Sequelize.QueryTypes.SELECT
|
type: db.Sequelize.QueryTypes.SELECT,
|
||||||
});
|
});
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
console.error('Game not found:', gameId);
|
console.error("Game not found:", gameId);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const r = rows[0] as any;
|
const r = rows[0] as any;
|
||||||
let state = r.state;
|
let state = r.state;
|
||||||
if (typeof state === 'string') {
|
if (typeof state === "string") {
|
||||||
try {
|
try {
|
||||||
state = JSON.parse(state);
|
state = JSON.parse(state);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse stored state JSON:', e);
|
console.error("Failed to parse stored state JSON:", e);
|
||||||
process.exit(3);
|
process.exit(3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
console.error('Empty state for game', gameId);
|
console.error("Empty state for game", gameId);
|
||||||
process.exit(4);
|
process.exit(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,21 +78,21 @@ async function main() {
|
|||||||
console.log(` - turns: ${state.turns || 0}`);
|
console.log(` - turns: ${state.turns || 0}`);
|
||||||
if (state.turnHistory || state.turnsData || state.turns_list) {
|
if (state.turnHistory || state.turnsData || state.turns_list) {
|
||||||
const turns = state.turnHistory || state.turnsData || state.turns_list;
|
const turns = state.turnHistory || state.turnsData || state.turns_list;
|
||||||
console.log('Turns:');
|
console.log("Turns:");
|
||||||
turns.forEach((t: any, idx: number) => {
|
turns.forEach((t: any, idx: number) => {
|
||||||
console.log(`${idx}: ${JSON.stringify(t)}`);
|
console.log(`${idx}: ${JSON.stringify(t)}`);
|
||||||
});
|
});
|
||||||
} else if (state.turns && state.turns > 0) {
|
} else if (state.turns && state.turns > 0) {
|
||||||
console.log('No explicit turn history found inside state; showing snapshot metadata.');
|
console.log("No explicit turn history found inside state; showing snapshot metadata.");
|
||||||
// Print limited snapshot details per turn if available
|
// Print limited snapshot details per turn if available
|
||||||
if (state.turnsData) {
|
if (state.turnsData) {
|
||||||
state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`));
|
state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('No turn history recorded in state.');
|
console.log("No turn history recorded in state.");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load game state for', gameId, e);
|
console.error("Failed to load game state for", gameId, e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { Tile } from "../routes/games/types";
|
import { Tile } from "../routes/games/types";
|
||||||
|
import { ResourceType } from "../routes/games/types";
|
||||||
|
|
||||||
/* Board Tiles:
|
/* Board Tiles:
|
||||||
* 0 1 2
|
* 0 1 2
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { layout } from './layout';
|
import { CornerType, Game, PlayerColor } from "../routes/games/types";
|
||||||
|
import { layout } from "./layout";
|
||||||
|
|
||||||
const isRuleEnabled = (game: any, rule: string): boolean => {
|
const isRuleEnabled = (game: any, rule: string): boolean => {
|
||||||
return rule in game.rules && game.rules[rule].enabled;
|
return rule in game.rules && game.rules[rule].enabled;
|
||||||
@ -62,28 +63,29 @@ const getValidRoads = (game: any, color: string): number[] => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return limits;
|
return limits;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: CornerType): number[] => {
|
||||||
const limits: number[] = [];
|
const limits: number[] = [];
|
||||||
|
|
||||||
|
console.log("getValidCorners", color, type);
|
||||||
/* For each corner, if the corner already has a color set, skip it if type
|
/* For each corner, if the corner already has a color set, skip it if type
|
||||||
* isn't set. If type is set, if it is a match, and the color is a match,
|
* isn't set. If type is set and is a match, and the color is a match,
|
||||||
* add it to the list.
|
* add it to the list.
|
||||||
*
|
*
|
||||||
* If we are limiting based on active player, a corner is only valid
|
* If we are limiting based on active player, a corner is only valid
|
||||||
* if it connects to a road that is owned by that player.
|
* if it connects to a road that is owned by that player.
|
||||||
*
|
*
|
||||||
* If no color is set, walk each road that leaves that corner and
|
* If no color is set, walk each road that leaves that corner and
|
||||||
* check to see if there is a settlement placed at the end of that road
|
* check to see if there is a settlement placed at the end of that road
|
||||||
*
|
*
|
||||||
* If so, this location cannot have a settlement.
|
* If so, this location cannot have a settlement.
|
||||||
*
|
*
|
||||||
* If still valid, and we are in initial settlement placement, and if
|
* If still valid, and we are in initial settlement placement, and if
|
||||||
* Volcano is enabled, verify the tile is not the Volcano.
|
* Volcano is enabled, verify the tile is not the Volcano.
|
||||||
*/
|
*/
|
||||||
layout.corners.forEach((corner, cornerIndex) => {
|
layout.corners.forEach((corner, cornerIndex) => {
|
||||||
const placement = game.placements.corners[cornerIndex];
|
const placement = game.placements.corners[cornerIndex]!;
|
||||||
if (type) {
|
if (type) {
|
||||||
if (placement.color === color && placement.type === type) {
|
if (placement.color === color && placement.type === type) {
|
||||||
limits.push(cornerIndex);
|
limits.push(cornerIndex);
|
||||||
@ -93,7 +95,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
|
|
||||||
// If the corner has a color set and it's not the explicit sentinel
|
// If the corner has a color set and it's not the explicit sentinel
|
||||||
// "unassigned" then it's occupied and should be skipped.
|
// "unassigned" then it's occupied and should be skipped.
|
||||||
if (placement.color && placement.color !== "unassigned") {
|
if (placement.color !== "unassigned") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +109,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
if (rr == null) {
|
if (rr == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const placementsRoads = (game as any).placements && (game as any).placements.roads;
|
const placementsRoads = game.placements && game.placements.roads;
|
||||||
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
|
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -116,11 +118,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
if (!corner.roads) {
|
if (!corner.roads) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const ridx = corner.roads[r] as number;
|
const ridx = corner.roads[r];
|
||||||
if (ridx == null || (layout as any).roads[ridx] == null) {
|
if (ridx == null || layout.roads[ridx] == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const road = (layout as any).roads[ridx];
|
const road = layout.roads[ridx];
|
||||||
for (let c = 0; valid && c < (road.corners || []).length; c++) {
|
for (let c = 0; valid && c < (road.corners || []).length; c++) {
|
||||||
/* This side of the road is pointing to the corner being validated.
|
/* This side of the road is pointing to the corner being validated.
|
||||||
* Skip it. */
|
* Skip it. */
|
||||||
@ -130,13 +132,7 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
/* There is a settlement within one segment from this
|
/* There is a settlement within one segment from this
|
||||||
* corner, so it is invalid for settlement placement */
|
* corner, so it is invalid for settlement placement */
|
||||||
const cc = road.corners[c] as number;
|
const cc = road.corners[c] as number;
|
||||||
if (
|
if (game.placements.corners[cc]!.color !== "unassigned") {
|
||||||
(game as any).placements &&
|
|
||||||
(game as any).placements.corners &&
|
|
||||||
(game as any).placements.corners[cc] &&
|
|
||||||
(game as any).placements.corners[cc].color &&
|
|
||||||
(game as any).placements.corners[cc].color !== "unassigned"
|
|
||||||
) {
|
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -149,10 +145,11 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
!(
|
!(
|
||||||
game.state === "initial-placement" &&
|
game.state === "initial-placement" &&
|
||||||
isRuleEnabled(game, "volcano") &&
|
isRuleEnabled(game, "volcano") &&
|
||||||
(layout as any).tiles &&
|
game.robber &&
|
||||||
(layout as any).tiles[(game as any).robber] &&
|
layout.tiles &&
|
||||||
Array.isArray((layout as any).tiles[(game as any).robber].corners) &&
|
layout.tiles[game.robber] &&
|
||||||
(layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1
|
Array.isArray(layout.tiles[game.robber]?.corners) &&
|
||||||
|
layout.tiles[game.robber]?.corners.indexOf(cornerIndex as number) !== -1
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
limits.push(cornerIndex);
|
limits.push(cornerIndex);
|
||||||
@ -161,10 +158,6 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return limits;
|
return limits;
|
||||||
}
|
};
|
||||||
|
|
||||||
export {
|
export { getValidCorners, getValidRoads, isRuleEnabled };
|
||||||
getValidCorners,
|
|
||||||
getValidRoads,
|
|
||||||
isRuleEnabled
|
|
||||||
};
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user