1
0

Compare commits

...

7 Commits

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

1
.gitignore vendored
View File

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

View File

@ -50,7 +50,10 @@ const App = () => {
useEffect(() => { useEffect(() => {
if (error) { if (error) {
setTimeout(() => setError(null), 5000); setTimeout(() => {
setError(null);
}, 5000);
console.error(`app - error`, error);
} }
}, [error]); }, [error]);
@ -58,13 +61,13 @@ const App = () => {
if (!session) { if (!session) {
return; return;
} }
console.log(`App - sessionId`, session.id); console.log(`app - sessionId`, session.id);
}, [session]); }, [session]);
const getSession = useCallback(async () => { const getSession = useCallback(async () => {
try { try {
const session = await sessionApi.getCurrent(); const session = await sessionApi.getCurrent();
console.log(`App - got sessionId`, session.id); console.log(`app - got sessionId`, session.id);
setSession(session); setSession(session);
setSessionRetryAttempt(0); setSessionRetryAttempt(0);
} catch (err) { } catch (err) {
@ -121,7 +124,19 @@ const App = () => {
</Router> </Router>
)} )}
{error && ( {error && (
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}> <Paper
className="Error"
sx={{
position: "absolute",
top: 0,
left: 0,
zIndex: 32767,
p: 2,
m: 2,
width: "fit-content",
backgroundColor: "#ffdddd",
}}
>
<Typography color="red">{error}</Typography> <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,86 +147,123 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
return; return;
} }
const data = lastJsonMessage; const data = lastJsonMessage;
const handleUpdate = (update: any) => {
console.log(`board - game update`, update);
if ("robber" in update && update.robber !== robber) {
setRobber(update.robber);
}
if ("robberName" in update) {
const newName = Array.isArray(update.robberName) ? String(update.robberName[0] || "") : String(update.robberName || "");
if (newName !== robberName) setRobberName(newName);
}
if ("state" in update && update.state !== state) {
setState(update.state);
}
if ("rules" in update && !equal(update.rules, rules)) {
setRules(update.rules);
}
if ("color" in update && update.color !== color) {
setColor(update.color);
}
if ("longestRoadLength" in update && update.longestRoadLength !== longestRoadLength) {
setLongestRoadLength(update.longestRoadLength);
}
if ("turn" in update) {
if (!equal(update.turn, turn)) {
console.log(`board - turn`, update.turn);
setTurn(update.turn);
}
}
if ("placements" in update && !equal(update.placements, placements)) {
console.log(`board - placements`, update.placements);
setPlacements(update.placements);
}
/* The following are only updated if there is a new game
* signature or changed ordering */
if ("pipOrder" in update && !equal(update.pipOrder, pipOrder)) {
console.log(`board - setting new pipOrder`);
setPipOrder(update.pipOrder);
}
if ("borderOrder" in update && !equal(update.borderOrder, borderOrder)) {
console.log(`board - setting new borderOrder`);
setBorderOrder(update.borderOrder);
}
if ("animationSeeds" in update && !equal(update.animationSeeds, animationSeeds)) {
console.log(`board - setting new animationSeeds`);
setAnimationSeeds(update.animationSeeds);
}
if ("tileOrder" in update && !equal(update.tileOrder, tileOrder)) {
console.log(`board - setting new tileOrder`);
setTileOrder(update.tileOrder);
}
if (update.signature !== undefined && update.signature !== signature) {
console.log(`board - setting new signature`);
setSignature(update.signature);
}
/* Static data from the server (defensive): update when present and different */
if ("pips" in update && !equal(update.pips, pips)) {
console.log(`board - setting new static pips`);
setPips(update.pips);
}
if ("tiles" in update && !equal(update.tiles, tiles)) {
console.log(`board - setting new static tiles`);
setTiles(update.tiles);
}
if ("borders" in update && !equal(update.borders, borders)) {
console.log(`board - setting new static borders`);
setBorders(update.borders);
}
};
switch (data.type) { switch (data.type) {
case "game-update": case "game-update":
console.log(`board - game update`, data.update); handleUpdate(data.update || {});
if ("robber" in data.update && data.update.robber !== robber) { break;
setRobber(data.update.robber);
}
if ("robberName" in data.update && data.update.robberName !== robberName) { case "initial-game":
setRobberName(data.update.robberName); // initial snapshot contains the consolidated game in data.snapshot
} console.log(`board - initial-game snapshot received`);
if (data.snapshot) {
const snap = data.snapshot;
// Normalize snapshot fields to same keys used for incremental updates
const initialUpdate: any = {};
// Pick expected fields from snapshot
[
"robber",
"robberName",
"state",
"rules",
"color",
"longestRoadLength",
"turn",
"placements",
"pipOrder",
"borderOrder",
"animationSeeds",
"tileOrder",
"signature",
].forEach((k) => {
if (k in snap) initialUpdate[k] = snap[k];
});
// static asset metadata
if ("tiles" in snap) initialUpdate.tiles = snap.tiles;
if ("pips" in snap) initialUpdate.pips = snap.pips;
if ("borders" in snap) initialUpdate.borders = snap.borders;
if ("state" in data.update && data.update.state !== state) { handleUpdate(initialUpdate);
setState(data.update.state);
}
if ("rules" in data.update && !equal(data.update.rules, rules)) {
setRules(data.update.rules);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
if ("longestRoadLength" in data.update && data.update.longestRoadLength !== longestRoadLength) {
setLongestRoadLength(data.update.longestRoadLength);
}
if ("turn" in data.update) {
if (!equal(data.update.turn, turn)) {
console.log(`board - turn`, data.update.turn);
setTurn(data.update.turn);
}
}
if ("placements" in data.update && !equal(data.update.placements, placements)) {
console.log(`board - placements`, data.update.placements);
setPlacements(data.update.placements);
}
/* The following are only updated if there is a new game
* signature */
if ("pipOrder" in data.update && !equal(data.update.pipOrder, pipOrder)) {
console.log(`board - setting new pipOrder`);
setPipOrder(data.update.pipOrder);
}
if ("borderOrder" in data.update && !equal(data.update.borderOrder, borderOrder)) {
console.log(`board - setting new borderOrder`);
setBorderOrder(data.update.borderOrder);
}
if ("animationSeeds" in data.update && !equal(data.update.animationSeeds, animationSeeds)) {
console.log(`board - setting new animationSeeds`);
setAnimationSeeds(data.update.animationSeeds);
}
if ("tileOrder" in data.update && !equal(data.update.tileOrder, tileOrder)) {
console.log(`board - setting new tileOrder`);
setTileOrder(data.update.tileOrder);
}
if (data.update.signature !== signature) {
console.log(`board - setting new signature`);
setSignature(data.update.signature);
}
/* This is permanent static data from the server -- do not update
* once set */
if ("pips" in data.update && !pips) {
console.log(`board - setting new static pips`);
setPips(data.update.pips);
}
if ("tiles" in data.update && !tiles) {
console.log(`board - setting new static tiles`);
setTiles(data.update.tiles);
}
if ("borders" in data.update && !borders) {
console.log(`board - setting new static borders`);
setBorders(data.update.borders);
} }
break; break;
default: default:
@ -508,10 +545,6 @@ 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,
@ -928,7 +961,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");
el.classList.add(robberName); if (robberName) el.classList.add(robberName);
} }
} }
}); });

