1
0

Compare commits

...

7 Commits

Author SHA1 Message Date
a586f3b491 Meida working 2025-10-10 20:01:42 -07:00
579632c293 THings are improving 2025-10-10 19:51:15 -07:00
b2e5fe4e03 Fixing bugs 2025-10-10 17:07:20 -07:00
0818145a81 Lots of refactoring 2025-10-10 16:55:20 -07:00
e68e49bf82 Working on stuff 2025-10-09 16:21:47 -07:00
570d9024ab Fixing "as any" usages 2025-10-09 15:51:16 -07:00
2dae5b7b17 Lots of type edits 2025-10-09 15:40:39 -07:00
98 changed files with 2509 additions and 2053 deletions

1
.gitignore vendored
View File

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

View File

@ -50,7 +50,10 @@ const App = () => {
useEffect(() => {
if (error) {
setTimeout(() => setError(null), 5000);
setTimeout(() => {
setError(null);
}, 5000);
console.error(`app - error`, error);
}
}, [error]);
@ -58,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) {
@ -121,7 +124,19 @@ const App = () => {
</Router>
)}
{error && (
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
<Paper
className="Error"
sx={{
position: "absolute",
top: 0,
left: 0,
zIndex: 32767,
p: 2,
m: 2,
width: "fit-content",
backgroundColor: "#ffdddd",
}}
>
<Typography color="red">{error}</Typography>
</Paper>
)}

View File

