1
0

Compare commits

..

No commits in common. "a586f3b491d1886d8d2a0bb8c54bb1a0ac74fba1" and "d12d87a7960ba5db08e65785aaf22cf46b52f447" have entirely different histories.

98 changed files with 2058 additions and 2514 deletions

1
.gitignore vendored
View File

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

View File

@ -50,10 +50,7 @@ const App = () => {
useEffect(() => { useEffect(() => {
if (error) { if (error) {
setTimeout(() => { setTimeout(() => setError(null), 5000);
setError(null);
}, 5000);
console.error(`app - error`, error);
} }
}, [error]); }, [error]);
@ -61,13 +58,13 @@ const App = () => {
if (!session) { if (!session) {
return; return;
} }
console.log(`app - sessionId`, session.id); console.log(`App - sessionId`, session.id);
}, [session]); }, [session]);
const getSession = useCallback(async () => { const getSession = useCallback(async () => {
try { try {
const session = await sessionApi.getCurrent(); const session = await sessionApi.getCurrent();
console.log(`app - got sessionId`, session.id); console.log(`App - got sessionId`, session.id);
setSession(session); setSession(session);
setSessionRetryAttempt(0); setSessionRetryAttempt(0);
} catch (err) { } catch (err) {
@ -124,19 +121,7 @@ const App = () => {
</Router> </Router>
)} )}
{error && ( {error && (
<Paper <Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
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> <Typography color="red">{error}</Typography>
</Paper> </Paper>
)} )}

View File

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

View File

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

View File

@ -42,8 +42,8 @@
left: 0; /* Start at left of container */ left: 0; /* Start at left of container */
width: 5rem; width: 5rem;
height: 3.75rem; height: 3.75rem;
min-width: 3.5rem; min-width: 1.25rem;
min-height: 1.8725rem; min-height: 0.9375rem;
z-index: 1200; z-index: 1200;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
@ -68,81 +68,30 @@
} }
.MediaControl .Controls { .MediaControl .Controls {
display: none; /* Hidden by default, shown on hover */ display: flex;
position: absolute; position: absolute;
gap: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
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: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; flex-direction: column;
pointer-events: none; /* non-interactive */ z-index: 1;
align-items: flex-start; align-items: flex-start;
justify-content: center
} }
.Indicators .IndicatorRow { .MediaControl.Small .Controls {
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; 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 { .MediaControl .Controls > div {
border-radius: 0.25em; border-radius: 0.25em;
cursor: pointer; cursor: pointer;
} }
.MediaControl .Controls > div:hover {
background-color: #d0d0d0;
}
.moveable-control-box { .moveable-control-box {
border: none; border: none;
--moveable-color: unset !important; --moveable-color: unset !important;

View File

@ -14,7 +14,6 @@ import { useContext } from "react";
import WebRTCStatus from "./WebRTCStatus"; import WebRTCStatus from "./WebRTCStatus";
import Moveable from "react-moveable"; import Moveable from "react-moveable";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { SxProps, Theme } from "@mui/material";
const debug = true; const debug = true;
// When true, do not send host candidates to the signaling server. Keeps TURN relays preferred. // When true, do not send host candidates to the signaling server. Keeps TURN relays preferred.
@ -1305,7 +1304,6 @@ interface MediaControlProps {
sendJsonMessage?: (msg: any) => void; sendJsonMessage?: (msg: any) => void;
remoteAudioMuted?: boolean; remoteAudioMuted?: boolean;
remoteVideoOff?: boolean; remoteVideoOff?: boolean;
sx?: SxProps<Theme>;
} }
const MediaControl: React.FC<MediaControlProps> = ({ const MediaControl: React.FC<MediaControlProps> = ({
@ -1315,7 +1313,6 @@ const MediaControl: React.FC<MediaControlProps> = ({
sendJsonMessage, sendJsonMessage,
remoteAudioMuted, remoteAudioMuted,
remoteVideoOff, remoteVideoOff,
sx,
}) => { }) => {
const [muted, setMuted] = useState<boolean>(peer?.muted || false); const [muted, setMuted] = useState<boolean>(peer?.muted || false);
const [videoOn, setVideoOn] = useState<boolean>(peer?.video_on !== false); const [videoOn, setVideoOn] = useState<boolean>(peer?.video_on !== false);
@ -1336,13 +1333,8 @@ const MediaControl: React.FC<MediaControlProps> = ({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const targetRef = useRef<HTMLDivElement>(null); const targetRef = useRef<HTMLDivElement>(null);
const spacerRef = useRef<HTMLDivElement>(null); const spacerRef = useRef<HTMLDivElement>(null);
const controlsRef = useRef<HTMLDivElement>(null);
const indicatorsRef = useRef<HTMLDivElement>(null);
const moveableRef = useRef<any>(null); const moveableRef = useRef<any>(null);
const [isDragging, setIsDragging] = useState<boolean>(false); 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 // Get sendJsonMessage from props
// Reset drag state on pointerup/touchend/mouseup anywhere in the document // Reset drag state on pointerup/touchend/mouseup anywhere in the document
useEffect(() => { useEffect(() => {
@ -1521,6 +1513,22 @@ const MediaControl: React.FC<MediaControlProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]); }, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]);
// Debug target element
useEffect(() => {
console.log("Target ref current:", targetRef.current, "for peer:", peer?.session_id);
if (targetRef.current) {
console.log("Target element rect:", targetRef.current.getBoundingClientRect());
console.log("Target element computed style:", {
position: getComputedStyle(targetRef.current).position,
left: getComputedStyle(targetRef.current).left,
top: getComputedStyle(targetRef.current).top,
transform: getComputedStyle(targetRef.current).transform,
width: getComputedStyle(targetRef.current).width,
height: getComputedStyle(targetRef.current).height,
});
}
}, [peer?.session_id]);
const toggleMute = useCallback( const toggleMute = useCallback(
(e: React.MouseEvent | React.TouchEvent) => { (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -1594,7 +1602,6 @@ const MediaControl: React.FC<MediaControlProps> = ({
alignItems: "center", alignItems: "center",
minWidth: "200px", minWidth: "200px",
minHeight: "100px", minHeight: "100px",
...sx,
}} }}
> >
<div <div
@ -1637,7 +1644,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
{/* Moveable element - positioned absolute relative to container */} {/* Moveable element - positioned absolute relative to container */}
<div <div
ref={targetRef} ref={targetRef}
className={`MediaControl ${className} ${controlsExpanded ? "Expanded" : "Small"}`} className={`MediaControl ${className}`}
data-peer={peer.session_id} data-peer={peer.session_id}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
style={{ style={{
@ -1648,67 +1655,8 @@ const MediaControl: React.FC<MediaControlProps> = ({
height: frame.height ? `${frame.height}px` : undefined, height: frame.height ? `${frame.height}px` : undefined,
transform: `translate(${frame.translate[0]}px, ${frame.translate[1]}px)`, 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);
}}
> >
{/* Visual indicators: placed inside a clipped container that matches the <Box className="Controls">
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 ? ( {isSelf ? (
<IconButton onClick={toggleMute}> <IconButton onClick={toggleMute}>
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />} {muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}

View File

@ -64,20 +64,6 @@ const PlayerList: React.FC = () => {
[session] [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 // Use the WebSocket hook for room events with automatic reconnection
useEffect(() => { useEffect(() => {
if (!lastJsonMessage) { if (!lastJsonMessage) {
@ -135,7 +121,7 @@ const PlayerList: React.FC = () => {
break; break;
} }
default: default:
// console.log(`player-list - ignoring message: ${data.type}`); console.log(`player-list - ignoring message: ${data.type}`);
break; break;
} }
}, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]); }, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]);
@ -163,9 +149,7 @@ const PlayerList: React.FC = () => {
> >
<MediaAgent {...{ session, peers, setPeers }} /> <MediaAgent {...{ session, peers, setPeers }} />
<List className="PlayerSelector"> <List className="PlayerSelector">
{players?.map((player) => { {players?.map((player) => (
const peerObj = peers[player.session_id] || peers[player.name];
return (
<Box <Box
key={player.session_id} key={player.session_id}
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }} sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
@ -192,21 +176,20 @@ const PlayerList: React.FC = () => {
</Box> </Box>
{player.name && !player.live && <div className="NoNetwork"></div>} {player.name && !player.live && <div className="NoNetwork"></div>}
</Box> </Box>
{player.name && player.live && peerObj && (player.local || player.has_media !== false) ? ( {player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? (
<> <>
<MediaControl <MediaControl
sx={{ border: "3px solid blue" }}
className="Medium" className="Medium"
key={player.session_id} key={player.session_id}
peer={peerObj} peer={peers[player.session_id]}
isSelf={player.local} isSelf={player.local}
sendJsonMessage={player.local ? sendJsonMessage : undefined} sendJsonMessage={player.local ? sendJsonMessage : undefined}
remoteAudioMuted={peerObj?.muted} remoteAudioMuted={peers[player.session_id].muted}
remoteVideoOff={peerObj?.video_on === false} remoteVideoOff={peers[player.session_id].video_on === false}
/> />
{/* If this is the local player and they haven't picked a color, show a picker */} {/* If this is the local player and they haven't picked a color, show a picker */}
{player.local && player.color === "unassigned" && ( {player.local && !player.color && (
<div style={{ marginTop: 8, width: "100%" }}> <div style={{ marginTop: 8, width: "100%" }}>
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div> <div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
@ -255,8 +238,7 @@ const PlayerList: React.FC = () => {
<video className="Video"></video> <video className="Video"></video>
)} )}
</Box> </Box>
); ))}
})}
</List> </List>
</Paper> </Paper>
</Box> </Box>

View File

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

BIN
original/birds.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
original/birds.xcf Executable file

Binary file not shown.

BIN
original/borders-1.6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

BIN
original/borders-1.6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
original/borders-2.1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

BIN
original/borders-2.1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

BIN
original/borders-3.2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

BIN
original/borders-3.2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

BIN
original/borders-4.3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

BIN
original/borders-4.3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

BIN
original/borders-5.4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

BIN
original/borders-5.4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 KiB

BIN
original/borders-6.5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

BIN
original/borders-6.5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

BIN
original/card-army-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

BIN
original/card-army-10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

BIN
original/card-army-11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

BIN
original/card-army-12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

BIN
original/card-army-13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

BIN
original/card-army-14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

BIN
original/card-army-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

BIN
original/card-army-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

BIN
original/card-army-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

BIN
original/card-army-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

BIN
original/card-army-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

BIN
original/card-army-7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

BIN
original/card-army-8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

BIN
original/card-army-9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

BIN
original/card-brick.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

BIN
original/card-monopoly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

BIN
original/card-road-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

BIN
original/card-road-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

BIN
original/card-sheep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

BIN
original/card-stone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

BIN
original/card-vp-market.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

BIN
original/card-vp-palace.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

BIN
original/card-wheat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

BIN
original/card-wood.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

BIN
original/extra-cards.xcf Executable file

Binary file not shown.

BIN
original/pieces-blue.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
original/pieces-orange.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
original/pieces-red.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
original/pieces-white.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
original/pieces.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
original/pip-numbers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
original/pip-ships.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

BIN
original/placard-blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
original/placard-orange.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
original/placard-red.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
original/placard-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 KiB

BIN
original/sheep-alpha.xcf Executable file

Binary file not shown.

BIN
original/sheep.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
original/tabletop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
original/tiles-brick.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
original/tiles-desert.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
original/tiles-sheep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
original/tiles-stone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
original/tiles-volcano.xcf Executable file

Binary file not shown.

BIN
original/tiles-wheat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
original/tiles-wood.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
original/uncut/army.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

BIN
original/uncut/borders.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
original/uncut/cards.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
original/uncut/cards.xcf Normal file

Binary file not shown.

BIN
original/uncut/pips.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
original/uncut/placards.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
original/uncut/tiles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,306 +0,0 @@
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,8 +1,5 @@
import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types"; import type { Game, Session, Player } from "./types";
import { newPlayer } from "./playerFactory"; import { newPlayer } from "./playerFactory";
import { debug, info, MAX_CITIES, MAX_SETTLEMENTS, SEND_THROTTLE_MS } from "./constants";
import { shuffleBoard } from "./gameFactory";
import { getVictoryPointRule } from "./rules";
export const addActivity = (game: Game, session: Session | null, message: string): void => { export const addActivity = (game: Game, session: Session | null, message: string): void => {
let date = Date.now(); let date = Date.now();
@ -10,8 +7,7 @@ export const addActivity = (game: Game, session: Session | null, message: string
if (game.activities.length && game.activities[game.activities.length - 1].date === date) { if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
date++; date++;
} }
const actColor = session && session.color && session.color !== "unassigned" ? session.color : ""; game.activities.push({ color: session ? session.color : "", message, date });
game.activities.push({ color: actColor, message, date });
if (game.activities.length > 30) { if (game.activities.length > 30) {
game.activities.splice(0, game.activities.length - 30); game.activities.splice(0, game.activities.length - 30);
} }
@ -38,7 +34,7 @@ export const addChatMessage = (game: Game, session: Session | null, message: str
if (session && session.name) { if (session && session.name) {
entry.from = session.name; entry.from = session.name;
} }
if (session && session.color && session.color !== "unassigned") { if (session && session.color) {
entry.color = session.color; entry.color = session.color;
} }
game.chat.push(entry); game.chat.push(entry);
@ -51,7 +47,7 @@ export const getColorFromName = (game: Game, name: string): string => {
for (let id in game.sessions) { for (let id in game.sessions) {
const s = game.sessions[id]; const s = game.sessions[id];
if (s && s.name === name) { if (s && s.name === name) {
return s.color && s.color !== "unassigned" ? s.color : ""; return s.color || "";
} }
} }
return ""; return "";
@ -83,7 +79,7 @@ export const getFirstPlayerName = (game: Game): string => {
}; };
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => { export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
let color: PlayerColor | undefined; let color: string | undefined;
for (let id in game.sessions) { for (let id in game.sessions) {
const s = game.sessions[id]; const s = game.sessions[id];
if (s && s.name === name) { if (s && s.name === name) {
@ -110,7 +106,7 @@ export const getNextPlayerSession = (game: Game, name: string): Session | undefi
}; };
export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => { export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => {
let color: PlayerColor | undefined; let color: string | undefined;
for (let id in game.sessions) { for (let id in game.sessions) {
const s = game.sessions[id]; const s = game.sessions[id];
if (s && s.name === name) { if (s && s.name === name) {
@ -167,7 +163,7 @@ export const setForCityPlacement = (game: Game, limits: any): void => {
game.turn.limits = { corners: limits }; game.turn.limits = { corners: limits };
}; };
export const setForSettlementPlacement = (game: Game, limits: number[]): void => { export const setForSettlementPlacement = (game: Game, limits: number[] | undefined, _extra?: any): void => {
game.turn.actions = ["place-settlement"]; game.turn.actions = ["place-settlement"];
game.turn.limits = { corners: limits }; game.turn.limits = { corners: limits };
}; };
@ -190,363 +186,3 @@ export const adjustResources = (player: Player, deltas: Partial<Record<string, n
}); });
player.resources = total; player.resources = total;
}; };
export const startTurnTimer = (game: Game, session: Session) => {
const timeout = 90;
if (!session.ws) {
console.log(`${session.short}: Aborting turn timer as ${session.name} is disconnected.`);
} else {
console.log(`${session.short}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`);
}
if (game.turnTimer) {
clearTimeout(game.turnTimer);
}
if (!session.connected) {
game.turnTimer = 0;
return;
}
game.turnTimer = setTimeout(() => {
console.log(`${session.short}: Turn timer expired for ${session.name}`);
if (session.player) {
session.player.turnNotice = "It is still your turn.";
}
sendUpdateToPlayer(game, session, {
private: session.player,
});
resetTurnTimer(game, session);
}, timeout * 1000);
};
const calculatePoints = (game: any, update: any): void => {
if (game.state === "winner") {
return;
}
/* Calculate points and determine if there is a winner */
for (let key in game.players) {
const player = game.players[key];
if (player.status === "Not active") {
continue;
}
const currentPoints = player.points;
player.points = 0;
if (key === game.longestRoad) {
player.points += 2;
}
if (key === game.largestArmy) {
player.points += 2;
}
if (key === game.mostPorts) {
player.points += 2;
}
if (key === game.mostDeveloped) {
player.points += 2;
}
player.points += MAX_SETTLEMENTS - player.settlements;
player.points += 2 * (MAX_CITIES - player.cities);
player.unplayed = 0;
player.potential = 0;
player.development.forEach((card: any) => {
if (card.type === "vp") {
if (card.played) {
player.points++;
} else {
player.potential++;
}
}
if (!card.played) {
player.unplayed++;
}
});
if (player.points === currentPoints) {
continue;
}
if (player.points < getVictoryPointRule(game)) {
update.players = getFilteredPlayers(game);
continue;
}
/* This player has enough points! Check if they are the current
* player and if so, declare victory! */
console.log(`${info}: Whoa! ${player.name} has ${player.points}!`);
for (let key in game.sessions) {
if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") {
continue;
}
const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`;
addChatMessage(game, null, message);
console.log(`${info}: ${message}`);
update.winner = Object.assign({}, player, {
state: "winner",
stolen: game.stolen,
chat: game.chat,
turns: game.turns,
players: game.players,
elapsedTime: Date.now() - game.startTime,
});
game.winner = update.winner;
game.state = "winner";
game.waiting = [];
stopTurnTimer(game);
sendUpdateToPlayers(game, {
state: game.state,
winner: game.winner,
players: game.players /* unfiltered */,
});
}
}
/* If the game isn't in a win state, do not share development card information
* with other players */
if (game.state !== "winner") {
for (let key in game.players) {
const player = game.players[key];
if (player.status === "Not active") {
continue;
}
delete player.potential;
}
}
};
export const resetTurnTimer = (game: Game, session: Session): void => {
startTurnTimer(game, session);
};
export const stopTurnTimer = (game: Game): void => {
if (game.turnTimer) {
console.log(`${info}: Stopping turn timer.`);
try {
clearTimeout(game.turnTimer);
} catch (e) {
/* ignore if not a real timeout */
}
game.turnTimer = 0;
}
return undefined;
};
export const sendUpdateToPlayers = async (game: any, update: any): Promise<void> => {
/* Ensure clearing of a field actually gets sent by setting
* undefined to 'false'
*/
for (let key in update) {
if (update[key] === undefined) {
update[key] = false;
}
}
calculatePoints(game, update);
if (debug.update) {
console.log(`[ all ]: -> sendUpdateToPlayers - `, update);
} else {
const keys = Object.getOwnPropertyNames(update);
console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`);
}
const message = JSON.stringify({
type: "game-update",
update,
});
for (let key in game.sessions) {
const session = game.sessions[key];
/* Only send player and game data to named players */
if (!session.name) {
console.log(`${session.short}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`);
if (session.ws) {
session.ws.send(
JSON.stringify({
type: "game-update",
update: { name: "" },
})
);
}
continue;
}
if (!session.ws) {
console.log(`${session.short}: -> sendUpdateToPlayers: ` + `Currently no connection.`);
} else {
queueSend(session, message);
}
}
};
export const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise<void> => {
/* If this player does not have a name, *ONLY* send the name, regardless
* of what is requested */
if (!session.name) {
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`);
update = { name: "" };
}
/* Ensure clearing of a field actually gets sent by setting
* undefined to 'false'
*/
for (let key in update) {
if (update[key] === undefined) {
update[key] = false;
}
}
calculatePoints(game, update);
if (debug.update) {
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - `, update);
} else {
const keys = Object.getOwnPropertyNames(update);
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`);
}
const message = JSON.stringify({
type: "game-update",
update,
});
if (!session.ws) {
console.log(`${session.short}: -> sendUpdateToPlayer: ` + `Currently no connection.`);
} else {
queueSend(session, message);
}
};
export const queueSend = (session: any, message: any): void => {
if (!session || !session.ws) return;
try {
// Ensure we compare a stable serialization: if message is JSON text,
// parse it and re-serialize with sorted keys so semantically-equal
// objects compare equal even when property order differs.
const stableStringify = (msg: any): string => {
try {
const obj = typeof msg === "string" ? JSON.parse(msg) : msg;
const ordered = (v: any): any => {
if (v === null || typeof v !== "object") return v;
if (Array.isArray(v)) return v.map(ordered);
const keys = Object.keys(v).sort();
const out: any = {};
for (const k of keys) out[k] = ordered(v[k]);
return out;
};
return JSON.stringify(ordered(obj));
} catch (e) {
// If parsing fails, fall back to original string representation
return typeof msg === "string" ? msg : JSON.stringify(msg);
}
};
const stableMessage = stableStringify(message);
const now = Date.now();
if (!session._lastSent) session._lastSent = 0;
const elapsed = now - session._lastSent;
// If the exact same message (in stable form) was sent last time and
// nothing is pending, skip sending to avoid pointless duplicate
// traffic.
if (!session._pendingTimeout && session._lastMessage === stableMessage) {
return;
}
// If we haven't sent recently and there's no pending timer, send now
if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) {
try {
session.ws.send(typeof message === "string" ? message : JSON.stringify(message));
session._lastSent = Date.now();
session._lastMessage = stableMessage;
} catch (e) {
console.warn(`${session.id}: queueSend immediate send failed:`, e);
}
return;
}
// Otherwise, store latest message and schedule a send
// If the pending message would equal the last-sent message, don't bother
// storing/scheduling it.
if (session._lastMessage === stableMessage) {
return;
}
session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message);
if (session._pendingTimeout) {
// already scheduled; newest message will be sent when timer fires
return;
}
const delay = Math.max(1, SEND_THROTTLE_MS - elapsed);
session._pendingTimeout = setTimeout(() => {
try {
if (session.ws && session._pendingMessage) {
session.ws.send(session._pendingMessage);
session._lastSent = Date.now();
// compute stable form of what we actually sent
try {
session._lastMessage = stableStringify(session._pendingMessage);
} catch (e) {
session._lastMessage = session._pendingMessage;
}
}
} catch (e) {
console.warn(`${session.id}: queueSend delayed send failed:`, e);
}
// clear pending fields
session._pendingMessage = undefined;
clearTimeout(session._pendingTimeout);
session._pendingTimeout = undefined;
}, delay);
} catch (e) {
console.warn(`${session.id}: queueSend exception:`, e);
}
};
export const shuffle = (game: Game, session: Session): string | undefined => {
if (game.state !== "lobby") {
return `Game no longer in lobby (${game.state}). Can not shuffle board.`;
}
if (game.turns > 0) {
return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`;
}
shuffleBoard(game);
console.log(`${session.short}: Shuffled to new signature: ${game.signature}`);
sendUpdateToPlayers(game, {
pipOrder: game.pipOrder,
tileOrder: game.tileOrder,
borderOrder: game.borderOrder,
robber: game.robber,
robberName: game.robberName,
signature: game.signature,
animationSeeds: game.animationSeeds,
});
return undefined;
};
export const getName = (session: Session): string => {
return session ? (session.name ? session.name : session.id) : "Admin";
};
export const getFilteredPlayers = (game: Game): Record<string, Player> => {
const filtered: Record<string, Player> = {};
for (let color in game.players) {
const player = Object.assign({}, game.players[color]);
filtered[color] = player;
if (player.status === "Not active") {
if (game.state !== "lobby") {
delete filtered[color];
}
continue;
}
player.resources = 0;
RESOURCE_TYPES.forEach((resource) => {
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 { MAX_ROADS, MAX_CITIES, MAX_SETTLEMENTS } from "./constants";
import type { Player, PlayerColor } from "./types"; import type { Player } from "./types";
export const newPlayer = (color: PlayerColor): Player => { export const newPlayer = (color: string): Player => {
return { return {
roads: MAX_ROADS, roads: MAX_ROADS,
cities: MAX_CITIES, cities: MAX_CITIES,
@ -24,16 +24,7 @@ export const newPlayer = (color: PlayerColor): Player => {
turnStart: 0, turnStart: 0,
ports: 0, ports: 0,
developmentCards: 0, developmentCards: 0,
orderRoll: 0, } as Player;
position: "",
orderStatus: "none",
tied: false,
mustDiscard: 0,
live: true,
turnNotice: "",
longestRoad: 0,
banks: [],
};
}; };
export default newPlayer; export default newPlayer;

View File

@ -1,17 +0,0 @@
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

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

View File

@ -0,0 +1,34 @@
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,67 +1,68 @@
import type { Game } from "./types"; import type { GameState } from './state.js';
import { promises as fsp } from "fs"; import { promises as fsp } from 'fs';
import path from "path"; import path from 'path';
import { transientState } from "./sessionState";
interface GameDB { export interface GameDB {
db: any | null; sequelize?: any;
init(): Promise<void>; Sequelize?: any;
getGameById(id: string): Promise<Game | null>; getGameById(id: string | number): Promise<GameState | null>;
saveGame(game: Game): Promise<void>; saveGameState(id: string | number, state: GameState): Promise<void>;
deleteGame?(id: string): Promise<void>; deleteGame?(id: string | number): Promise<void>;
[k: string]: any;
} }
export const gameDB: GameDB = { /**
db: null, * Thin game DB initializer / accessor.
init: async () => { * 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; let mod: any;
try { try {
// Use runtime require to load the DB module. This runs under Node (ts-node) // 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. // so a direct require is appropriate and avoids relying on globalThis.
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
mod = require("../../db/games"); mod = require('../../db/games');
} catch (e) { } catch (e) {
// DB-only mode: fail fast so callers know persistence is required. // DB-only mode: fail fast so callers know persistence is required.
throw new Error("Game DB module could not be loaded: " + String(e)); throw new Error('Game DB module could not be loaded: ' + String(e));
} }
// If the module uses default export, prefer it // If the module uses default export, prefer it
let db = mod.default || mod; let db: any = (mod && (mod.default || mod));
// If the required module returned a Promise (the db initializer may), await it. // If the required module returned a Promise (the db initializer may), await it.
if (db && typeof db.then === "function") { if (db && typeof db.then === 'function') {
try { try {
db = await db; db = await db;
} catch (e) { } catch (e) {
throw new Error("Game DB initializer promise rejected: " + String(e)); throw new Error('Game DB initializer promise rejected: ' + String(e));
} }
} }
gameDB.db = db; // attach typed helper placeholders (will be implemented incrementally)
return db; if (!db.getGameById) {
}, db.getGameById = async (id: string | number): Promise<GameState | null> => {
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 // fallback: try to query by id using raw SQL if sequelize is available
if (db && db.sequelize) { if (db && db.sequelize) {
try { try {
const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", { const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
replacements: { id }, replacements: { id },
type: db.Sequelize.QueryTypes.SELECT, type: db.Sequelize.QueryTypes.SELECT
}); });
if (rows && rows.length) { if (rows && rows.length) {
const r = rows[0]; const r = rows[0] as any;
// state may be stored as text or JSON // state may be stored as text or JSON
if (typeof r.state === "string") { if (typeof r.state === 'string') {
try { try {
return JSON.parse(r.state) as Game; return JSON.parse(r.state) as GameState;
} catch (e) { } catch (e) {
return null; return null;
} }
} }
return r.state as Game; return r.state as GameState;
} }
} catch (e) { } catch (e) {
// ignore and fallthrough // ignore and fallthrough
@ -70,12 +71,12 @@ export const gameDB: GameDB = {
// If DB didn't have a state or query failed, attempt to read from the // 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. // filesystem copy at db/games/<id> or <id>.json so the state remains editable.
try { try {
const gamesDir = path.resolve(__dirname, "../../../db/games"); const gamesDir = path.resolve(__dirname, '../../../db/games');
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")]; const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')];
for (const filePath of candidates) { for (const filePath of candidates) {
try { try {
const raw = await fsp.readFile(filePath, "utf8"); const raw = await fsp.readFile(filePath, 'utf8');
return JSON.parse(raw) as Game; return JSON.parse(raw) as GameState;
} catch (e) { } catch (e) {
// try next candidate // try next candidate
} }
@ -84,92 +85,64 @@ export const gameDB: GameDB = {
} catch (err) { } catch (err) {
return null; 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) if (!db.saveGameState) {
transientState.stripGameTransients(reducedGame); db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
/* Save per turn while debugging... */
game.step = game.step ? game.step : 0;
// Always persist a JSON file so game state is inspectable/editable. // Always persist a JSON file so game state is inspectable/editable.
try { try {
const gamesDir = path.resolve(__dirname, "../../../db/games"); const gamesDir = path.resolve(__dirname, '../../../db/games');
await fsp.mkdir(gamesDir, { recursive: true }); await fsp.mkdir(gamesDir, { recursive: true });
// Write extensionless filename to match existing files // Write extensionless filename to match existing files
const filePath = path.join(gamesDir, reducedGame.id); const filePath = path.join(gamesDir, String(id));
const tmpPath = `${filePath}.tmp`; const tmpPath = `${filePath}.tmp`;
await fsp.writeFile(tmpPath, JSON.stringify(reducedGame, null, 2), "utf8"); await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
await fsp.rename(tmpPath, filePath); await fsp.rename(tmpPath, filePath);
} catch (err) { } catch (err) {
// Log but continue to attempt DB persistence // Log but continue to attempt DB persistence
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error("Failed to write game JSON file for", reducedGame.id, err); console.error('Failed to write game JSON file for', id, err);
} }
// Now attempt DB persistence if sequelize is present. // Now attempt DB persistence if sequelize is present.
if (db && db.sequelize) { if (db && db.sequelize) {
const payload = JSON.stringify(reducedGame); const payload = JSON.stringify(state);
// Try an UPDATE; if it errors due to missing column, try to add the // Try an UPDATE; if it errors due to missing column, try to add the
// column and retry. If update affects no rows, try INSERT. // column and retry. If update affects no rows, try INSERT.
try { try {
try { try {
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", { await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
replacements: { id: reducedGame.id, state: payload }, replacements: { id, state: payload }
}); });
// Some dialects don't return affectedRows consistently; we'll // Some dialects don't return affectedRows consistently; we'll
// still attempt insert if no row exists by checking select. // still attempt insert if no row exists by checking select.
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", { const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
replacements: { id: reducedGame.id }, replacements: { id },
type: db.Sequelize.QueryTypes.SELECT, type: db.Sequelize.QueryTypes.SELECT
}); });
if (!check || check.length === 0) { if (!check || check.length === 0) {
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { id: reducedGame.id, state: payload }, replacements: { id, state: payload }
}); });
} }
} catch (e: any) { } catch (e: any) {
const msg = String(e && e.message ? e.message : e); const msg = String(e && e.message ? e.message : e);
// If the column doesn't exist (SQLite: no such column: state), add it. // If the column doesn't exist (SQLite: no such column: state), add it.
if ( if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) {
/no such column: state/i.test(msg) ||
/has no column named state/i.test(msg) ||
/unknown column/i.test(msg)
) {
try { try {
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT"); await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
// retry insert/update after adding column // retry insert/update after adding column
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", { await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
replacements: { id: reducedGame.id, state: payload }, replacements: { id, state: payload }
}); });
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", { const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
replacements: { id: reducedGame.id }, replacements: { id },
type: db.Sequelize.QueryTypes.SELECT, type: db.Sequelize.QueryTypes.SELECT
}); });
if (!check || check.length === 0) { if (!check || check.length === 0) {
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { id: reducedGame.id, state: payload }, replacements: { id, state: payload }
}); });
} }
} catch (inner) { } catch (inner) {
@ -178,8 +151,8 @@ export const gameDB: GameDB = {
} else { } else {
// For other errors, attempt insert as a fallback // For other errors, attempt insert as a fallback
try { try {
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { id: reducedGame.id, state: payload }, replacements: { id, state: payload }
}); });
} catch (err) { } catch (err) {
// swallow; callers should handle missing persistence // swallow; callers should handle missing persistence
@ -190,22 +163,22 @@ export const gameDB: GameDB = {
// swallow; we don't want persistence errors to crash the server // swallow; we don't want persistence errors to crash the server
} }
} }
}, };
deleteGame: async (id: string | number): Promise<void> => {
if (!gameDB.db) {
await gameDB.init();
} }
const db = gameDB.db;
if (!db.deleteGame) {
db.deleteGame = async (id: string | number): Promise<void> => {
if (db && db.sequelize) { if (db && db.sequelize) {
try { try {
await db.sequelize.query("DELETE FROM games WHERE id=:id", { await db.sequelize.query('DELETE FROM games WHERE id=:id', {
replacements: { id }, replacements: { id }
}); });
} catch (e) { } catch (e) {
// swallow; callers should handle missing persistence // swallow; callers should handle missing persistence
} }
} }
}, };
}; }
export const games: Record<string, Game> = {}; return db as GameDB;
}