View File

@ -351,10 +351,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard <Placard
sx={{ sx={{
float: "left", float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */, marginRight: "0.5rem",
clipPath: "inset(0)" /* Ensures proper wrapping area */, marginBottom: "0.5rem",
marginRight: "1rem",
marginBottom: "1rem",
}} }}
type="most-developed" type="most-developed"
/> />
@ -364,6 +362,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
fields. Picture yourself snagging this beautifully illustrated 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>
), ),
}, },
@ -378,10 +377,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard <Placard
sx={{ sx={{
float: "left", float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */, marginRight: "0.5rem",
clipPath: "inset(0)" /* Ensures proper wrapping area */, marginBottom: "0.5rem",
marginRight: "1rem",
marginBottom: "1rem",
}} }}
type="port-of-call" type="port-of-call"
/> />
@ -392,6 +389,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
moment someone else builds a larger network of harbors, 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>
), ),
}, },
@ -406,10 +404,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
<Placard <Placard
sx={{ sx={{
float: "left", float: "left",
shapeOutside: "inset(0)" /* Text wraps the full rectangle */, marginRight: "0.5rem",
clipPath: "inset(0)" /* Ensures proper wrapping area */, marginBottom: "0.5rem",
marginRight: "1rem",
marginBottom: "1rem",
}} }}
type="longest-turn" type="longest-turn"
/> />
@ -418,6 +414,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
handed this charming 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: 1.25rem; min-width: 3.5rem;
min-height: 0.9375rem; min-height: 1.8725rem;
z-index: 1200; z-index: 1200;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
@ -68,30 +68,81 @@
} }
.MediaControl .Controls { .MediaControl .Controls {
display: flex; display: none; /* Hidden by default, shown on hover */
position: absolute; position: absolute;
gap: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
flex-direction: column; right: 0;
z-index: 1; min-width: fit-content;
align-items: flex-start; min-height: fit-content;
justify-content: center z-index: 1251; /* Above the Indicators but below active move handles */
background-color: rgba(64, 64, 64, 64);
backdrop-filter: blur(5px);
} }
.MediaControl.Small .Controls { .MediaControl:hover .Controls {
display: flex; /* Show controls on hover */
flex-direction: row;
}
/* Indicators: visual, non-interactive icons anchored lower-left of the container.
They are independent from the interactive Controls and scale responsively. */
.Indicators {
position: absolute;
z-index: 1250; /* Above the video but below active move handles */
/* Use percentage offsets so the indicators scale and stick to lower-left of the target */
left: 4%;
bottom: 4%;
display: flex;
flex-direction: column;
pointer-events: none; /* non-interactive */
align-items: flex-start;
}
.Indicators .IndicatorRow {
display: flex;
gap: 0.12rem;
align-items: center;
}
.Indicators .IndicatorItem {
background: rgba(0, 0, 0, 0.45);
padding: 0.12rem;
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
color: white;
display: inline-flex;
align-items: center;
justify-content: center; 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,6 +14,7 @@ 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.
@ -1304,6 +1305,7 @@ 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> = ({
@ -1313,6 +1315,7 @@ 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);
@ -1333,8 +1336,13 @@ 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(() => {
@ -1513,22 +1521,6 @@ const MediaControl: React.FC<MediaControlProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]); }, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]);
// Debug target element
useEffect(() => {
console.log("Target ref current:", targetRef.current, "for peer:", peer?.session_id);
if (targetRef.current) {
console.log("Target element rect:", targetRef.current.getBoundingClientRect());
console.log("Target element computed style:", {
position: getComputedStyle(targetRef.current).position,
left: getComputedStyle(targetRef.current).left,
top: getComputedStyle(targetRef.current).top,
transform: getComputedStyle(targetRef.current).transform,
width: getComputedStyle(targetRef.current).width,
height: getComputedStyle(targetRef.current).height,
});
}
}, [peer?.session_id]);
const toggleMute = useCallback( const toggleMute = useCallback(
(e: React.MouseEvent | React.TouchEvent) => { (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -1602,6 +1594,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
alignItems: "center", alignItems: "center",
minWidth: "200px", minWidth: "200px",
minHeight: "100px", minHeight: "100px",
...sx,
}} }}
> >
<div <div
@ -1644,7 +1637,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}`} className={`MediaControl ${className} ${controlsExpanded ? "Expanded" : "Small"}`}
data-peer={peer.session_id} data-peer={peer.session_id}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
style={{ style={{
@ -1655,8 +1648,67 @@ 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);
}}
> >
<Box className="Controls"> {/* Visual indicators: placed inside a clipped container that matches the
Moveable target size so indicators scale with and are clipped by the target. */}
<Box
className="Indicators"
sx={{ display: "flex", flexDirection: "row", color: "grey", pointerEvents: "none" }}
ref={indicatorsRef}
>
{isSelf ? (
<>
{muted ? <MicOff sx={{ height: "100%" }} /> : <Mic />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
) : (
<>
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
{remoteAudioMuted && <MicOff />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
)}
</Box>
{/* Interactive controls: rendered inside target but referenced separately */}
<Box className="Controls" ref={controlsRef}>
{isSelf ? ( {isSelf ? (
<IconButton onClick={toggleMute}> <IconButton onClick={toggleMute}>
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />} {muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}

View File

@ -64,6 +64,20 @@ 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) {
@ -121,7 +135,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]);
@ -149,96 +163,100 @@ const PlayerList: React.FC = () => {
> >
<MediaAgent {...{ session, peers, setPeers }} /> <MediaAgent {...{ session, peers, setPeers }} />
<List className="PlayerSelector"> <List className="PlayerSelector">
{players?.map((player) => ( {players?.map((player) => {
<Box const peerObj = peers[player.session_id] || peers[player.name];
key={player.session_id} return (
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }} <Box
className={`PlayerEntry ${player.local ? "PlayerSelf" : ""}`} key={player.session_id}
> sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
<Box> className={`PlayerEntry ${player.local ? "PlayerSelf" : ""}`}
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}> >
<Box style={{ display: "flex-wrap", alignItems: "center" }}> <Box>
<div className="Name">{player.name ? player.name : player.session_id}</div> <Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
{player.protected && ( <Box style={{ display: "flex-wrap", alignItems: "center" }}>
<div <div className="Name">{player.name ? player.name : player.session_id}</div>
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }} {player.protected && (
title="This name is protected with a password" <div
> style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
🔒 title="This name is protected with a password"
</div> >
)} 🔒
{player.bot_instance_id && ( </div>
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot"> )}
🤖 {player.bot_instance_id && (
</div> <div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
)} 🤖
</div>
)}
</Box>
</Box> </Box>
{player.name && !player.live && <div className="NoNetwork"></div>}
</Box> </Box>
{player.name && !player.live && <div className="NoNetwork"></div>} {player.name && player.live && peerObj && (player.local || player.has_media !== false) ? (
</Box> <>
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? ( <MediaControl
<> sx={{ border: "3px solid blue" }}
<MediaControl 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 && ( {player.local && player.color === "unassigned" && (
<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 }}>
{["orange", "red", "white", "blue"].map((c) => ( {["orange", "red", "white", "blue"].map((c) => (
<Box <Box
key={c} key={c}
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
padding: "6px 8px", padding: "6px 8px",
borderRadius: 6, borderRadius: 6,
border: "1px solid #ccc", border: "1px solid #ccc",
background: "#fff", background: "#fff",
cursor: sendJsonMessage ? "pointer" : "not-allowed", cursor: sendJsonMessage ? "pointer" : "not-allowed",
}} }}
onClick={() => { onClick={() => {
if (!sendJsonMessage) return; if (!sendJsonMessage) return;
sendJsonMessage({ type: "set", field: "color", value: c[0].toUpperCase() }); sendJsonMessage({ type: "set", field: "color", value: c[0].toUpperCase() });
}} }}
> >
<PlayerColor color={c} /> <PlayerColor color={c} />
</Box> </Box>
))} ))}
</div>
</div> </div>
</div> )}
)} </>
</> ) : player.name && player.live && player.has_media === false ? (
) : player.name && player.live && player.has_media === false ? ( <div
<div className="Video fade-in"
className="Video fade-in" style={{
style={{ background: "#333",
background: "#333", color: "#fff",
color: "#fff", display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", justifyContent: "center",
justifyContent: "center", width: "100%",
width: "100%", height: "100%",
height: "100%", fontSize: "14px",
fontSize: "14px", }}
}} >
> 💬 Chat Only
💬 Chat Only </div>
</div> ) : (
) : ( <video className="Video"></video>
<video className="Video"></video> )}
)} </Box>
</Box> );
))} })}
</List> </List>
</Paper> </Paper>
</Box> </Box>

View File

@ -48,14 +48,12 @@ 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;
}; };
@ -149,15 +147,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("App - Received ping from server, sending pong"); console.log("room-view - Received ping from server, sending pong");
sendJsonMessage({ type: "pong" }); sendJsonMessage({ type: "pong" });
break; break;
case "error": case "error":
console.error(`App - error`, data.error); console.error(`room-view - error`, data.error);
setError(data.error); setError(data.data.error || JSON.stringify(data));
break; break;
case "warning": case "warning":
console.warn(`App - warning`, data.warning); console.warn(`room-view - warning`, data.warning);
setWarning(data.warning); setWarning(data.warning);
setTimeout(() => { setTimeout(() => {
setWarning(""); setWarning("");
@ -223,7 +221,7 @@ const RoomView = (props: RoomProps) => {
default: default:
break; break;
} }
}, [lastJsonMessage, session, setError, setSession]); }, [lastJsonMessage, session]);
useEffect(() => { useEffect(() => {
if (state === "volcano") { if (state === "volcano") {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

View File

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

View File

@ -16,6 +16,9 @@ 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;
} }
@ -42,11 +45,12 @@ const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedC
if (placedCorner.walking) { if (placedCorner.walking) {
return; return;
} }
placedCorner.walking = true; placedCorner.walking = true;
/* 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);
}); });
}; };
@ -54,7 +58,7 @@ const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedC
const processRoad = (game: any, color: string, roadIndex: number, placedRoad: any): number => { const processRoad = (game: any, color: string, roadIndex: number, placedRoad: any): number => {
/* If this road isn't assigned to the walking color, skip it */ /* If this road isn't assigned to the walking color, skip it */
if (placedRoad.color !== color) { if (placedRoad.color !== color) {
return 0; return 0;
} }
/* If this road is already being walked, skip it */ /* If this road is already being walked, skip it */
@ -65,8 +69,9 @@ const processRoad = (game: any, color: string, roadIndex: number, placedRoad: an
placedRoad.walking = true; 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;
} }
@ -89,21 +94,26 @@ 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];
buildCornerGraph(game, color, cornerIndex, placedCorner, set) if (!placedCorner) return;
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) => {
delete game.placements.roads[itemIndex].walking; if (game.placements && game.placements.roads && game.placements.roads[itemIndex]) {
delete game.placements.roads[itemIndex].walking;
}
}); });
/* Clear out walk markers on corners */ /* Clear out walk markers on corners */
layout.corners.forEach((item, itemIndex) => { layout.corners.forEach((item, itemIndex) => {
delete game.placements.corners[itemIndex].walking; if (game.placements && game.placements.corners && game.placements.corners[itemIndex]) {
delete game.placements.corners[itemIndex].walking;
}
}); });
} }
@ -122,6 +132,7 @@ const calculateRoadLengths = (game: any) => {
let graphs = []; 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);
@ -133,13 +144,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);
@ -154,7 +165,9 @@ const calculateRoadLengths = (game: any) => {
}); });
}); });
game.placements.roads.forEach(road => delete road.walking); game.placements.roads.forEach((road: any) => {
if (road) delete road.walking;
});
return final; return final;
}; };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,306 @@
import randomWords from "random-words";
import { games, gameDB } from "./store";
import { PLAYER_COLORS, type Game } from "./types";
import { addChatMessage, clearPlayer, stopTurnTimer } from "./helpers";
import newPlayer from "./playerFactory";
import { info } from "./constants";
import { layout, staticData } from "../../util/layout";
import { shuffleArray } from "./utils";
import { pickRobber } from "./robber";
import { audio } from "../webrtc-signaling";
export const resetGame = (game: any) => {
Object.assign(game, {
startTime: Date.now(),
state: "lobby",
turns: 0,
step: 0 /* used for the suffix # in game backups */,
turn: {},
sheep: 19,
ore: 19,
wool: 19,
brick: 19,
wheat: 19,
placements: {
corners: [],
roads: [],
},
developmentCards: [],
chat: [],
activities: [],
pipOrder: game.pipOrder,
borderOrder: game.borderOrder,
tileOrder: game.tileOrder,
signature: game.signature,
players: game.players,
stolen: {
robber: {
stole: {
total: 0,
},
},
total: 0,
},
longestRoad: "",
longestRoadLength: 0,
largestArmy: "",
largestArmySize: 0,
mostDeveloped: "",
mostDevelopmentCards: 0,
mostPorts: "",
mostPortCount: 0,
winner: undefined,
active: 0,
});
stopTurnTimer(game);
/* Populate the game corner and road placement data as cleared */
for (let i = 0; i < layout.corners.length; i++) {
game.placements.corners[i] = {
color: undefined,
type: undefined,
};
}
for (let i = 0; i < layout.roads.length; i++) {
game.placements.roads[i] = {
color: undefined,
longestRoad: undefined,
};
}
/* Put the robber back on the Desert */
for (let i = 0; i < game.pipOrder.length; i++) {
if (game.pipOrder[i] === 18) {
game.robber = i;
break;
}
}
/* Populate the game development cards with a fresh deck */
for (let i = 1; i <= 14; i++) {
game.developmentCards.push({
type: "army",
card: i,
});
}
["monopoly", "monopoly", "road-1", "road-2", "year-of-plenty", "year-of-plenty"].forEach((card) =>
game.developmentCards.push({
type: "progress",
card: card,
})
);
["market", "library", "palace", "university"].forEach((card) =>
game.developmentCards.push({
type: "vp",
card: card,
})
);
shuffleArray(game.developmentCards);
/* Reset all player data, and add in any missing colors */
PLAYER_COLORS.forEach((color) => {
if (color in game.players) {
clearPlayer(game.players[color]);
} else {
game.players[color] = newPlayer(color);
}
});
/* Ensure sessions are connected to player objects */
for (let key in game.sessions) {
const session = game.sessions[key];
if (session.color !== "unassigned") {
game.active++;
session.player = game.players[session.color];
session.player.status = "Active";
session.player.lastActive = Date.now();
session.player.live = session.live;
session.player.name = session.name;
session.player.color = session.color;
}
}
game.animationSeeds = [];
for (let i = 0; i < game.tileOrder.length; i++) {
game.animationSeeds.push(Math.random());
}
};
export const setBeginnerGame = (game: Game) => {
pickRobber(game);
shuffleArray(game.developmentCards);
game.borderOrder = [];
for (let i = 0; i < 6; i++) {
game.borderOrder.push(i);
}
game.tileOrder = [9, 12, 1, 5, 16, 13, 17, 6, 2, 0, 3, 10, 4, 11, 7, 14, 18, 8, 15];
game.robber = 9;
game.animationSeeds = [];
for (let i = 0; i < game.tileOrder.length; i++) {
game.animationSeeds.push(Math.random());
}
game.pipOrder = [5, 1, 6, 7, 2, 9, 11, 12, 8, 18, 3, 4, 10, 16, 13, 0, 14, 15, 17];
game.signature = gameSignature(game);
};
export const shuffleBoard = (game: any): void => {
pickRobber(game);
const seq = [];
for (let i = 0; i < 6; i++) {
seq.push(i);
}
shuffleArray(seq);
game.borderOrder = seq.slice();
for (let i = 6; i < 19; i++) {
seq.push(i);
}
shuffleArray(seq);
game.tileOrder = seq.slice();
/* Pip order is from one of the random corners, then rotate around
* and skip over the desert (robber) */
/* Board:
* 0 1 2
* 3 4 5 6
* 7 8 9 10 11
* 12 13 14 15
* 16 17 18
*/
const order = [
[0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9],
[2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 5, 10, 14, 13, 8, 4, 9],
[11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 10, 14, 13, 8, 4, 5, 9],
[18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 14, 13, 8, 4, 5, 10, 9],
[16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 13, 8, 4, 5, 10, 14, 9],
[7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 8, 4, 5, 10, 14, 13, 9],
];
const sequence = order[Math.floor(Math.random() * order.length)];
if (!sequence || !Array.isArray(sequence)) {
// Defensive: should not happen, but guard for TS strictness
return;
}
game.pipOrder = [];
game.animationSeeds = [];
for (let i = 0, p = 0; i < sequence.length; i++) {
const target: number = sequence[i]!;
/* If the target tile is the desert (18), then set the
* pip value to the robber (18) otherwise set
* the target pip value to the currently incremeneting
* pip value. */
const tileIdx = game.tileOrder[target];
const tileType = staticData.tiles[tileIdx]?.type;
if (!game.pipOrder) game.pipOrder = [];
if (tileType === "desert") {
game.robber = target;
game.pipOrder[target] = 18;
} else {
game.pipOrder[target] = p++;
}
game.animationSeeds.push(Math.random());
}
shuffleArray(game.developmentCards);
game.signature = gameSignature(game);
};
export const createGame = async (id: string | null = null) => {
/* Look for a new game with random words that does not already exist */
while (!id) {
id = randomWords(4).join("-");
try {
/* If a game with this id exists in the DB, look for a new name */
if (!gameDB || !gameDB.getGameById) {
throw new Error("Game DB not available for uniqueness check");
}
let exists = false;
try {
const g = await gameDB.getGameById(id);
if (g) exists = true;
} catch (e) {
// if DB check fails treat as non-existent and continue searching
}
if (exists) {
id = "";
}
} catch (error) {
break;
}
}
console.log(`${info}: creating ${id}`);
const game: Game = {
id: id,
developmentCards: [],
playerOrder: [],
turns: 0,
players: {
O: newPlayer("O"),
R: newPlayer("R"),
B: newPlayer("B"),
W: newPlayer("W"),
},
mostPorts: null,
mostPortCount: 0,
sessions: {},
unselected: [],
placements: {
corners: [],
roads: [],
},
turn: {
name: "",
color: "unassigned",
actions: [],
limits: {},
roll: 0,
},
rules: {
"victory-points": {
points: 10,
},
},
pipOrder: [],
borderOrder: [],
tileOrder: [],
step: 0 /* used for the suffix # in game backups */,
};
setBeginnerGame(game);
resetGame(game);
addChatMessage(game, null, `New game created with Beginner's Layout: ${game.id}`);
games[game.id] = game;
audio[game.id] = {};
return game;
};
const gameSignature = (game: Game): string => {
if (!game) {
return "";
}
const salt = 251;
const signature =
(game.borderOrder || []).map((border: any) => `00${(Number(border) ^ salt).toString(16)}`.slice(-2)).join("") +
"-" +
(game.pipOrder || [])
.map((pip: any, index: number) => `00${(Number(pip) ^ salt ^ (salt * index)).toString(16)}`.slice(-2))
.join("") +
"-" +
(game.tileOrder || [])
.map((tile: any, index: number) => `00${(Number(tile) ^ salt ^ (salt * index)).toString(16)}`.slice(-2))
.join("");
return signature;
};