@ -106,7 +106,7 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
const [signature, setSignature] = useState<string>("");
const [generated, setGenerated] = useState<string>("");
const [robber, setRobber] = useState<number>(-1);
const [robberName, setRobberName] = useState<string[]>([]);
const [robberName, setRobberName] = useState<string>("");
const [pips, setPips] = useState<any>(undefined); // Keep as any for now, complex structure
const [pipOrder, setPipOrder] = useState<any>(undefined);
const [borders, setBorders] = useState<any>(undefined);
@ -147,86 +147,123 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
return;
}
const data = lastJsonMessage;
const handleUpdate = (update: any) => {
console.log(`board - game update`, update);
if ("robber" in update && update.robber !== robber) {
setRobber(update.robber);
}
if ("robberName" in update) {
const newName = Array.isArray(update.robberName) ? String(update.robberName[0] || "") : String(update.robberName || "");
if (newName !== robberName) setRobberName(newName);
}
if ("state" in update && update.state !== state) {
setState(update.state);
}
if ("rules" in update && !equal(update.rules, rules)) {
setRules(update.rules);
}
if ("color" in update && update.color !== color) {
setColor(update.color);
}
if ("longestRoadLength" in update && update.longestRoadLength !== longestRoadLength) {
setLongestRoadLength(update.longestRoadLength);
}
if ("turn" in update) {
if (!equal(update.turn, turn)) {
console.log(`board - turn`, update.turn);
setTurn(update.turn);
}
}
if ("placements" in update && !equal(update.placements, placements)) {
console.log(`board - placements`, update.placements);
setPlacements(update.placements);
}
/* The following are only updated if there is a new game
* signature or changed ordering */
if ("pipOrder" in update && !equal(update.pipOrder, pipOrder)) {
console.log(`board - setting new pipOrder`);
setPipOrder(update.pipOrder);
}
if ("borderOrder" in update && !equal(update.borderOrder, borderOrder)) {
console.log(`board - setting new borderOrder`);
setBorderOrder(update.borderOrder);
}
if ("animationSeeds" in update && !equal(update.animationSeeds, animationSeeds)) {
console.log(`board - setting new animationSeeds`);
setAnimationSeeds(update.animationSeeds);
}
if ("tileOrder" in update && !equal(update.tileOrder, tileOrder)) {
console.log(`board - setting new tileOrder`);
setTileOrder(update.tileOrder);
}
if (update.signature !== undefined && update.signature !== signature) {
console.log(`board - setting new signature`);
setSignature(update.signature);
}
/* Static data from the server (defensive): update when present and different */
if ("pips" in update && !equal(update.pips, pips)) {
console.log(`board - setting new static pips`);
setPips(update.pips);
}
if ("tiles" in update && !equal(update.tiles, tiles)) {
console.log(`board - setting new static tiles`);
setTiles(update.tiles);
}
if ("borders" in update && !equal(update.borders, borders)) {
console.log(`board - setting new static borders`);
setBorders(update.borders);
}
};
switch (data.type) {
case "game-update":
console.log(`board - game update`, data.update);
if ("robber" in data.update && data.update.robber !== robber) {
setRobber(data.update.robber);
}
handleUpdate(data.update || {});
break;
if ("robberName" in data.update && data.update.robberName !== robberName) {
setRobberName(data.update.robberName);
}
case "initial-game":
// initial snapshot contains the consolidated game in data.snapshot
console.log(`board - initial-game snapshot received`);
if (data.snapshot) {
const snap = data.snapshot;
// Normalize snapshot fields to same keys used for incremental updates
const initialUpdate: any = {};
// Pick expected fields from snapshot
[
"robber",
"robberName",
"state",
"rules",
"color",
"longestRoadLength",
"turn",
"placements",
"pipOrder",
"borderOrder",
"animationSeeds",
"tileOrder",
"signature",
].forEach((k) => {
if (k in snap) initialUpdate[k] = snap[k];
});
// static asset metadata
if ("tiles" in snap) initialUpdate.tiles = snap.tiles;
if ("pips" in snap) initialUpdate.pips = snap.pips;
if ("borders" in snap) initialUpdate.borders = snap.borders;
if ("state" in data.update && data.update.state !== state) {
setState(data.update.state);
}
if ("rules" in data.update && !equal(data.update.rules, rules)) {
setRules(data.update.rules);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
if ("longestRoadLength" in data.update && data.update.longestRoadLength !== longestRoadLength) {
setLongestRoadLength(data.update.longestRoadLength);
}
if ("turn" in data.update) {
if (!equal(data.update.turn, turn)) {
console.log(`board - turn`, data.update.turn);
setTurn(data.update.turn);
}
}
if ("placements" in data.update && !equal(data.update.placements, placements)) {
console.log(`board - placements`, data.update.placements);
setPlacements(data.update.placements);
}
/* The following are only updated if there is a new game
* signature */
if ("pipOrder" in data.update && !equal(data.update.pipOrder, pipOrder)) {
console.log(`board - setting new pipOrder`);
setPipOrder(data.update.pipOrder);
}
if ("borderOrder" in data.update && !equal(data.update.borderOrder, borderOrder)) {
console.log(`board - setting new borderOrder`);
setBorderOrder(data.update.borderOrder);
}
if ("animationSeeds" in data.update && !equal(data.update.animationSeeds, animationSeeds)) {
console.log(`board - setting new animationSeeds`);
setAnimationSeeds(data.update.animationSeeds);
}
if ("tileOrder" in data.update && !equal(data.update.tileOrder, tileOrder)) {
console.log(`board - setting new tileOrder`);
setTileOrder(data.update.tileOrder);
}
if (data.update.signature !== signature) {
console.log(`board - setting new signature`);
setSignature(data.update.signature);
}
/* This is permanent static data from the server -- do not update
* once set */
if ("pips" in data.update && !pips) {
console.log(`board - setting new static pips`);
setPips(data.update.pips);
}
if ("tiles" in data.update && !tiles) {
console.log(`board - setting new static tiles`);
setTiles(data.update.tiles);
}
if ("borders" in data.update && !borders) {
console.log(`board - setting new static borders`);
setBorders(data.update.borders);
handleUpdate(initialUpdate);
}
break;
default:
@ -508,10 +545,6 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
console.log(`board - Generate pip, border, and tile elements`);
const Pip: React.FC<PipProps> = ({ pip, className }) => {
const onPipClicked = (pip) => {
if (!ws) {
console.error(`board - sendPlacement - ws is NULL`);
return;
}
sendJsonMessage({
type: "place-robber",
index: pip.index,
@ -928,7 +961,7 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
const el = document.querySelector(`.Pip[data-index="${robber}"]`);
if (el) {
el.classList.add("Robber");
el.classList.add(robberName);
if (robberName) el.classList.add(robberName);
}
}
});

View File

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

View File

@ -42,8 +42,8 @@
left: 0; /* Start at left of container */
width: 5rem;
height: 3.75rem;
min-width: 1.25rem;
min-height: 0.9375rem;
min-width: 3.5rem;
min-height: 1.8725rem;
z-index: 1200;
border-radius: 0.25rem;
}
@ -68,30 +68,81 @@
}
.MediaControl .Controls {
display: flex;
display: none; /* Hidden by default, shown on hover */
position: absolute;
gap: 0;
left: 0;
bottom: 0;
flex-direction: column;
z-index: 1;
align-items: flex-start;
justify-content: center
right: 0;
min-width: fit-content;
min-height: fit-content;
z-index: 1251; /* Above the Indicators but below active move handles */
background-color: rgba(64, 64, 64, 64);
backdrop-filter: blur(5px);
}
.MediaControl.Small .Controls {
.MediaControl:hover .Controls {
display: flex; /* Show controls on hover */
flex-direction: row;
}
/* Indicators: visual, non-interactive icons anchored lower-left of the container.
They are independent from the interactive Controls and scale responsively. */
.Indicators {
position: absolute;
z-index: 1250; /* Above the video but below active move handles */
/* Use percentage offsets so the indicators scale and stick to lower-left of the target */
left: 4%;
bottom: 4%;
display: flex;
flex-direction: column;
pointer-events: none; /* non-interactive */
align-items: flex-start;
}
.Indicators .IndicatorRow {
display: flex;
gap: 0.12rem;
align-items: center;
}
.Indicators .IndicatorItem {
background: rgba(0, 0, 0, 0.45);
padding: 0.12rem;
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
}
.Indicators .IndicatorItem svg {
/* make svg scale relative to parent (which is sized by the Moveable target) */
width: 1.6rem;
height: 1.6rem;
}
/* Make indicator items size proportionally to the target using relative units */
.Indicators .IndicatorItem {
padding: 0.35rem;
border-radius: 999px;
}
/* Reduce absolute pixel values that may prevent scaling; use clamp for min/max */
.Indicators .IndicatorItem svg {
width: clamp(0.6rem, 6%, 1.2rem);
height: clamp(0.6rem, 6%, 1.2rem);
}
/* Ensure interactive Controls are reachable even when target is small: allow Controls to overflow
the moveable target visually (they are positioned absolute inside the target). */
.MediaControl .Controls {
overflow: visible;
}
.MediaControl .Controls > div {
border-radius: 0.25em;
cursor: pointer;
}
.MediaControl .Controls > div:hover {
background-color: #d0d0d0;
}
.moveable-control-box {
border: none;
--moveable-color: unset !important;

View File

@ -14,6 +14,7 @@ import { useContext } from "react";
import WebRTCStatus from "./WebRTCStatus";
import Moveable from "react-moveable";
import { flushSync } from "react-dom";
import { SxProps, Theme } from "@mui/material";
const debug = true;
// When true, do not send host candidates to the signaling server. Keeps TURN relays preferred.
@ -1304,6 +1305,7 @@ interface MediaControlProps {
sendJsonMessage?: (msg: any) => void;
remoteAudioMuted?: boolean;
remoteVideoOff?: boolean;
sx?: SxProps<Theme>;
}
const MediaControl: React.FC<MediaControlProps> = ({
@ -1313,6 +1315,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
sendJsonMessage,
remoteAudioMuted,
remoteVideoOff,
sx,
}) => {
const [muted, setMuted] = useState<boolean>(peer?.muted || false);
const [videoOn, setVideoOn] = useState<boolean>(peer?.video_on !== false);
@ -1333,8 +1336,13 @@ const MediaControl: React.FC<MediaControlProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const targetRef = useRef<HTMLDivElement>(null);
const spacerRef = useRef<HTMLDivElement>(null);
const controlsRef = useRef<HTMLDivElement>(null);
const indicatorsRef = useRef<HTMLDivElement>(null);
const moveableRef = useRef<any>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
// Controls expansion state for hover/tap compact mode
const [controlsExpanded, setControlsExpanded] = useState<boolean>(false);
const touchCollapseTimeoutRef = useRef<number | null>(null);
// Get sendJsonMessage from props
// Reset drag state on pointerup/touchend/mouseup anywhere in the document
useEffect(() => {
@ -1513,22 +1521,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();
@ -1602,6 +1594,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
alignItems: "center",
minWidth: "200px",
minHeight: "100px",
...sx,
}}
>
<div
@ -1644,7 +1637,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
{/* Moveable element - positioned absolute relative to container */}
<div
ref={targetRef}
className={`MediaControl ${className}`}
className={`MediaControl ${className} ${controlsExpanded ? "Expanded" : "Small"}`}
data-peer={peer.session_id}
onDoubleClick={handleDoubleClick}
style={{
@ -1655,8 +1648,67 @@ const MediaControl: React.FC<MediaControlProps> = ({
height: frame.height ? `${frame.height}px` : undefined,
transform: `translate(${frame.translate[0]}px, ${frame.translate[1]}px)`,
}}
onMouseEnter={() => {
// Expand controls for mouse
setControlsExpanded(true);
}}
onMouseLeave={(e) => {
// Collapse when leaving with mouse, but keep expanded if the pointer
// moved into the interactive controls or indicators (which are rendered
// outside the target box to avoid disappearing when target is small).
const related = (e as React.MouseEvent).relatedTarget as Node | null;
try {
if (related && controlsRef.current && controlsRef.current.contains(related)) {
// Pointer moved into the controls; do not collapse
return;
}
if (related && indicatorsRef.current && indicatorsRef.current.contains(related)) {
// Pointer moved into the indicators; keep expanded
return;
}
} catch (err) {
// In some browsers relatedTarget may be null or inaccessible; fall back to collapsing
}
setControlsExpanded(false);
}}
onTouchStart={(e) => {
// Expand on touch; stop propagation so Moveable doesn't interpret as drag start
setControlsExpanded(true);
// Prevent immediate drag when the user intends to tap the controls
e.stopPropagation();
// Start a collapse timeout for touch devices
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
touchCollapseTimeoutRef.current = window.setTimeout(() => setControlsExpanded(false), 4000);
}}
onClick={(e) => {
// Keep controls expanded while user interacts inside
setControlsExpanded(true);
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
}}
>
<Box className="Controls">
{/* Visual indicators: placed inside a clipped container that matches the
Moveable target size so indicators scale with and are clipped by the target. */}
<Box
className="Indicators"
sx={{ display: "flex", flexDirection: "row", color: "grey", pointerEvents: "none" }}
ref={indicatorsRef}
>
{isSelf ? (
<>
{muted ? <MicOff sx={{ height: "100%" }} /> : <Mic />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
) : (
<>
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
{remoteAudioMuted && <MicOff />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
)}
</Box>
{/* Interactive controls: rendered inside target but referenced separately */}
<Box className="Controls" ref={controlsRef}>
{isSelf ? (
<IconButton onClick={toggleMute}>
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}

View File

@ -64,6 +64,20 @@ const PlayerList: React.FC = () => {
[session]
);
useEffect(() => {
if (!players) {
return;
}
players.forEach((player) => {
console.log("rabbit - player:", {
name: player.name,
live: player.live,
in_peers: peers[player.session_id],
local_or_media: player.local || player.has_media !== false,
});
});
}, [players]);
// Use the WebSocket hook for room events with automatic reconnection
useEffect(() => {
if (!lastJsonMessage) {
@ -121,7 +135,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]);
@ -149,96 +163,100 @@ const PlayerList: React.FC = () => {
>
<MediaAgent {...{ session, peers, setPeers }} />
<List className="PlayerSelector">
{players?.map((player) => (
<Box
key={player.session_id}
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
className={`PlayerEntry ${player.local ? "PlayerSelf" : ""}`}
>
<Box>
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
<Box style={{ display: "flex-wrap", alignItems: "center" }}>
<div className="Name">{player.name ? player.name : player.session_id}</div>
{player.protected && (
<div
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
title="This name is protected with a password"
>
🔒
</div>
)}
{player.bot_instance_id && (
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
🤖
</div>
)}
{players?.map((player) => {
const peerObj = peers[player.session_id] || peers[player.name];
return (
<Box
key={player.session_id}
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
className={`PlayerEntry ${player.local ? "PlayerSelf" : ""}`}
>
<Box>
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
<Box style={{ display: "flex-wrap", alignItems: "center" }}>
<div className="Name">{player.name ? player.name : player.session_id}</div>
{player.protected && (
<div
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
title="This name is protected with a password"
>
🔒
</div>
)}
{player.bot_instance_id && (
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
🤖
</div>
)}
</Box>
</Box>
{player.name && !player.live && <div className="NoNetwork"></div>}
</Box>
{player.name && !player.live && <div className="NoNetwork"></div>}
</Box>
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? (
<>
<MediaControl
className="Medium"
key={player.session_id}
peer={peers[player.session_id]}
isSelf={player.local}
sendJsonMessage={player.local ? sendJsonMessage : undefined}
remoteAudioMuted={peers[player.session_id].muted}
remoteVideoOff={peers[player.session_id].video_on === false}
/>
{player.name && player.live && peerObj && (player.local || player.has_media !== false) ? (
<>
<MediaControl
sx={{ border: "3px solid blue" }}
className="Medium"
key={player.session_id}
peer={peerObj}
isSelf={player.local}
sendJsonMessage={player.local ? sendJsonMessage : undefined}
remoteAudioMuted={peerObj?.muted}
remoteVideoOff={peerObj?.video_on === false}
/>
{/* If this is the local player and they haven't picked a color, show a picker */}
{player.local && !player.color && (
<div style={{ marginTop: 8, width: "100%" }}>
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
<div style={{ display: "flex", gap: 8 }}>
{["orange", "red", "white", "blue"].map((c) => (
<Box
key={c}
sx={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #ccc",
background: "#fff",
cursor: sendJsonMessage ? "pointer" : "not-allowed",
}}
onClick={() => {
if (!sendJsonMessage) return;
sendJsonMessage({ type: "set", field: "color", value: c[0].toUpperCase() });
}}
>
<PlayerColor color={c} />
</Box>
))}
{/* If this is the local player and they haven't picked a color, show a picker */}
{player.local && player.color === "unassigned" && (
<div style={{ marginTop: 8, width: "100%" }}>
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
<div style={{ display: "flex", gap: 8 }}>
{["orange", "red", "white", "blue"].map((c) => (
<Box
key={c}
sx={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #ccc",
background: "#fff",
cursor: sendJsonMessage ? "pointer" : "not-allowed",
}}
onClick={() => {
if (!sendJsonMessage) return;
sendJsonMessage({ type: "set", field: "color", value: c[0].toUpperCase() });
}}
>
<PlayerColor color={c} />
</Box>
))}
</div>
</div>
</div>
)}
</>
) : player.name && player.live && player.has_media === false ? (
<div
className="Video fade-in"
style={{
background: "#333",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
fontSize: "14px",
}}
>
💬 Chat Only
</div>
) : (
<video className="Video"></video>
)}
</Box>
))}
)}
</>
) : player.name && player.live && player.has_media === false ? (
<div
className="Video fade-in"
style={{
background: "#333",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
fontSize: "14px",
}}
>
💬 Chat Only
</div>
) : (
<video className="Video"></video>
)}
</Box>
);
})}
</List>
</Paper>
</Box>

View File

@ -48,14 +48,12 @@ const audioEffects: Record<string, AudioEffect | undefined> = {};
const loadAudio = (src: string) => {
const audio = document.createElement("audio") as AudioEffect;
audio.src = audioFiles[src];
console.log("Loading audio:", audio.src);
audio.setAttribute("preload", "auto");
audio.setAttribute("controls", "none");
audio.style.display = "none";
document.body.appendChild(audio);
audio.load();
audio.addEventListener("error", (e) => console.error("Audio load error:", e, audio.src));
audio.addEventListener("canplay", () => console.log("Audio can play:", audio.src));
return audio;
};
@ -149,15 +147,15 @@ const RoomView = (props: RoomProps) => {
switch (data.type) {
case "ping":
// Respond to server ping immediately to maintain connection
console.log("App - Received ping from server, sending pong");
console.log("room-view - Received ping from server, sending pong");
sendJsonMessage({ type: "pong" });
break;
case "error":
console.error(`App - error`, data.error);
setError(data.error);
console.error(`room-view - error`, data.error);
setError(data.data.error || JSON.stringify(data));
break;
case "warning":
console.warn(`App - warning`, data.warning);
console.warn(`room-view - warning`, data.warning);
setWarning(data.warning);
setTimeout(() => {
setWarning("");
@ -223,7 +221,7 @@ const RoomView = (props: RoomProps) => {
default:
break;
}
}, [lastJsonMessage, session, setError, setSession]);
}, [lastJsonMessage, session]);
useEffect(() => {
if (state === "volcano") {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

View File

@ -300,7 +300,7 @@ const bestRoadPlacement = (game) => {
return;
}
const placedRoad = game.placements.roads[roadIndex];
if (placedRoad.color) {
if (!placedRoad || placedRoad.color) {
return;
}
attempt = roadIndex;

View File

@ -16,6 +16,9 @@ const processCorner = (game: any, color: string, cornerIndex: number, placedCorn
let longest = 0;
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) {
return;
}
if (placedRoad.walking) {
return;
}
@ -47,6 +50,7 @@ const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedC
/* Calculate the longest road branching from both corners */
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
buildRoadGraph(game, color, roadIndex, placedRoad, set);
});
};
@ -54,7 +58,7 @@ const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedC
const processRoad = (game: any, color: string, roadIndex: number, placedRoad: any): number => {
/* If this road isn't assigned to the walking color, skip it */
if (placedRoad.color !== color) {
return 0;
return 0;
}
/* If this road is already being walked, skip it */
@ -65,8 +69,9 @@ const processRoad = (game: any, color: string, roadIndex: number, placedRoad: an
placedRoad.walking = true;
/* Calculate the longest road branching from both corners */
let roadLength = 1;
layout.roads[roadIndex].corners.forEach(cornerIndex => {
layout.roads[roadIndex].corners.forEach((cornerIndex) => {
const placedCorner = game.placements.corners[cornerIndex];
if (!placedCorner) return;
if (placedCorner.walking) {
return;
}
@ -89,21 +94,26 @@ const buildRoadGraph = (game: any, color: string, roadIndex: number, placedRoad:
placedRoad.walking = true;
set.push(roadIndex);
/* Calculate the longest road branching from both corners */
layout.roads[roadIndex].corners.forEach(cornerIndex => {
layout.roads[roadIndex].corners.forEach((cornerIndex) => {
const placedCorner = game.placements.corners[cornerIndex];
buildCornerGraph(game, color, cornerIndex, placedCorner, set)
if (!placedCorner) return;
buildCornerGraph(game, color, cornerIndex, placedCorner, set);
});
};
const clearRoadWalking = (game: any) => {
/* Clear out walk markers on roads */
layout.roads.forEach((item, itemIndex) => {
delete game.placements.roads[itemIndex].walking;
if (game.placements && game.placements.roads && game.placements.roads[itemIndex]) {
delete game.placements.roads[itemIndex].walking;
}
});
/* Clear out walk markers on corners */
layout.corners.forEach((item, itemIndex) => {
delete game.placements.corners[itemIndex].walking;
if (game.placements && game.placements.corners && game.placements.corners[itemIndex]) {
delete game.placements.corners[itemIndex].walking;
}
});
}
@ -122,6 +132,7 @@ const calculateRoadLengths = (game: any) => {
let graphs = [];
layout.roads.forEach((_, roadIndex) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
if (placedRoad.color === color) {
let set = [];
buildRoadGraph(game, color, roadIndex, placedRoad, set);
@ -133,13 +144,13 @@ const calculateRoadLengths = (game: any) => {
let final = {
segments: 0,
index: -1
index: -1,
};
clearRoadWalking(game);
graphs.forEach(graph => {
graphs.forEach((graph) => {
graph.longestRoad = 0;
graph.set.forEach(roadIndex => {
graph.set.forEach((roadIndex) => {
const placedRoad = game.placements.roads[roadIndex];
clearRoadWalking(game);
const length = processRoad(game, color, roadIndex, placedRoad);
@ -154,7 +165,9 @@ const calculateRoadLengths = (game: any) => {
});
});
game.placements.roads.forEach(road => delete road.walking);
game.placements.roads.forEach((road: any) => {
if (road) delete road.walking;
});
return final;
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,306 @@
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"),
},
mostPorts: null,
mostPortCount: 0,
sessions: {},
unselected: [],
placements: {
corners: [],
roads: [],
},
turn: {
name: "",
color: "unassigned",
actions: [],
limits: {},
roll: 0,
},
rules: {
"victory-points": {
points: 10,
},
},
pipOrder: [],
borderOrder: [],
tileOrder: [],
step: 0 /* used for the suffix # in game backups */,
};
setBeginnerGame(game);
resetGame(game);
addChatMessage(game, null, `New game created with Beginner's Layout: ${game.id}`);
games[game.id] = game;
audio[game.id] = {};
return game;
};
const gameSignature = (game: Game): string => {
if (!game) {
return "";
}
const salt = 251;
const signature =
(game.borderOrder || []).map((border: any) => `00${(Number(border) ^ salt).toString(16)}`.slice(-2)).join("") +
"-" +
(game.pipOrder || [])
.map((pip: any, index: number) => `00${(Number(pip) ^ salt ^ (salt * index)).toString(16)}`.slice(-2))
.join("") +
"-" +
(game.tileOrder || [])
.map((tile: any, index: number) => `00${(Number(tile) ^ salt ^ (salt * index)).toString(16)}`.slice(-2))
.join("");
return signature;
};

View File

@ -1,5 +1,8 @@
import type { Game, Session, Player } from "./types";
import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types";
import { newPlayer } from "./playerFactory";
import { debug, info, MAX_CITIES, MAX_SETTLEMENTS, SEND_THROTTLE_MS } from "./constants";
import { shuffleBoard } from "./gameFactory";
import { getVictoryPointRule } from "./rules";
export const addActivity = (game: Game, session: Session | null, message: string): void => {
let date = Date.now();
@ -7,7 +10,8 @@ export const addActivity = (game: Game, session: Session | null, message: string
if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
date++;
}
game.activities.push({ color: session ? session.color : "", message, date });
const actColor = session && session.color && session.color !== "unassigned" ? session.color : "";
game.activities.push({ color: actColor, message, date });
if (game.activities.length > 30) {
game.activities.splice(0, game.activities.length - 30);
}
@ -34,7 +38,7 @@ export const addChatMessage = (game: Game, session: Session | null, message: str
if (session && session.name) {
entry.from = session.name;
}
if (session && session.color) {
if (session && session.color && session.color !== "unassigned") {
entry.color = session.color;
}
game.chat.push(entry);
@ -47,7 +51,7 @@ export const getColorFromName = (game: Game, name: string): string => {
for (let id in game.sessions) {
const s = game.sessions[id];
if (s && s.name === name) {
return s.color || "";
return s.color && s.color !== "unassigned" ? s.color : "";
}
}
return "";
@ -79,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) {
@ -106,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) {
@ -163,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 };
};
@ -186,3 +190,363 @@ 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) => {
switch (resource) {
case "wood":
case "brick":
case "sheep":
case "wheat":
case "stone":
player.resources += player[resource];
player[resource] = 0;
break;
}
});
player.development = [];
}
return filtered;
};

View File

@ -1,7 +1,7 @@
import { MAX_ROADS, MAX_CITIES, MAX_SETTLEMENTS } from "./constants";
import type { Player } from "./types";
import type { Player, PlayerColor } from "./types";
export const newPlayer = (color: string): Player => {
export const newPlayer = (color: PlayerColor): Player => {
return {
roads: MAX_ROADS,
cities: MAX_CITIES,
@ -24,7 +24,16 @@ export const newPlayer = (color: string): Player => {
turnStart: 0,
ports: 0,
developmentCards: 0,
} as Player;
orderRoll: 0,
position: "",
orderStatus: "none",
tied: false,
mustDiscard: 0,
live: true,
turnNotice: "",
longestRoad: 0,
banks: [],
};
};
export default newPlayer;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,184 +1,211 @@
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.
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') {
export const gameDB: GameDB = {
db: null,
init: async () => {
let mod: any;
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) {
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)
if (!db.getGameById) {
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) {
try {
const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
replacements: { id },
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') {
try {
return JSON.parse(r.state) as GameState;
} catch (e) {
return 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", {
replacements: { id },
type: db.Sequelize.QueryTypes.SELECT,
});
if (rows && rows.length) {
const r = rows[0];
// 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) {
// ignore and fallthrough
return r.state as Game;
}
} 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.
try {
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;
} 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.
}
// 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")];
for (const filePath of candidates) {
try {
try {
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
replacements: { id, state: payload }
const raw = await fsp.readFile(filePath, "utf8");
return JSON.parse(raw) as Game;
} 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.
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 (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)
) {
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) {
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)) {
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, 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
}
} else {
// For other errors, attempt insert as a fallback
try {
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
}
}
} 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
}
};
}
if (!db.deleteGame) {
db.deleteGame = async (id: string | number): Promise<void> => {
if (db && db.sequelize) {
try {
await db.sequelize.query('DELETE FROM games WHERE id=:id', {
replacements: { id }
});
} catch (e) {
// swallow; callers should handle missing persistence
}
}
},
deleteGame: async (id: string | number): Promise<void> => {
if (!gameDB.db) {
await gameDB.init();
}
const db = gameDB.db;
if (db && db.sequelize) {
try {
await db.sequelize.query("DELETE FROM games WHERE id=:id", {
replacements: { id },
});
} catch (e) {
// swallow; callers should handle missing persistence
}
};
}
}
},
};
return db as GameDB;
}
export const games: Record<string, Game> = {};

View File

@ -8,59 +8,66 @@ export interface TransientGameState {
}
export interface Player {
name?: string;
color?: string;
name: string;
color: PlayerColor;
order: number;
orderRoll?: number;
position?: string;
orderStatus?: string;
tied?: boolean;
roads?: number;
settlements?: number;
cities?: number;
longestRoad?: number;
orderRoll: number;
position: string;
orderStatus: string;
tied: boolean;
roads: number;
settlements: number;
cities: number;
longestRoad: number;
mustDiscard?: number;
sheep?: number;
wheat?: number;
stone?: number;
brick?: number;
wood?: number;
points?: number;
resources?: number;
lastActive?: number;
live?: boolean;
status?: string;
developmentCards?: number;
development?: DevelopmentCard[];
turnNotice?: string;
turnStart?: number;
totalTime?: number;
[key: string]: any; // allow incremental fields until fully typed
sheep: number;
wheat: number;
stone: number;
brick: number;
wood: number;
army: number;
points: number;
ports: number;
resources: number;
lastActive: number;
live: boolean;
status: string;
developmentCards: number;
development: DevelopmentCard[];
turnNotice: string;
turnStart: number;
totalTime: number;
banks: ResourceType[];
}
export type CornerType = "settlement" | "city" | "none";
export const CORNER_TYPES: CornerType[] = ["settlement", "city", "none"];
export interface CornerPlacement {
color?: string;
type?: "settlement" | "city";
color: PlayerColor;
type: "settlement" | "city" | "none";
walking?: boolean;
longestRoad?: number;
[key: string]: any;
}
export type RoadType = "road" | "ship";
export const ROAD_TYPES: RoadType[] = ["road", "ship"];
export interface RoadPlacement {
color?: string;
color?: PlayerColor;
walking?: boolean;
[key: string]: any;
type?: "road" | "ship";
longestRoad?: number;
}
export interface Placements {
corners: CornerPlacement[];
roads: RoadPlacement[];
[key: string]: any;
corners: Array<CornerPlacement | undefined>;
roads: Array<RoadPlacement | undefined>;
}
export interface Turn {
name?: string;
color?: string;
color: PlayerColor;
actions?: string[];
limits?: any;
roll?: number;
@ -71,6 +78,7 @@ export interface Turn {
active?: string;
robberInAction?: boolean;
placedRobber?: number;
offer?: Offer;
[key: string]: any;
}
@ -81,7 +89,7 @@ export interface DevelopmentCard {
}
// Import from schema for DRY compliance
import { TransientSessionState } from './transientSchema';
import { TransientSessionState } from "./transientSchema";
/**
* Persistent Session data (saved to DB)
@ -89,7 +97,7 @@ import { TransientSessionState } from './transientSchema';
export interface PersistentSessionData {
id: string;
name: string;
color: string;
color: PlayerColor;
lastActive: number;
userId?: number;
player?: Player;
@ -97,6 +105,9 @@ export interface PersistentSessionData {
resources?: number;
}
export type PlayerColor = "R" | "B" | "O" | "W" | "robber" | "unassigned";
export const PLAYER_COLORS: PlayerColor[] = ["R", "B", "O", "W", "robber", "unassigned"];
/**
* Runtime Session type = Persistent + Transient
* At runtime, sessions have both persistent and transient fields
@ -114,6 +125,21 @@ export interface Offer {
[key: string]: any;
}
export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert" | "bank";
export const RESOURCE_TYPES: ResourceType[] = ["wood", "brick", "sheep", "wheat", "stone", "desert", "bank"];
export interface Tile {
robber: boolean;
index: number;
type: ResourceType;
resource?: ResourceKey | null;
roll?: number | null;
corners: number[];
pip: number;
roads: number[];
asset: number;
}
export interface Game {
id: string;
developmentCards: DevelopmentCard[];
@ -127,32 +153,38 @@ export interface Game {
step?: number;
placements: Placements;
turn: Turn;
pipOrder?: number[];
tileOrder?: number[];
pipOrder: number[];
tileOrder: number[];
resources?: number;
tiles?: any[];
pips?: any[];
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[];
borderOrder: number[];
largestArmy?: string | false;
largestArmySize?: number;
mostPorts?: string | false;
mostPorts: PlayerColor | null;
mostPortCount: number;
mostDeveloped?: string | false;
private?: boolean;
created?: number;
lastActivity?: number;
signature?: string;
animationSeeds?: number[];
[key: string]: any;
startTime?: number;
direction?: "forward" | "backward";
winner?: string | false;
history?: any[];
createdAt?: string;
}
export type GameId = string;
export type IncomingMessage = { type: string | null; data: any };

View File

@ -1,30 +1,14 @@
export function normalizeIncoming(msg: unknown): { type: string | null, data: unknown } {
if (!msg) return { type: null, data: null };
let parsed: unknown = null;
try {
if (typeof msg === 'string') {
parsed = JSON.parse(msg);
} else {
parsed = msg;
}
} catch (e) {
return { type: null, data: null };
}
if (!parsed) return { type: null, data: null };
const type = (parsed as any).type || (parsed as any).action || null;
const data = (parsed as any).data || (Object.keys(parsed as any).length ? Object.assign({}, parsed as any) : null);
return { type, data };
}
export function shuffleArray<T>(array: T[]): T[] {
let currentIndex = array.length, temporaryValue: T | undefined, randomIndex: number;
let currentIndex = array.length,
temporaryValue: T | undefined,
randomIndex: number;
while (0 !== currentIndex) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// use non-null assertions because we're swapping indices that exist
temporaryValue = array[currentIndex] as T;
array[currentIndex] = array[randomIndex] as T;
array[randomIndex] = temporaryValue as T;
// use non-null assertions because we're swapping indices that exist
temporaryValue = array[currentIndex] as T;
array[currentIndex] = array[randomIndex] as T;
array[randomIndex] = temporaryValue as T;
}
return array;
}

View File

@ -1,19 +1,32 @@
/* WebRTC signaling helpers extracted from games.ts
* Exports:
* - audio: map of gameId -> peers
* - join(peers, session, config, safeSend)
* - part(peers, session, safeSend)
* - handleRelayICECandidate(gameId, cfg, session, safeSend, debug)
* - handleRelaySessionDescription(gameId, cfg, session, safeSend, debug)
* - broadcastPeerStateUpdate(gameId, cfg, session, safeSend)
* - join(peers, session, config)
* - part(peers, session)
* - handleRelayICECandidate(gameId, cfg, session, debug)
* - handleRelaySessionDescription(gameId, cfg, session, debug)
* - broadcastPeerStateUpdate(gameId, cfg, session)
*/
export const audio: Record<string, any> = {};
import { Session } from "./games/types";
interface Peer {
ws: any;
name: string;
}
/* Map of session => peer_id => peer */
export const audio: Record<string, Record<string, Peer>> = {};
// Default send helper used when caller doesn't provide a safeSend implementation.
const defaultSend = (targetOrSession: any, message: any): boolean => {
try {
const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null;
const target =
targetOrSession && typeof targetOrSession.send === "function"
? targetOrSession
: targetOrSession && targetOrSession.ws
? targetOrSession.ws
: null;
if (!target) return false;
target.send(typeof message === "string" ? message : JSON.stringify(message));
return true;
@ -22,17 +35,12 @@ const defaultSend = (targetOrSession: any, message: any): boolean => {
}
};
export const join = (
peers: any,
session: any,
{ hasVideo, hasAudio, has_media }: { hasVideo?: boolean; hasAudio?: boolean; has_media?: boolean },
safeSend?: (targetOrSession: any, message: any) => boolean
): void => {
const send = safeSend ? safeSend : defaultSend;
export const join = (peers: Record<string, Peer>, session: Session): void => {
const send = defaultSend;
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,25 +49,21 @@ 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.`);
const peer = peers[session.id];
// Use session.id as the canonical peer key
if (peer) {
console.log(`${session.short}:${session.id} - Already joined to Audio, updating WebSocket reference.`);
try {
const prev = peers[session.name] && peers[session.name].ws;
const prev = peer.ws;
if (prev && prev._pingInterval) {
clearInterval(prev._pingInterval);
}
} catch (e) {
/* ignore */
}
peers[session.name].ws = ws;
peers[session.name].has_media = peerHasMedia;
peers[session.name].hasAudio = hasAudio;
peers[session.name].hasVideo = hasVideo;
peer.ws = ws;
send(ws, {
type: "join_status",
@ -67,34 +71,30 @@ export const join = (
message: "Reconnected",
});
for (const peer in peers) {
if (peer === session.name) continue;
// Tell the reconnecting client about existing peers
for (const peerId in peers) {
if (peerId === session.id) continue;
send(ws, {
type: "addPeer",
data: {
peer_id: peer,
peer_name: peer,
has_media: peers[peer].has_media,
peer_id: peerId,
peer_name: peers[peerId]!.name,
should_create_offer: true,
hasAudio: peers[peer].hasAudio,
hasVideo: peers[peer].hasVideo,
},
});
}
for (const peer in peers) {
if (peer === session.name) continue;
// Tell existing peers about the reconnecting client
for (const peerId in peers) {
if (peerId === session.id) continue;
send(peers[peer].ws, {
send(peers[peerId]!.ws, {
type: "addPeer",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
has_media: peerHasMedia,
should_create_offer: false,
hasAudio,
hasVideo,
},
});
}
@ -102,37 +102,32 @@ export const join = (
return;
}
for (let peer in peers) {
send(peers[peer].ws, {
for (let peerId in peers) {
// notify existing peers about the new client
send(peers[peerId]!.ws, {
type: "addPeer",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
has_media: peers[session.name]?.has_media ?? peerHasMedia,
should_create_offer: false,
hasAudio,
hasVideo,
},
});
// tell the new client about existing peers
send(ws, {
type: "addPeer",
data: {
peer_id: peer,
peer_name: peer,
has_media: peers[peer].has_media,
peer_id: peerId,
peer_name: peers[peerId]!.name || peerId,
should_create_offer: true,
hasAudio: peers[peer].hasAudio,
hasVideo: peers[peer].hasVideo,
},
});
}
peers[session.name] = {
// Store peer keyed by session.id and keep the display name
peers[session.id] = {
ws,
hasAudio,
hasVideo,
has_media: peerHasMedia,
name: session.name,
};
send(ws, {
@ -142,53 +137,46 @@ export const join = (
});
};
export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => {
export const part = (peers: Record<string, Peer>, session: Session): void => {
const ws = session.ws;
const send = safeSend
? safeSend
: defaultSend;
const send = defaultSend;
if (!session.name) {
console.error(`${session.id}: <- part - No name set yet. Audio not available.`);
return;
}
if (!(session.name in peers)) {
console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`);
if (!(session.id in peers)) {
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];
// Remove this peer
delete peers[session.id];
for (let peer in peers) {
send(peers[peer].ws, {
for (let peerId in peers) {
send(peers[peerId]!.ws, {
type: "removePeer",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
},
});
send(ws, {
type: "removePeer",
data: {
peer_id: peer,
peer_name: peer,
peer_id: peerId,
peer_name: peers[peerId]!.name || peerId,
},
});
}
};
export const handleRelayICECandidate = (
gameId: string,
cfg: any,
session: any,
safeSend?: (targetOrSession: any, message: any) => boolean,
debug?: any
) => {
const send = safeSend ? safeSend : defaultSend;
export const handleRelayICECandidate = (gameId: string, cfg: any, session: Session, debug?: any) => {
const send = defaultSend;
const ws = session && session.ws;
if (!cfg) {
@ -202,19 +190,20 @@ export const handleRelayICECandidate = (
return;
}
const { peer_id, candidate } = cfg;
if (debug && debug.audio) console.log(`${session.id}:${gameId} <- relayICECandidate ${session.name} to ${peer_id}`, candidate);
if (debug && debug.audio)
console.log(`${session.id}:${gameId} <- relayICECandidate ${session.name} to ${peer_id}`, candidate);
const message = JSON.stringify({
type: "iceCandidate",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
candidate,
},
});
if (peer_id in audio[gameId]) {
const target = audio[gameId][peer_id] as any;
if (peer_id in audio[gameId]!) {
const target = audio[gameId]![peer_id] as any;
if (!target || !target.ws) {
console.warn(`${session.id}:${gameId} relayICECandidate - target ${peer_id} has no ws`);
} else if (!send(target.ws, message)) {
@ -223,14 +212,8 @@ export const handleRelayICECandidate = (
}
};
export const handleRelaySessionDescription = (
gameId: string,
cfg: any,
session: any,
safeSend?: (targetOrSession: any, message: any) => boolean,
debug?: any
) => {
const send = safeSend ? safeSend : defaultSend;
export const handleRelaySessionDescription = (gameId: string, cfg: any, session: any, debug?: any) => {
const send = defaultSend;
const ws = session && session.ws;
if (!cfg) {
@ -247,17 +230,21 @@ 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: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
session_description,
},
});
if (peer_id in audio[gameId]) {
const target = audio[gameId][peer_id] as any;
if (peer_id in audio[gameId]!) {
const target = audio[gameId]![peer_id] as any;
if (!target || !target.ws) {
console.warn(`${session.id}:${gameId} relaySessionDescription - target ${peer_id} has no ws`);
} else if (!send(target.ws, message)) {
@ -266,19 +253,22 @@ export const handleRelaySessionDescription = (
}
};
export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean) => {
const send = safeSend
? safeSend
: (targetOrSession: any, message: any) => {
try {
const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null;
if (!target) return false;
target.send(typeof message === "string" ? message : JSON.stringify(message));
return true;
} catch (e) {
return false;
}
};
export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any) => {
const send = (targetOrSession: any, message: any) => {
try {
const target =
targetOrSession && typeof targetOrSession.send === "function"
? targetOrSession
: targetOrSession && targetOrSession.ws
? targetOrSession.ws
: null;
if (!target) return false;
target.send(typeof message === "string" ? message : JSON.stringify(message));
return true;
} catch (e) {
return false;
}
};
if (!(gameId in audio)) {
console.error(`${session.id}:${gameId} <- peer_state_update - Does not have Audio`);
@ -294,24 +284,24 @@ export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any,
const messagePayload = JSON.stringify({
type: "peer_state_update",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
muted,
video_on,
},
});
for (const other in audio[gameId]) {
if (other === session.name) continue;
for (const otherId in audio[gameId]) {
if (otherId === session.id) continue;
try {
const tgt = audio[gameId][other] as any;
const tgt = audio[gameId][otherId] as any;
if (!tgt || !tgt.ws) {
console.warn(`${session.id}:${gameId} peer_state_update - target ${other} has no ws`);
console.warn(`${session.id}:${gameId} peer_state_update - target ${otherId} has no ws`);
} else if (!send(tgt.ws, messagePayload)) {
console.warn(`${session.id}:${gameId} peer_state_update - send failed to ${other}`);
console.warn(`${session.id}:${gameId} peer_state_update - send failed to ${otherId}`);
}
} catch (e) {
console.warn(`Failed sending peer_state_update to ${other}:`, e);
console.warn(`Failed sending peer_state_update to ${otherId}:`, e);
}
}
};

View File

@ -1,11 +1,11 @@
import type { Request, Response, NextFunction } from 'express';
import express from 'express';
import bodyParser from 'body-parser';
import config from 'config';
import basePath from '../basepath';
import cookieParser from 'cookie-parser';
import http from 'http';
import expressWs from 'express-ws';
import type { Request, Response, NextFunction } from "express";
import express from "express";
import bodyParser from "body-parser";
import config from "config";
import basePath from "../basepath";
import cookieParser from "cookie-parser";
import http from "http";
import expressWs from "express-ws";
process.env.TZ = "Etc/GMT";
@ -29,7 +29,7 @@ try {
const debugRouter = require("../routes/debug").default || require("../routes/debug");
app.use(basePath, debugRouter);
} catch (e: any) {
console.error('Failed to mount debug routes (src):', e && e.stack || e);
console.error("Failed to mount debug routes (src):", (e && e.stack) || e);
}
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
@ -87,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(`http/ws server listening on ${serverConfig.port}`);
});
}).catch(function(error: any) {
console.error(error);
process.exit(-1);
console.log("Opening server.");
server.listen(serverConfig.port, () => {
console.log(`http/ws server listening on ${serverConfig.port}`);
});
server.on("error", function(error: any) {
server.on("error", function (error: any) {
if (error.syscall !== "listen") {
throw error;
}

View File

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

View File

@ -1,5 +1,5 @@
#!/usr/bin/env ts-node
import { initGameDB } from '../routes/games/store';
import { gameDB } from "../routes/games/store";
type Args = {
gameId?: string;
@ -10,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);
}
}

View File

@ -1,12 +1,15 @@
"use strict";
import { Tile } from "../routes/games/types";
import { ResourceType } from "../routes/games/types";
/* Board Tiles:
* 0 1 2
* 3 4 5 6
* 7 8 9 10 11
* 12 13 14 15
* 16 17 18
*/
* 0 1 2
* 3 4 5 6
* 7 8 9 10 11
* 12 13 14 15
* 16 17 18
*/
/*
* c0
@ -20,54 +23,59 @@
* r5 \ / r3
* \/
* c4
*/
*/
/* |
* 0 1 2 |
* 1| 3| 5|
* \. / \ / \ / \ 3
* \. 0/ 1\ 3/ 4\ 6/ 7\
* \./ \ / \ / \
* 0| 2| 4| |6
* 17 2| 0 5| 1 8| 2 |9 4
* 8| 10| 12| |14
* / \ / \ / \ / \
* 10/ 11\ 13/ 14\ 16/ 17\ 19/ 20\
* / \ / \ / \ / \
* 7| 9| 11| 13| |15
* 16 12| 3 15| 4 18| 5 21| 6 |22
* 17| 19| 21| 23| |25 5
* / \ / \ / \ / \ / \ ,/
* 23/ 24\ 26/ 27\ 29/ 30\ 32/ 33\ 35/ 36\ ,/
* / \ / \ / \ / \ / \ ,/
* 16| 18| 20| 22| 24| |26
* 15 25| 7 28| 8 31| 9 34| 10 37| 11 |38 6
* 27| 29| 31| 33| 35| |37
* /' \ / \ / \ / \ / \ /
* /' 39\ 40/ 41\ 43/ 44\ 46/ 47\ 49/ 50\ /53
* /' \ / \ / \ / \ / \ / 7
* 28| 30| 32| 34| |36
* 14 42| 12 45| 13 48| 14 51| 15 |52
* 38| 40| 42| 44| |46
* \ / \ / \ / \ /
* 54\ 55/ 56\ 58/ 59\ 61/ 62\ /65
* \ / \ / \ / \ / 8
* 39| 41| 43| |45
* 13 57| 16 60| 17 63| 18 |64
* 47| 49| 51| |53
* \ / \ / \ / `\
* 66\ 67/ 68\ 69/ 70\ /71 `\
* \ / \ / \ / `\
* 48| 50| 52| 9
* |
* 12 | 11 10
*/
const Tile = (corners: number[], roads: number[]) => {
* 0 1 2 |
* 1| 3| 5|
* \. / \ / \ / \ 3
* \. 0/ 1\ 3/ 4\ 6/ 7\
* \./ \ / \ / \
* 0| 2| 4| |6
* 17 2| 0 5| 1 8| 2 |9 4
* 8| 10| 12| |14
* / \ / \ / \ / \
* 10/ 11\ 13/ 14\ 16/ 17\ 19/ 20\
* / \ / \ / \ / \
* 7| 9| 11| 13| |15
* 16 12| 3 15| 4 18| 5 21| 6 |22
* 17| 19| 21| 23| |25 5
* / \ / \ / \ / \ / \ ,/
* 23/ 24\ 26/ 27\ 29/ 30\ 32/ 33\ 35/ 36\ ,/
* / \ / \ / \ / \ / \ ,/
* 16| 18| 20| 22| 24| |26
* 15 25| 7 28| 8 31| 9 34| 10 37| 11 |38 6
* 27| 29| 31| 33| 35| |37
* /' \ / \ / \ / \ / \ /
* /' 39\ 40/ 41\ 43/ 44\ 46/ 47\ 49/ 50\ /53
* /' \ / \ / \ / \ / \ / 7
* 28| 30| 32| 34| |36
* 14 42| 12 45| 13 48| 14 51| 15 |52
* 38| 40| 42| 44| |46
* \ / \ / \ / \ /
* 54\ 55/ 56\ 58/ 59\ 61/ 62\ /65
* \ / \ / \ / \ / 8
* 39| 41| 43| |45
* 13 57| 16 60| 17 63| 18 |64
* 47| 49| 51| |53
* \ / \ / \ / `\
* 66\ 67/ 68\ 69/ 70\ /71 `\
* \ / \ / \ / `\
* 48| 50| 52| 9
* |
* 12 | 11 10
*/
const newTile = (corners: number[], roads: number[]): Tile => {
return {
corners: corners, /* 6 */
robber: false,
index: -1,
type: "desert",
resource: null,
roll: null,
corners: corners /* 6 */,
pip: -1,
roads: roads,
asset: -1
asset: -1,
};
};
@ -76,44 +84,44 @@ const Tile = (corners: number[], roads: number[]) => {
const Corner = (roads: number[], banks: number[]) => {
return {
roads: roads, /* max of 3 */
banks: banks, /* max of 2 */
data: undefined
roads: roads /* max of 3 */,
banks: banks /* max of 2 */,
data: undefined,
};
};
const Road = (corners: number[]) => {
return {
corners: corners, /* 2 */
data: undefined
}
corners: corners /* 2 */,
data: undefined,
};
};
const layout = {
tiles: [
Tile([ 0, 1, 2, 10, 9, 8], [ 0, 1, 5, 13, 11, 2]),
Tile([ 2, 3, 4, 12, 11, 10], [ 3, 4, 8, 16, 14, 5]),
Tile([ 4, 5, 6, 14, 13, 12], [ 6, 7, 9, 19, 17, 8]),
newTile([0, 1, 2, 10, 9, 8], [0, 1, 5, 13, 11, 2]),
newTile([2, 3, 4, 12, 11, 10], [3, 4, 8, 16, 14, 5]),
newTile([4, 5, 6, 14, 13, 12], [6, 7, 9, 19, 17, 8]),
Tile([ 7, 8, 9, 19, 18, 17], [ 10, 11, 15, 26, 24, 12]),
Tile([ 9, 10, 11, 21, 20, 19], [ 13, 14, 18, 29, 27, 15]),
Tile([ 11, 12, 13, 23, 22, 21], [ 16, 17, 21, 32, 30, 18]),
Tile([ 13, 14, 15, 25, 24, 23], [ 19, 20, 22, 35, 33, 21]),
newTile([7, 8, 9, 19, 18, 17], [10, 11, 15, 26, 24, 12]),
newTile([9, 10, 11, 21, 20, 19], [13, 14, 18, 29, 27, 15]),
newTile([11, 12, 13, 23, 22, 21], [16, 17, 21, 32, 30, 18]),
newTile([13, 14, 15, 25, 24, 23], [19, 20, 22, 35, 33, 21]),
Tile([ 16, 17, 18, 29, 28, 27], [ 23, 24, 28, 40, 39, 25]),
Tile([ 18, 19, 20, 31, 30, 29], [ 26, 27, 31, 43, 41, 28]),
Tile([ 20, 21, 22, 33, 32, 31], [ 29, 30, 34, 46, 44, 31]),
Tile([ 22, 23, 24, 35, 34, 33], [ 32, 33, 37, 49, 47, 34]),
Tile([ 24, 25, 26, 37, 36, 35], [ 35, 36, 38, 53, 50, 37]),
newTile([16, 17, 18, 29, 28, 27], [23, 24, 28, 40, 39, 25]),
newTile([18, 19, 20, 31, 30, 29], [26, 27, 31, 43, 41, 28]),
newTile([20, 21, 22, 33, 32, 31], [29, 30, 34, 46, 44, 31]),
newTile([22, 23, 24, 35, 34, 33], [32, 33, 37, 49, 47, 34]),
newTile([24, 25, 26, 37, 36, 35], [35, 36, 38, 53, 50, 37]),
Tile([ 28, 29, 30, 40, 39, 38], [ 40, 41, 45, 55, 54, 42]),
Tile([ 30, 31, 32, 42, 41, 40], [ 43, 44, 48, 58, 56, 45]),
Tile([ 32, 33, 34, 44, 43, 42], [ 46, 47, 51, 61, 59, 48]),
Tile([ 34, 35, 36, 46, 45, 44], [ 49, 50, 52, 65, 62, 51]),
newTile([28, 29, 30, 40, 39, 38], [40, 41, 45, 55, 54, 42]),
newTile([30, 31, 32, 42, 41, 40], [43, 44, 48, 58, 56, 45]),
newTile([32, 33, 34, 44, 43, 42], [46, 47, 51, 61, 59, 48]),
newTile([34, 35, 36, 46, 45, 44], [49, 50, 52, 65, 62, 51]),
Tile([ 39, 40, 41, 49, 48, 47], [ 55, 56, 60, 67, 66, 57]),
Tile([ 41, 42, 43, 51, 50, 49], [ 58, 59, 63, 69, 68, 60]),
Tile([ 43, 44, 45, 53, 52, 51], [ 61, 62, 64, 71, 70, 63])
newTile([39, 40, 41, 49, 48, 47], [55, 56, 60, 67, 66, 57]),
newTile([41, 42, 43, 51, 50, 49], [58, 59, 63, 69, 68, 60]),
newTile([43, 44, 45, 53, 52, 51], [61, 62, 64, 71, 70, 63]),
],
roads: [
/* 0 */
@ -140,7 +148,7 @@ const layout = {
Road([14, 13]),
/* 20 */
Road([14, 15]),
Road([13,23 ]),
Road([13, 23]),
Road([15, 25]),
Road([17, 16]),
Road([17, 18]),
@ -154,113 +162,117 @@ const layout = {
Road([20, 31]),
Road([23, 22]),
Road([23, 24]),
Road([22,33]),
Road([25,24]),
Road([25,26]),
Road([22, 33]),
Road([25, 24]),
Road([25, 26]),
Road([24, 35]),
Road([26,37]),
Road([27,28]),
Road([26, 37]),
Road([27, 28]),
/* 40 */
Road([29,28]),
Road([29,30]),
Road([28,38]),
Road([31,30]),
Road([31,32]),
Road([30,40]),
Road([33,32]),
Road([33,34]),
Road([32,42]),
Road([35,34]),
Road([29, 28]),
Road([29, 30]),
Road([28, 38]),
Road([31, 30]),
Road([31, 32]),
Road([30, 40]),
Road([33, 32]),
Road([33, 34]),
Road([32, 42]),
Road([35, 34]),
/* 50 */
Road([35,36]),
Road([34,44]),
Road([36,46]),
Road([37,36]),
Road([38,39]),
Road([40,39]),
Road([40,41]),
Road([39,47]),
Road([41,42]),
Road([42,43]),
Road([35, 36]),
Road([34, 44]),
Road([36, 46]),
Road([37, 36]),
Road([38, 39]),
Road([40, 39]),
Road([40, 41]),
Road([39, 47]),
Road([41, 42]),
Road([42, 43]),
/* 60 */
Road([41,49]),
Road([44,43]),
Road([44,45]),
Road([43,51]),
Road([45,53]),
Road([46,45]),
Road([47,48]),
Road([49,48]),
Road([49,50]),
Road([51,50]),
Road([41, 49]),
Road([44, 43]),
Road([44, 45]),
Road([43, 51]),
Road([45, 53]),
Road([46, 45]),
Road([47, 48]),
Road([49, 48]),
Road([49, 50]),
Road([51, 50]),
/* 70 */
Road([51,52]),
Road([53,52]),
Road([51, 52]),
Road([53, 52]),
],
corners: [
/* 0 */
/* 0 */ Corner([2, 0],[0]),
/* 1 */ Corner([0, 1],[0]),
/* 2 */ Corner([1,3,5],[1]),
/* 3 */ Corner([3,4],[1,2]),
/* 4 */ Corner([8,4,6],[2]),
/* 5 */ Corner([6,7],[3]),
/* 6 */ Corner([7,9],[3]),
/* 7 */ Corner([12,10],[16,17]),
/* 8 */ Corner([2,10,11],[17]),
/* 9 */ Corner([11,13,15],[]),
/* 0 */ Corner([2, 0], [0]),
/* 1 */ Corner([0, 1], [0]),
/* 2 */ Corner([1, 3, 5], [1]),
/* 3 */ Corner([3, 4], [1, 2]),
/* 4 */ Corner([8, 4, 6], [2]),
/* 5 */ Corner([6, 7], [3]),
/* 6 */ Corner([7, 9], [3]),
/* 7 */ Corner([12, 10], [16, 17]),
/* 8 */ Corner([2, 10, 11], [17]),
/* 9 */ Corner([11, 13, 15], []),
/* 10 */
/* 10 */ Corner([5,13,14],[]),
/* 11 */ Corner([14,16,18],[]),
/* 12 */ Corner([8,16,17],[]),
/* 13 */ Corner([17,19,21],[]),
/* 14 */ Corner([9,19,20],[4]),
/* 15 */ Corner([20,22],[4,5]),
/* 16 */ Corner([23,25],[15]),
/* 17 */ Corner([12,23,24],[16]),
/* 18 */ Corner([24,26,28],[]),
/* 19 */ Corner([15,26,27],[]),
/* 10 */ Corner([5, 13, 14], []),
/* 11 */ Corner([14, 16, 18], []),
/* 12 */ Corner([8, 16, 17], []),
/* 13 */ Corner([17, 19, 21], []),
/* 14 */ Corner([9, 19, 20], [4]),
/* 15 */ Corner([20, 22], [4, 5]),
/* 16 */ Corner([23, 25], [15]),
/* 17 */ Corner([12, 23, 24], [16]),
/* 18 */ Corner([24, 26, 28], []),
/* 19 */ Corner([15, 26, 27], []),
/* 20 */
/* 20 */ Corner([27,29,31],[]),
/* 21 */ Corner([18,29,30],[]),
/* 22 */ Corner([30,32,34],[]),
/* 23 */ Corner([21,32,33],[]),
/* 24 */ Corner([33,35,37],[]),
/* 25 */ Corner([22,35,36],[5]),
/* 26 */ Corner([36,38],[6]),
/* 27 */ Corner([25,39],[15]),
/* 28 */ Corner([39,40,42],[14]),
/* 29 */ Corner([28,40,41],[]),
/* 20 */ Corner([27, 29, 31], []),
/* 21 */ Corner([18, 29, 30], []),
/* 22 */ Corner([30, 32, 34], []),
/* 23 */ Corner([21, 32, 33], []),
/* 24 */ Corner([33, 35, 37], []),
/* 25 */ Corner([22, 35, 36], [5]),
/* 26 */ Corner([36, 38], [6]),
/* 27 */ Corner([25, 39], [15]),
/* 28 */ Corner([39, 40, 42], [14]),
/* 29 */ Corner([28, 40, 41], []),
/* 30 */
/* 30 */ Corner([41,43,45],[]),
/* 31 */ Corner([31,43,44],[]),
/* 32 */ Corner([44,46,48],[]),
/* 33 */ Corner([34,46,47],[]),
/* 34 */ Corner([47,49,51],[]),
/* 35 */ Corner([37,49,50],[]),
/* 36 */ Corner([50,53,52],[7]),
/* 37 */ Corner([38,53],[6]),
/* 38 */ Corner([42,54],[14,13]),
/* 39 */ Corner([54,55,57],[13]),
/* 30 */ Corner([41, 43, 45], []),
/* 31 */ Corner([31, 43, 44], []),
/* 32 */ Corner([44, 46, 48], []),
/* 33 */ Corner([34, 46, 47], []),
/* 34 */ Corner([47, 49, 51], []),
/* 35 */ Corner([37, 49, 50], []),
/* 36 */ Corner([50, 53, 52], [7]),
/* 37 */ Corner([38, 53], [6]),
/* 38 */ Corner([42, 54], [14, 13]),
/* 39 */ Corner([54, 55, 57], [13]),
/* 40 */
/* 40 */ Corner([45,55,56],[]),
/* 41 */ Corner([56,58,60],[]),
/* 42 */ Corner([48,58,59],[]),
/* 43 */ Corner([59,61,63],[]),
/* 44 */ Corner([51,61,62],[]),
/* 45 */ Corner([62,65,64],[8]),
/* 46 */ Corner([52,65],[7,8]),
/* 47 */ Corner([57,66],[12]),
/* 48 */ Corner([67,66],[12]),
/* 49 */ Corner([60,67,68],[11]),
/* 40 */ Corner([45, 55, 56], []),
/* 41 */ Corner([56, 58, 60], []),
/* 42 */ Corner([48, 58, 59], []),
/* 43 */ Corner([59, 61, 63], []),
/* 44 */ Corner([51, 61, 62], []),
/* 45 */ Corner([62, 65, 64], [8]),
/* 46 */ Corner([52, 65], [7, 8]),
/* 47 */ Corner([57, 66], [12]),
/* 48 */ Corner([67, 66], [12]),
/* 49 */ Corner([60, 67, 68], [11]),
/* 50 */
/* 50 */ Corner([68,69],[11,10]),
/* 51 */ Corner([69,70,63],[10]),
/* 52 */ Corner([70,71],[9]),
/* 53 */ Corner([64,71],[9]),
]
/* 50 */ Corner([68, 69], [11, 10]),
/* 51 */ Corner([69, 70, 63], [10]),
/* 52 */ Corner([70, 71], [9]),
/* 53 */ Corner([64, 71], [9]),
],
};
interface StaticDataTile {
type: ResourceType;
card: number;
}
const staticData = {
tiles: [
{ type: "desert", card: 0 },
@ -281,8 +293,8 @@ const staticData = {
{ type: "sheep", card: 3 },
{ type: "brick", card: 0 },
{ type: "brick", card: 1 },
{ type: "brick", card: 2 }
],
{ type: "brick", card: 2 },
] as StaticDataTile[],
pips: [
{ roll: 5, pips: 4 },
{ roll: 2, pips: 1 },
@ -302,16 +314,16 @@ const staticData = {
{ roll: 6, pips: 5 },
{ roll: 3, pips: 2 },
{ roll: 11, pips: 2 },
{ roll: 7, pips: 0 }, /* Robber is at the end or indexing gets off */
{ roll: 7, pips: 0 } /* Robber is at the end or indexing gets off */,
],
borders: [
["bank", undefined, "sheep"],
[undefined, "bank", undefined],
["bank", undefined, "brick"],
[undefined, "wood", undefined],
["bank", undefined, "wheat"],
[undefined, "stone", undefined]
]
["bank", "none", "sheep"],
["none", "bank", "none"],
["bank", "none", "brick"],
["none", "wood", "none"],
["bank", "none", "wheat"],
["none", "stone", "none"],
] as ResourceType[][],
};
export {

View File

@ -1,4 +1,5 @@
import { layout } from './layout';
import { CornerType, Game, PlayerColor } from "../routes/games/types";
import { layout } from "./layout";
const isRuleEnabled = (game: any, rule: string): boolean => {
return rule in game.rules && game.rules[rule].enabled;
@ -12,19 +13,33 @@ const getValidRoads = (game: any, color: string): number[] => {
* has a matching color, add this to the set. Otherwise skip.
*/
layout.roads.forEach((road, roadIndex) => {
if (!game.placements || !game.placements.roads || game.placements.roads[roadIndex]?.color) {
// Skip if placements or roads missing, or if this road is already occupied
// Treat the explicit sentinel "unassigned" as "not available" so only
// consider a road occupied when a color is present and not "unassigned".
if (
!game.placements ||
!game.placements.roads ||
(game.placements.roads[roadIndex] &&
game.placements.roads[roadIndex].color &&
game.placements.roads[roadIndex].color !== "unassigned")
) {
return;
}
let valid = false;
for (let c = 0; !valid && c < road.corners.length; c++) {
for (let c = 0; !valid && c < road.corners.length; c++) {
const cornerIndex = road.corners[c] as number;
if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) {
continue;
}
const corner = (layout as any).corners[cornerIndex];
const cornerColor = (game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cornerIndex] && (game as any).placements.corners[cornerIndex].color;
/* Roads do not pass through other player's settlements */
if (cornerColor && cornerColor !== color) {
const cornerColor =
(game as any).placements &&
(game as any).placements.corners &&
(game as any).placements.corners[cornerIndex] &&
(game as any).placements.corners[cornerIndex].color;
/* Roads do not pass through other player's settlements.
* Consider a corner with color === "unassigned" as empty. */
if (cornerColor && cornerColor !== "unassigned" && cornerColor !== color) {
continue;
}
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
@ -33,7 +48,9 @@ const getValidRoads = (game: any, color: string): number[] => {
continue;
}
const rr = corner.roads[r];
if (rr == null) { continue; }
if (rr == null) {
continue;
}
const placementsRoads = (game as any).placements && (game as any).placements.roads;
if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) {
valid = true;
@ -46,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
@ -67,68 +85,89 @@ 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 && game.placements.corners ? game.placements.corners[cornerIndex] : undefined;
if (!placement) {
// Treat a missing placement as unassigned (no owner)
// Continue processing using a falsy placement where appropriate
}
if (type) {
if (placement.color === color && placement.type === type) {
if (placement && placement.color === color && placement.type === type) {
limits.push(cornerIndex);
}
return;
}
if (placement.color) {
// If the corner has a color set and it's not the explicit sentinel
// "unassigned" then it's occupied and should be skipped.
// Note: placement.color may be undefined (initial state), treat that
// the same as unassigned.
if (placement && placement.color && placement.color !== "unassigned") {
return;
}
let valid;
if (!color) {
// Treat either a falsy color (""/undefined) or the explicit sentinel
// "unassigned" as meaning "do not filter by player".
if (!color || color === "unassigned") {
valid = true; /* Not filtering based on current player */
} else {
valid = false;
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
const rr = corner.roads[r];
if (rr == null) { continue; }
const placementsRoads = (game as any).placements && (game as any).placements.roads;
if (rr == null) {
continue;
}
const placementsRoads = game.placements && game.placements.roads;
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
}
}
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
if (!corner.roads) { break; }
const ridx = corner.roads[r] as number;
if (ridx == null || (layout as any).roads[ridx] == null) { continue; }
const road = (layout as any).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. */
if (road.corners[c] === cornerIndex) {
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
if (!corner.roads) {
break;
}
const ridx = corner.roads[r];
if (ridx == null || layout.roads[ridx] == null) {
continue;
}
/* 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) {
valid = false;
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. */
if (road.corners[c] === cornerIndex) {
continue;
}
/* There is a settlement within one segment from this
* corner, so it is invalid for settlement placement */
const cc = road.corners[c] as number;
const ccPlacement = game.placements && game.placements.corners ? game.placements.corners[cc] : undefined;
const ccColor = ccPlacement ? ccPlacement.color : undefined;
if (ccColor && ccColor !== "unassigned") {
valid = false;
}
}
}
}
if (valid) {
/* During initial placement, if volcano is enabled, do not allow
* placement on a corner connected to the volcano (robber starts
* on the volcano) */
if (!(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
)) {
if (
!(
game.state === "initial-placement" &&
isRuleEnabled(game, "volcano") &&
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);
}
}
});
return limits;
}
export {
getValidCorners,
getValidRoads,
isRuleEnabled
};
export { getValidCorners, getValidRoads, isRuleEnabled };