View File

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

View File

@ -1,7 +1,23 @@
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[] { export function shuffleArray<T>(array: T[]): T[] {
let currentIndex = array.length, let currentIndex = array.length, temporaryValue: T | undefined, randomIndex: number;
temporaryValue: T | undefined,
randomIndex: number;
while (0 !== currentIndex) { while (0 !== currentIndex) {
randomIndex = Math.floor(Math.random() * currentIndex); randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1; currentIndex -= 1;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import { CornerType, Game, PlayerColor } from "../routes/games/types"; import { layout } from './layout';
import { layout } from "./layout";
const isRuleEnabled = (game: any, rule: string): boolean => { const isRuleEnabled = (game: any, rule: string): boolean => {
return rule in game.rules && game.rules[rule].enabled; return rule in game.rules && game.rules[rule].enabled;
@ -13,16 +12,7 @@ const getValidRoads = (game: any, color: string): number[] => {
* has a matching color, add this to the set. Otherwise skip. * has a matching color, add this to the set. Otherwise skip.
*/ */
layout.roads.forEach((road, roadIndex) => { layout.roads.forEach((road, roadIndex) => {
// Skip if placements or roads missing, or if this road is already occupied if (!game.placements || !game.placements.roads || game.placements.roads[roadIndex]?.color) {
// 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; return;
} }
let valid = false; let valid = false;
@ -32,14 +22,9 @@ const getValidRoads = (game: any, color: string): number[] => {
continue; continue;
} }
const corner = (layout as any).corners[cornerIndex]; const corner = (layout as any).corners[cornerIndex];
const cornerColor = const cornerColor = (game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cornerIndex] && (game as any).placements.corners[cornerIndex].color;
(game as any).placements && /* Roads do not pass through other player's settlements */
(game as any).placements.corners && if (cornerColor && cornerColor !== color) {
(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; continue;
} }
for (let r = 0; !valid && r < (corner.roads || []).length; r++) { for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
@ -48,9 +33,7 @@ const getValidRoads = (game: any, color: string): number[] => {
continue; continue;
} }
const rr = corner.roads[r]; const rr = corner.roads[r];
if (rr == null) { if (rr == null) { continue; }
continue;
}
const placementsRoads = (game as any).placements && (game as any).placements.roads; const placementsRoads = (game as any).placements && (game as any).placements.roads;
if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) { if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) {
valid = true; valid = true;
@ -63,14 +46,13 @@ const getValidRoads = (game: any, color: string): number[] => {
}); });
return limits; return limits;
}; }
const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: CornerType): number[] => { const getValidCorners = (game: any, color: string, type?: string): number[] => {
const limits: number[] = []; const limits: number[] = [];
console.log("getValidCorners", color, type);
/* For each corner, if the corner already has a color set, skip it if type /* For each corner, if the corner already has a color set, skip it if type
* isn't set. If type is set and is a match, and the color is a match, * isn't set. If type is set, if it is a match, and the color is a match,
* add it to the list. * add it to the list.
* *
* If we are limiting based on active player, a corner is only valid * If we are limiting based on active player, a corner is only valid
@ -85,52 +67,36 @@ const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: C
* Volcano is enabled, verify the tile is not the Volcano. * Volcano is enabled, verify the tile is not the Volcano.
*/ */
layout.corners.forEach((corner, cornerIndex) => { layout.corners.forEach((corner, cornerIndex) => {
const placement = game.placements && game.placements.corners ? game.placements.corners[cornerIndex] : undefined; const placement = game.placements.corners[cornerIndex];
if (!placement) {
// Treat a missing placement as unassigned (no owner)
// Continue processing using a falsy placement where appropriate
}
if (type) { if (type) {
if (placement && placement.color === color && placement.type === type) { if (placement.color === color && placement.type === type) {
limits.push(cornerIndex); limits.push(cornerIndex);
} }
return; return;
} }
// If the corner has a color set and it's not the explicit sentinel if (placement.color) {
// "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; return;
} }
let valid; let valid;
// Treat either a falsy color (""/undefined) or the explicit sentinel if (!color) {
// "unassigned" as meaning "do not filter by player".
if (!color || color === "unassigned") {
valid = true; /* Not filtering based on current player */ valid = true; /* Not filtering based on current player */
} else { } else {
valid = false; valid = false;
for (let r = 0; !valid && r < (corner.roads || []).length; r++) { for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
const rr = corner.roads[r]; const rr = corner.roads[r];
if (rr == null) { if (rr == null) { continue; }
continue; const placementsRoads = (game as any).placements && (game as any).placements.roads;
}
const placementsRoads = game.placements && game.placements.roads;
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color); valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
} }
} }
for (let r = 0; valid && r < (corner.roads || []).length; r++) { for (let r = 0; valid && r < (corner.roads || []).length; r++) {
if (!corner.roads) { if (!corner.roads) { break; }
break; const ridx = corner.roads[r] as number;
} if (ridx == null || (layout as any).roads[ridx] == null) { continue; }
const ridx = corner.roads[r]; const road = (layout as any).roads[ridx];
if (ridx == null || layout.roads[ridx] == null) {
continue;
}
const road = layout.roads[ridx];
for (let c = 0; valid && c < (road.corners || []).length; c++) { for (let c = 0; valid && c < (road.corners || []).length; c++) {
/* This side of the road is pointing to the corner being validated. /* This side of the road is pointing to the corner being validated.
* Skip it. */ * Skip it. */
@ -140,9 +106,7 @@ const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: C
/* There is a settlement within one segment from this /* There is a settlement within one segment from this
* corner, so it is invalid for settlement placement */ * corner, so it is invalid for settlement placement */
const cc = road.corners[c] as number; const cc = road.corners[c] as number;
const ccPlacement = game.placements && game.placements.corners ? game.placements.corners[cc] : undefined; if ((game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cc] && (game as any).placements.corners[cc].color) {
const ccColor = ccPlacement ? ccPlacement.color : undefined;
if (ccColor && ccColor !== "unassigned") {
valid = false; valid = false;
} }
} }
@ -151,23 +115,20 @@ const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: C
/* During initial placement, if volcano is enabled, do not allow /* During initial placement, if volcano is enabled, do not allow
* placement on a corner connected to the volcano (robber starts * placement on a corner connected to the volcano (robber starts
* on the volcano) */ * on the volcano) */
if ( if (!(game.state === 'initial-placement'
!( && isRuleEnabled(game, 'volcano')
game.state === "initial-placement" && && (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
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); limits.push(cornerIndex);
} }
} }
}); });
return limits; return limits;
}; }
export { getValidCorners, getValidRoads, isRuleEnabled }; export {
getValidCorners,
getValidRoads,
isRuleEnabled
};