View File

@ -1,5 +1,8 @@
import type { Game, Session, Player } from "./types"; import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types";
import { newPlayer } from "./playerFactory"; import { 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();
@ -7,7 +10,8 @@ export const addActivity = (game: Game, session: Session | null, message: string
if (game.activities.length && game.activities[game.activities.length - 1].date === date) { if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
date++; date++;
} }
game.activities.push({ color: session ? session.color : "", message, date }); const actColor = session && session.color && session.color !== "unassigned" ? session.color : "";
game.activities.push({ color: actColor, message, date });
if (game.activities.length > 30) { if (game.activities.length > 30) {
game.activities.splice(0, game.activities.length - 30); game.activities.splice(0, game.activities.length - 30);
} }
@ -34,7 +38,7 @@ export const addChatMessage = (game: Game, session: Session | null, message: str
if (session && session.name) { if (session && session.name) {
entry.from = session.name; entry.from = session.name;
} }
if (session && session.color) { if (session && session.color && session.color !== "unassigned") {
entry.color = session.color; entry.color = session.color;
} }
game.chat.push(entry); game.chat.push(entry);
@ -47,7 +51,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 || ""; return s.color && s.color !== "unassigned" ? s.color : "";
} }
} }
return ""; return "";
@ -79,7 +83,7 @@ export const getFirstPlayerName = (game: Game): string => {
}; };
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => { export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
let color: string | undefined; let color: PlayerColor | undefined;
for (let id in game.sessions) { for (let id in game.sessions) {
const s = game.sessions[id]; const s = game.sessions[id];
if (s && s.name === name) { if (s && s.name === name) {
@ -106,7 +110,7 @@ export const getNextPlayerSession = (game: Game, name: string): Session | undefi
}; };
export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => { export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => {
let color: string | undefined; let color: PlayerColor | undefined;
for (let id in game.sessions) { for (let id in game.sessions) {
const s = game.sessions[id]; const s = game.sessions[id];
if (s && s.name === name) { if (s && s.name === name) {
@ -163,7 +167,7 @@ export const setForCityPlacement = (game: Game, limits: any): void => {
game.turn.limits = { corners: limits }; game.turn.limits = { corners: limits };
}; };
export const setForSettlementPlacement = (game: Game, limits: number[] | undefined, _extra?: any): void => { export const setForSettlementPlacement = (game: Game, limits: number[]): void => {
game.turn.actions = ["place-settlement"]; game.turn.actions = ["place-settlement"];
game.turn.limits = { corners: limits }; game.turn.limits = { corners: limits };
}; };
@ -186,3 +190,363 @@ 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 } from "./types"; import type { Player, PlayerColor } from "./types";
export const newPlayer = (color: string): Player => { export const newPlayer = (color: PlayerColor): Player => {
return { return {
roads: MAX_ROADS, roads: MAX_ROADS,
cities: MAX_CITIES, cities: MAX_CITIES,
@ -24,7 +24,16 @@ export const newPlayer = (color: string): Player => {
turnStart: 0, turnStart: 0,
ports: 0, ports: 0,
developmentCards: 0, developmentCards: 0,
} as Player; orderRoll: 0,
position: "",
orderStatus: "none",
tied: false,
mustDiscard: 0,
live: true,
turnNotice: "",
longestRoad: 0,
banks: [],
};
}; };
export default newPlayer; export default newPlayer;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,184 +1,211 @@
import type { GameState } from './state.js'; import type { Game } from "./types";
import { promises as fsp } from 'fs'; import { promises as fsp } from "fs";
import path from 'path'; import path from "path";
import { transientState } from "./sessionState";
export interface GameDB { interface GameDB {
sequelize?: any; db: any | null;
Sequelize?: any; init(): Promise<void>;
getGameById(id: string | number): Promise<GameState | null>; getGameById(id: string): Promise<Game | null>;
saveGameState(id: string | number, state: GameState): Promise<void>; saveGame(game: Game): Promise<void>;
deleteGame?(id: string | number): Promise<void>; deleteGame?(id: string): Promise<void>;
[k: string]: any;
} }
/** export const gameDB: GameDB = {
* Thin game DB initializer / accessor. db: null,
* This currently returns the underlying db module (for runtime compatibility) init: async () => {
* and is the single place to add typed helper methods for game persistence. let mod: any;
*/
export async function initGameDB(): Promise<GameDB> {
// dynamic import to preserve original runtime ordering
// path is relative to this file (routes/games)
// Prefer synchronous require at runtime when available to avoid TS module resolution
// issues during type-checking. Declare require to keep TypeScript happy.
let mod: any;
try {
// Use runtime require to load the DB module. This runs under Node (ts-node)
// so a direct require is appropriate and avoids relying on globalThis.
// eslint-disable-next-line @typescript-eslint/no-var-requires
mod = require('../../db/games');
} catch (e) {
// DB-only mode: fail fast so callers know persistence is required.
throw new Error('Game DB module could not be loaded: ' + String(e));
}
// If the module uses default export, prefer it
let db: any = (mod && (mod.default || mod));
// If the required module returned a Promise (the db initializer may), await it.
if (db && typeof db.then === 'function') {
try { try {
db = await db; // Use runtime require to load the DB module. This runs under Node (ts-node)
// so a direct require is appropriate and avoids relying on globalThis.
// eslint-disable-next-line @typescript-eslint/no-var-requires
mod = require("../../db/games");
} catch (e) { } catch (e) {
throw new Error('Game DB initializer promise rejected: ' + String(e)); // DB-only mode: fail fast so callers know persistence is required.
throw new Error("Game DB module could not be loaded: " + String(e));
}
// If the module uses default export, prefer it
let db = mod.default || mod;
// If the required module returned a Promise (the db initializer may), await it.
if (db && typeof db.then === "function") {
try {
db = await db;
} catch (e) {
throw new Error("Game DB initializer promise rejected: " + String(e));
}
} }
}
// attach typed helper placeholders (will be implemented incrementally) gameDB.db = db;
if (!db.getGameById) { return db;
db.getGameById = async (id: string | number): Promise<GameState | null> => { },
// fallback: try to query by id using raw SQL if sequelize is available
if (db && db.sequelize) { getGameById: async (id: string | number): Promise<Game | null> => {
try { if (!gameDB.db) {
const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', { await gameDB.init();
replacements: { id }, }
type: db.Sequelize.QueryTypes.SELECT const db = gameDB.db;
}); // fallback: try to query by id using raw SQL if sequelize is available
if (rows && rows.length) { if (db && db.sequelize) {
const r = rows[0] as any; try {
// state may be stored as text or JSON const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
if (typeof r.state === 'string') { replacements: { id },
try { type: db.Sequelize.QueryTypes.SELECT,
return JSON.parse(r.state) as GameState; });
} catch (e) { if (rows && rows.length) {
return null; const r = rows[0];
} // state may be stored as text or JSON
if (typeof r.state === "string") {
try {
return JSON.parse(r.state) as Game;
} catch (e) {
return null;
} }
return r.state as GameState;
} }
} catch (e) { return r.state as Game;
// ignore and fallthrough
} }
} catch (e) {
// ignore and fallthrough
} }
// If DB didn't have a state or query failed, attempt to read from the }
// filesystem copy at db/games/<id> or <id>.json so the state remains editable. // If DB didn't have a state or query failed, attempt to read from the
try { // filesystem copy at db/games/<id> or <id>.json so the state remains editable.
const gamesDir = path.resolve(__dirname, '../../../db/games'); try {
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')]; const gamesDir = path.resolve(__dirname, "../../../db/games");
for (const filePath of candidates) { const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")];
try { for (const filePath of candidates) {
const raw = await fsp.readFile(filePath, 'utf8');
return JSON.parse(raw) as GameState;
} catch (e) {
// try next candidate
}
}
return null;
} catch (err) {
return null;
}
};
}
if (!db.saveGameState) {
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
// Always persist a JSON file so game state is inspectable/editable.
try {
const gamesDir = path.resolve(__dirname, '../../../db/games');
await fsp.mkdir(gamesDir, { recursive: true });
// Write extensionless filename to match existing files
const filePath = path.join(gamesDir, String(id));
const tmpPath = `${filePath}.tmp`;
await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
await fsp.rename(tmpPath, filePath);
} catch (err) {
// Log but continue to attempt DB persistence
// eslint-disable-next-line no-console
console.error('Failed to write game JSON file for', id, err);
}
// Now attempt DB persistence if sequelize is present.
if (db && db.sequelize) {
const payload = JSON.stringify(state);
// Try an UPDATE; if it errors due to missing column, try to add the
// column and retry. If update affects no rows, try INSERT.
try { try {
try { const raw = await fsp.readFile(filePath, "utf8");
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { return JSON.parse(raw) as Game;
replacements: { id, state: payload } } catch (e) {
// try next candidate
}
}
return null;
} catch (err) {
return null;
}
},
saveGame: async (game: Game): Promise<void> => {
if (!gameDB.db) {
await gameDB.init();
}
const db = gameDB.db;
/* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then
* delete the player field from them */
const reducedGame = Object.assign({}, game, { sessions: {} }),
reducedSessions = [];
for (let id in game.sessions) {
const reduced = Object.assign({}, game.sessions[id]);
// Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth)
transientState.stripSessionTransients(reduced);
reducedGame.sessions[id] = reduced;
/* Do not send session-id as those are secrets */
reducedSessions.push(reduced);
}
// Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA)
transientState.stripGameTransients(reducedGame);
/* Save per turn while debugging... */
game.step = game.step ? game.step : 0;
// Always persist a JSON file so game state is inspectable/editable.
try {
const gamesDir = path.resolve(__dirname, "../../../db/games");
await fsp.mkdir(gamesDir, { recursive: true });
// Write extensionless filename to match existing files
const filePath = path.join(gamesDir, reducedGame.id);
const tmpPath = `${filePath}.tmp`;
await fsp.writeFile(tmpPath, JSON.stringify(reducedGame, null, 2), "utf8");
await fsp.rename(tmpPath, filePath);
} catch (err) {
// Log but continue to attempt DB persistence
// eslint-disable-next-line no-console
console.error("Failed to write game JSON file for", reducedGame.id, err);
}
// Now attempt DB persistence if sequelize is present.
if (db && db.sequelize) {
const payload = JSON.stringify(reducedGame);
// Try an UPDATE; if it errors due to missing column, try to add the
// column and retry. If update affects no rows, try INSERT.
try {
try {
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
replacements: { id: reducedGame.id, state: payload },
});
// Some dialects don't return affectedRows consistently; we'll
// still attempt insert if no row exists by checking select.
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
replacements: { id: reducedGame.id },
type: db.Sequelize.QueryTypes.SELECT,
});
if (!check || check.length === 0) {
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
replacements: { id: reducedGame.id, state: payload },
}); });
// Some dialects don't return affectedRows consistently; we'll }
// still attempt insert if no row exists by checking select. } catch (e: any) {
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', { const msg = String(e && e.message ? e.message : e);
replacements: { id }, // If the column doesn't exist (SQLite: no such column: state), add it.
type: db.Sequelize.QueryTypes.SELECT if (
}); /no such column: state/i.test(msg) ||
if (!check || check.length === 0) { /has no column named state/i.test(msg) ||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { /unknown column/i.test(msg)
replacements: { id, state: payload } ) {
try {
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
// retry insert/update after adding column
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
replacements: { id: reducedGame.id, state: payload },
}); });
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
replacements: { id: reducedGame.id },
type: db.Sequelize.QueryTypes.SELECT,
});
if (!check || check.length === 0) {
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
replacements: { id: reducedGame.id, state: payload },
});
}
} catch (inner) {
// swallow; callers should handle missing persistence
} }
} catch (e: any) { } else {
const msg = String(e && e.message ? e.message : e); // For other errors, attempt insert as a fallback
// If the column doesn't exist (SQLite: no such column: state), add it. try {
if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) { await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
try { replacements: { id: reducedGame.id, state: payload },
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT'); });
// retry insert/update after adding column } catch (err) {
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { // swallow; callers should handle missing persistence
replacements: { id, state: payload }
});
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
replacements: { id },
type: db.Sequelize.QueryTypes.SELECT
});
if (!check || check.length === 0) {
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { id, state: payload }
});
}
} catch (inner) {
// swallow; callers should handle missing persistence
}
} else {
// For other errors, attempt insert as a fallback
try {
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { id, state: payload }
});
} catch (err) {
// swallow; callers should handle missing persistence
}
} }
} }
} catch (finalErr) {
// swallow; we don't want persistence errors to crash the server
} }
} catch (finalErr) {
// swallow; we don't want persistence errors to crash the server
} }
}; }
} },
deleteGame: async (id: string | number): Promise<void> => {
if (!db.deleteGame) { if (!gameDB.db) {
db.deleteGame = async (id: string | number): Promise<void> => { await gameDB.init();
if (db && db.sequelize) { }
try { const db = gameDB.db;
await db.sequelize.query('DELETE FROM games WHERE id=:id', { if (db && db.sequelize) {
replacements: { id } try {
}); await db.sequelize.query("DELETE FROM games WHERE id=:id", {
} catch (e) { replacements: { id },
// swallow; callers should handle missing persistence });
} } catch (e) {
// swallow; callers should handle missing persistence
} }
}; }
} },
};
return db as GameDB; export const games: Record<string, Game> = {};
}

View File

@ -8,59 +8,66 @@ export interface TransientGameState {
} }
export interface Player { export interface Player {
name?: string; name: string;
color?: string; color: PlayerColor;
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;
points?: number; army: number;
resources?: number; points: number;
lastActive?: number; ports: number;
live?: boolean; resources: number;
status?: string; lastActive: number;
developmentCards?: number; live: boolean;
development?: DevelopmentCard[]; status: string;
turnNotice?: string; developmentCards: number;
turnStart?: number; development: DevelopmentCard[];
totalTime?: number; turnNotice: string;
[key: string]: any; // allow incremental fields until fully typed turnStart: number;
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?: string; color: PlayerColor;
type?: "settlement" | "city"; type: "settlement" | "city" | "none";
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?: string; color?: PlayerColor;
walking?: boolean; walking?: boolean;
[key: string]: any; type?: "road" | "ship";
longestRoad?: number;
} }
export interface Placements { export interface Placements {
corners: CornerPlacement[]; corners: Array<CornerPlacement | undefined>;
roads: RoadPlacement[]; roads: Array<RoadPlacement | undefined>;
[key: string]: any;
} }
export interface Turn { export interface Turn {
name?: string; name?: string;
color?: string; color: PlayerColor;
actions?: string[]; actions?: string[];
limits?: any; limits?: any;
roll?: number; roll?: number;
@ -71,6 +78,7 @@ export interface Turn {
active?: string; active?: string;
robberInAction?: boolean; robberInAction?: boolean;
placedRobber?: number; placedRobber?: number;
offer?: Offer;
[key: string]: any; [key: string]: any;
} }
@ -81,7 +89,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)
@ -89,7 +97,7 @@ import { TransientSessionState } from './transientSchema';
export interface PersistentSessionData { export interface PersistentSessionData {
id: string; id: string;
name: string; name: string;
color: string; color: PlayerColor;
lastActive: number; lastActive: number;
userId?: number; userId?: number;
player?: Player; player?: Player;
@ -97,6 +105,9 @@ 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
@ -114,6 +125,21 @@ 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[];
@ -127,32 +153,38 @@ 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?: string[]; playerOrder: PlayerColor[];
state?: string; state?: string;
robber?: number; robber?: number;
robberName?: string; robberName?: string;
turns?: number; turns: number;
longestRoad?: string | false; longestRoad?: string | false;
longestRoadLength?: number; longestRoadLength?: number;
borderOrder?: number[]; borderOrder: number[];
largestArmy?: string | false; largestArmy?: string | false;
largestArmySize?: number; largestArmySize?: number;
mostPorts?: string | false; mostPorts: PlayerColor | null;
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[];
[key: string]: any; startTime?: number;
direction?: "forward" | "backward";
winner?: string | false;
history?: any[];
createdAt?: string;
} }
export type GameId = string;
export type IncomingMessage = { type: string | null; data: any }; export type IncomingMessage = { type: string | null; data: any };

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from "express";
import express from 'express'; import express from "express";
import bodyParser from 'body-parser'; import bodyParser from "body-parser";
import config from 'config'; import config from "config";
import basePath from '../basepath'; import basePath from "../basepath";
import cookieParser from 'cookie-parser'; import cookieParser from "cookie-parser";
import http from 'http'; import http from "http";
import expressWs from 'express-ws'; import expressWs from "express-ws";
process.env.TZ = "Etc/GMT"; process.env.TZ = "Etc/GMT";
@ -29,7 +29,7 @@ try {
const debugRouter = require("../routes/debug").default || require("../routes/debug"); const debugRouter = require("../routes/debug").default || require("../routes/debug");
app.use(basePath, debugRouter); app.use(basePath, debugRouter);
} catch (e: any) { } catch (e: any) {
console.error('Failed to mount debug routes (src):', e && e.stack || e); console.error("Failed to mount debug routes (src):", (e && e.stack) || e);
} }
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/", const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
@ -87,34 +87,18 @@ app.use(basePath, index);
*/ */
app.set("port", serverConfig.port); app.set("port", serverConfig.port);
process.on('SIGINT', () => { process.on("SIGINT", () => {
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds"); console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
setTimeout(() => process.exit(-1), 2000); setTimeout(() => process.exit(-1), 2000);
server.close(() => process.exit(1)); server.close(() => process.exit(1));
}); });
// database initializers console.log("Opening server.");
// eslint-disable-next-line @typescript-eslint/no-var-requires server.listen(serverConfig.port, () => {
import { initGameDB } from '../routes/games/store'; console.log(`http/ws server listening on ${serverConfig.port}`);
initGameDB().then(function(_db: any) {
// games DB initialized via store facade
}).then(function() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return Promise.resolve((require("../db/users") as any).default || require("../db/users")).then(function(_db: any) {
// users DB initialized
});
}).then(function() {
console.log("DB connected. Opening server.");
server.listen(serverConfig.port, () => {
console.log(`http/ws server listening on ${serverConfig.port}`);
});
}).catch(function(error: any) {
console.error(error);
process.exit(-1);
}); });
server.on("error", function(error: any) { server.on("error", function (error: any) {
if (error.syscall !== "listen") { if (error.syscall !== "listen") {
throw error; throw error;
} }

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

View File

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

View File

@ -1,73 +1,81 @@
"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
* 7 8 9 10 11 * 7 8 9 10 11
* 12 13 14 15 * 12 13 14 15
* 16 17 18 * 16 17 18
*/ */
/* /*
* c0 * c0
* /\ * /\
* r0 / \r1 * r0 / \r1
* c6 / \ c1 * c6 / \ c1
* | | * | |
* r6| p,a | r2 * r6| p,a | r2
* c5| | c3 * c5| | c3
* \ / * \ /
* 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 Tile = (corners: number[], roads: number[]) => { const newTile = (corners: number[], roads: number[]): Tile => {
return { return {
corners: corners, /* 6 */ robber: false,
index: -1,
type: "desert",
resource: null,
roll: null,
corners: corners /* 6 */,
pip: -1, pip: -1,
roads: roads, roads: roads,
asset: -1 asset: -1,
}; };
}; };
@ -76,44 +84,44 @@ const Tile = (corners: number[], roads: number[]) => {
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: [
Tile([ 0, 1, 2, 10, 9, 8], [ 0, 1, 5, 13, 11, 2]), newTile([0, 1, 2, 10, 9, 8], [0, 1, 5, 13, 11, 2]),
Tile([ 2, 3, 4, 12, 11, 10], [ 3, 4, 8, 16, 14, 5]), newTile([2, 3, 4, 12, 11, 10], [3, 4, 8, 16, 14, 5]),
Tile([ 4, 5, 6, 14, 13, 12], [ 6, 7, 9, 19, 17, 8]), newTile([4, 5, 6, 14, 13, 12], [6, 7, 9, 19, 17, 8]),
Tile([ 7, 8, 9, 19, 18, 17], [ 10, 11, 15, 26, 24, 12]), newTile([7, 8, 9, 19, 18, 17], [10, 11, 15, 26, 24, 12]),
Tile([ 9, 10, 11, 21, 20, 19], [ 13, 14, 18, 29, 27, 15]), newTile([9, 10, 11, 21, 20, 19], [13, 14, 18, 29, 27, 15]),
Tile([ 11, 12, 13, 23, 22, 21], [ 16, 17, 21, 32, 30, 18]), newTile([11, 12, 13, 23, 22, 21], [16, 17, 21, 32, 30, 18]),
Tile([ 13, 14, 15, 25, 24, 23], [ 19, 20, 22, 35, 33, 21]), newTile([13, 14, 15, 25, 24, 23], [19, 20, 22, 35, 33, 21]),
Tile([ 16, 17, 18, 29, 28, 27], [ 23, 24, 28, 40, 39, 25]), newTile([16, 17, 18, 29, 28, 27], [23, 24, 28, 40, 39, 25]),
Tile([ 18, 19, 20, 31, 30, 29], [ 26, 27, 31, 43, 41, 28]), newTile([18, 19, 20, 31, 30, 29], [26, 27, 31, 43, 41, 28]),
Tile([ 20, 21, 22, 33, 32, 31], [ 29, 30, 34, 46, 44, 31]), newTile([20, 21, 22, 33, 32, 31], [29, 30, 34, 46, 44, 31]),
Tile([ 22, 23, 24, 35, 34, 33], [ 32, 33, 37, 49, 47, 34]), newTile([22, 23, 24, 35, 34, 33], [32, 33, 37, 49, 47, 34]),
Tile([ 24, 25, 26, 37, 36, 35], [ 35, 36, 38, 53, 50, 37]), newTile([24, 25, 26, 37, 36, 35], [35, 36, 38, 53, 50, 37]),
Tile([ 28, 29, 30, 40, 39, 38], [ 40, 41, 45, 55, 54, 42]), newTile([28, 29, 30, 40, 39, 38], [40, 41, 45, 55, 54, 42]),
Tile([ 30, 31, 32, 42, 41, 40], [ 43, 44, 48, 58, 56, 45]), newTile([30, 31, 32, 42, 41, 40], [43, 44, 48, 58, 56, 45]),
Tile([ 32, 33, 34, 44, 43, 42], [ 46, 47, 51, 61, 59, 48]), newTile([32, 33, 34, 44, 43, 42], [46, 47, 51, 61, 59, 48]),
Tile([ 34, 35, 36, 46, 45, 44], [ 49, 50, 52, 65, 62, 51]), newTile([34, 35, 36, 46, 45, 44], [49, 50, 52, 65, 62, 51]),
Tile([ 39, 40, 41, 49, 48, 47], [ 55, 56, 60, 67, 66, 57]), newTile([39, 40, 41, 49, 48, 47], [55, 56, 60, 67, 66, 57]),
Tile([ 41, 42, 43, 51, 50, 49], [ 58, 59, 63, 69, 68, 60]), newTile([41, 42, 43, 51, 50, 49], [58, 59, 63, 69, 68, 60]),
Tile([ 43, 44, 45, 53, 52, 51], [ 61, 62, 64, 71, 70, 63]) newTile([43, 44, 45, 53, 52, 51], [61, 62, 64, 71, 70, 63]),
], ],
roads: [ roads: [
/* 0 */ /* 0 */
@ -140,7 +148,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]),
@ -154,113 +162,117 @@ 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 },
@ -281,8 +293,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 },
@ -302,16 +314,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", undefined, "sheep"], ["bank", "none", "sheep"],
[undefined, "bank", undefined], ["none", "bank", "none"],
["bank", undefined, "brick"], ["bank", "none", "brick"],
[undefined, "wood", undefined], ["none", "wood", "none"],
["bank", undefined, "wheat"], ["bank", "none", "wheat"],
[undefined, "stone", undefined] ["none", "stone", "none"],
] ] as ResourceType[][],
}; };
export { export {

View File

@ -1,4 +1,5 @@
import { layout } from './layout'; import { CornerType, Game, PlayerColor } from "../routes/games/types";
import { layout } from "./layout";
const isRuleEnabled = (game: any, rule: string): boolean => { 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;
@ -12,19 +13,33 @@ 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) => {
if (!game.placements || !game.placements.roads || game.placements.roads[roadIndex]?.color) { // Skip if placements or roads missing, or if this road is already occupied
// Treat the explicit sentinel "unassigned" as "not available" so only
// consider a road occupied when a color is present and not "unassigned".
if (
!game.placements ||
!game.placements.roads ||
(game.placements.roads[roadIndex] &&
game.placements.roads[roadIndex].color &&
game.placements.roads[roadIndex].color !== "unassigned")
) {
return; return;
} }
let valid = false; let valid = false;
for (let c = 0; !valid && c < road.corners.length; c++) { for (let c = 0; !valid && c < road.corners.length; c++) {
const cornerIndex = road.corners[c] as number; const cornerIndex = road.corners[c] as number;
if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) { if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) {
continue; continue;
} }
const corner = (layout as any).corners[cornerIndex]; const corner = (layout as any).corners[cornerIndex];
const cornerColor = (game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cornerIndex] && (game as any).placements.corners[cornerIndex].color; const cornerColor =
/* Roads do not pass through other player's settlements */ (game as any).placements &&
if (cornerColor && cornerColor !== color) { (game as any).placements.corners &&
(game as any).placements.corners[cornerIndex] &&
(game as any).placements.corners[cornerIndex].color;
/* Roads do not pass through other player's settlements.
* Consider a corner with color === "unassigned" as empty. */
if (cornerColor && cornerColor !== "unassigned" && cornerColor !== color) {
continue; continue;
} }
for (let r = 0; !valid && r < (corner.roads || []).length; r++) { for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
@ -33,7 +48,9 @@ const getValidRoads = (game: any, color: string): number[] => {
continue; continue;
} }
const rr = corner.roads[r]; const rr = corner.roads[r];
if (rr == null) { continue; } if (rr == null) {
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;
@ -46,89 +63,111 @@ const getValidRoads = (game: any, color: string): number[] => {
}); });
return limits; return limits;
} };
const getValidCorners = (game: any, color: string, type?: string): number[] => { const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: CornerType): number[] => {
const limits: number[] = []; const limits: number[] = [];
console.log("getValidCorners", color, type);
/* For each corner, if the corner already has a color set, skip it if type /* For each corner, if the corner already has a color set, skip it if type
* isn't set. If type is set, if it is a match, and the color is a match, * isn't set. If type is set and is a match, and the color is a match,
* add it to the list. * add it to the list.
* *
* If we are limiting based on active player, a corner is only valid * If we are limiting based on active player, a corner is only valid
* if it connects to a road that is owned by that player. * if it connects to a road that is owned by that player.
* *
* If no color is set, walk each road that leaves that corner and * If no color is set, walk each road that leaves that corner and
* check to see if there is a settlement placed at the end of that road * check to see if there is a settlement placed at the end of that road
* *
* If so, this location cannot have a settlement. * If so, this location cannot have a settlement.
* *
* If still valid, and we are in initial settlement placement, and if * If still valid, and we are in initial settlement placement, and if
* Volcano is enabled, verify the tile is not the Volcano. * Volcano is enabled, verify the tile is not the Volcano.
*/ */
layout.corners.forEach((corner, cornerIndex) => { layout.corners.forEach((corner, cornerIndex) => {
const placement = game.placements.corners[cornerIndex]; const placement = game.placements && game.placements.corners ? game.placements.corners[cornerIndex] : undefined;
if (!placement) {
// Treat a missing placement as unassigned (no owner)
// Continue processing using a falsy placement where appropriate
}
if (type) { if (type) {
if (placement.color === color && placement.type === type) { if (placement && placement.color === color && placement.type === type) {
limits.push(cornerIndex); limits.push(cornerIndex);
} }
return; return;
} }
if (placement.color) { // If the corner has a color set and it's not the explicit sentinel
// "unassigned" then it's occupied and should be skipped.
// Note: placement.color may be undefined (initial state), treat that
// the same as unassigned.
if (placement && placement.color && placement.color !== "unassigned") {
return; return;
} }
let valid; let valid;
if (!color) { // Treat either a falsy color (""/undefined) or the explicit sentinel
// "unassigned" as meaning "do not filter by player".
if (!color || color === "unassigned") {
valid = true; /* Not filtering based on current player */ 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) { continue; } if (rr == null) {
const placementsRoads = (game as any).placements && (game as any).placements.roads; continue;
}
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) { break; } if (!corner.roads) {
const ridx = corner.roads[r] as number; break;
if (ridx == null || (layout as any).roads[ridx] == null) { continue; } }
const road = (layout as any).roads[ridx]; const ridx = corner.roads[r];
for (let c = 0; valid && c < (road.corners || []).length; c++) { if (ridx == null || layout.roads[ridx] == null) {
/* This side of the road is pointing to the corner being validated.
* Skip it. */
if (road.corners[c] === cornerIndex) {
continue; continue;
} }
/* There is a settlement within one segment from this const road = layout.roads[ridx];
* corner, so it is invalid for settlement placement */ for (let c = 0; valid && c < (road.corners || []).length; c++) {
const cc = road.corners[c] as number; /* This side of the road is pointing to the corner being validated.
if ((game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cc] && (game as any).placements.corners[cc].color) { * Skip it. */
valid = false; if (road.corners[c] === cornerIndex) {
continue;
}
/* There is a settlement within one segment from this
* corner, so it is invalid for settlement placement */
const cc = road.corners[c] as number;
const ccPlacement = game.placements && game.placements.corners ? game.placements.corners[cc] : undefined;
const ccColor = ccPlacement ? ccPlacement.color : undefined;
if (ccColor && ccColor !== "unassigned") {
valid = false;
}
} }
} }
}
if (valid) { if (valid) {
/* 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 (!(game.state === 'initial-placement' if (
&& isRuleEnabled(game, 'volcano') !(
&& (layout as any).tiles && (layout as any).tiles[(game as any).robber] && Array.isArray((layout as any).tiles[(game as any).robber].corners) && (layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1 game.state === "initial-placement" &&
)) { isRuleEnabled(game, "volcano") &&
game.robber &&
layout.tiles &&
layout.tiles[game.robber] &&
Array.isArray(layout.tiles[game.robber]?.corners) &&
layout.tiles[game.robber]?.corners.indexOf(cornerIndex as number) !== -1
)
) {
limits.push(cornerIndex); limits.push(cornerIndex);
} }
} }
}); });
return limits; return limits;
} };
export { export { getValidCorners, getValidRoads, isRuleEnabled };
getValidCorners,
getValidRoads,
isRuleEnabled
};