Compare commits
7 Commits
d12d87a796
...
a586f3b491
Author | SHA1 | Date | |
---|---|---|---|
a586f3b491 | |||
579632c293 | |||
b2e5fe4e03 | |||
0818145a81 | |||
e68e49bf82 | |||
570d9024ab | |||
2dae5b7b17 |
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
original/
|
||||
test-output/
|
||||
certs/
|
||||
**/node_modules/
|
||||
|
@ -50,7 +50,10 @@ const App = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setTimeout(() => setError(null), 5000);
|
||||
setTimeout(() => {
|
||||
setError(null);
|
||||
}, 5000);
|
||||
console.error(`app - error`, error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
@ -58,13 +61,13 @@ const App = () => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
console.log(`App - sessionId`, session.id);
|
||||
console.log(`app - sessionId`, session.id);
|
||||
}, [session]);
|
||||
|
||||
const getSession = useCallback(async () => {
|
||||
try {
|
||||
const session = await sessionApi.getCurrent();
|
||||
console.log(`App - got sessionId`, session.id);
|
||||
console.log(`app - got sessionId`, session.id);
|
||||
setSession(session);
|
||||
setSessionRetryAttempt(0);
|
||||
} catch (err) {
|
||||
@ -121,7 +124,19 @@ const App = () => {
|
||||
</Router>
|
||||
)}
|
||||
{error && (
|
||||
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
|
||||
<Paper
|
||||
className="Error"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 32767,
|
||||
p: 2,
|
||||
m: 2,
|
||||
width: "fit-content",
|
||||
backgroundColor: "#ffdddd",
|
||||
}}
|
||||
>
|
||||
<Typography color="red">{error}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
@ -106,7 +106,7 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
||||
const [signature, setSignature] = useState<string>("");
|
||||
const [generated, setGenerated] = useState<string>("");
|
||||
const [robber, setRobber] = useState<number>(-1);
|
||||
const [robberName, setRobberName] = useState<string[]>([]);
|
||||
const [robberName, setRobberName] = useState<string>("");
|
||||
const [pips, setPips] = useState<any>(undefined); // Keep as any for now, complex structure
|
||||
const [pipOrder, setPipOrder] = useState<any>(undefined);
|
||||
const [borders, setBorders] = useState<any>(undefined);
|
||||
@ -147,86 +147,123 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
||||
return;
|
||||
}
|
||||
const data = lastJsonMessage;
|
||||
const handleUpdate = (update: any) => {
|
||||
console.log(`board - game update`, update);
|
||||
if ("robber" in update && update.robber !== robber) {
|
||||
setRobber(update.robber);
|
||||
}
|
||||
|
||||
if ("robberName" in update) {
|
||||
const newName = Array.isArray(update.robberName) ? String(update.robberName[0] || "") : String(update.robberName || "");
|
||||
if (newName !== robberName) setRobberName(newName);
|
||||
}
|
||||
|
||||
if ("state" in update && update.state !== state) {
|
||||
setState(update.state);
|
||||
}
|
||||
|
||||
if ("rules" in update && !equal(update.rules, rules)) {
|
||||
setRules(update.rules);
|
||||
}
|
||||
|
||||
if ("color" in update && update.color !== color) {
|
||||
setColor(update.color);
|
||||
}
|
||||
|
||||
if ("longestRoadLength" in update && update.longestRoadLength !== longestRoadLength) {
|
||||
setLongestRoadLength(update.longestRoadLength);
|
||||
}
|
||||
|
||||
if ("turn" in update) {
|
||||
if (!equal(update.turn, turn)) {
|
||||
console.log(`board - turn`, update.turn);
|
||||
setTurn(update.turn);
|
||||
}
|
||||
}
|
||||
|
||||
if ("placements" in update && !equal(update.placements, placements)) {
|
||||
console.log(`board - placements`, update.placements);
|
||||
setPlacements(update.placements);
|
||||
}
|
||||
|
||||
/* The following are only updated if there is a new game
|
||||
* signature or changed ordering */
|
||||
if ("pipOrder" in update && !equal(update.pipOrder, pipOrder)) {
|
||||
console.log(`board - setting new pipOrder`);
|
||||
setPipOrder(update.pipOrder);
|
||||
}
|
||||
|
||||
if ("borderOrder" in update && !equal(update.borderOrder, borderOrder)) {
|
||||
console.log(`board - setting new borderOrder`);
|
||||
setBorderOrder(update.borderOrder);
|
||||
}
|
||||
|
||||
if ("animationSeeds" in update && !equal(update.animationSeeds, animationSeeds)) {
|
||||
console.log(`board - setting new animationSeeds`);
|
||||
setAnimationSeeds(update.animationSeeds);
|
||||
}
|
||||
|
||||
if ("tileOrder" in update && !equal(update.tileOrder, tileOrder)) {
|
||||
console.log(`board - setting new tileOrder`);
|
||||
setTileOrder(update.tileOrder);
|
||||
}
|
||||
|
||||
if (update.signature !== undefined && update.signature !== signature) {
|
||||
console.log(`board - setting new signature`);
|
||||
setSignature(update.signature);
|
||||
}
|
||||
|
||||
/* Static data from the server (defensive): update when present and different */
|
||||
if ("pips" in update && !equal(update.pips, pips)) {
|
||||
console.log(`board - setting new static pips`);
|
||||
setPips(update.pips);
|
||||
}
|
||||
if ("tiles" in update && !equal(update.tiles, tiles)) {
|
||||
console.log(`board - setting new static tiles`);
|
||||
setTiles(update.tiles);
|
||||
}
|
||||
if ("borders" in update && !equal(update.borders, borders)) {
|
||||
console.log(`board - setting new static borders`);
|
||||
setBorders(update.borders);
|
||||
}
|
||||
};
|
||||
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`board - game update`, data.update);
|
||||
if ("robber" in data.update && data.update.robber !== robber) {
|
||||
setRobber(data.update.robber);
|
||||
}
|
||||
handleUpdate(data.update || {});
|
||||
break;
|
||||
|
||||
if ("robberName" in data.update && data.update.robberName !== robberName) {
|
||||
setRobberName(data.update.robberName);
|
||||
}
|
||||
case "initial-game":
|
||||
// initial snapshot contains the consolidated game in data.snapshot
|
||||
console.log(`board - initial-game snapshot received`);
|
||||
if (data.snapshot) {
|
||||
const snap = data.snapshot;
|
||||
// Normalize snapshot fields to same keys used for incremental updates
|
||||
const initialUpdate: any = {};
|
||||
// Pick expected fields from snapshot
|
||||
[
|
||||
"robber",
|
||||
"robberName",
|
||||
"state",
|
||||
"rules",
|
||||
"color",
|
||||
"longestRoadLength",
|
||||
"turn",
|
||||
"placements",
|
||||
"pipOrder",
|
||||
"borderOrder",
|
||||
"animationSeeds",
|
||||
"tileOrder",
|
||||
"signature",
|
||||
].forEach((k) => {
|
||||
if (k in snap) initialUpdate[k] = snap[k];
|
||||
});
|
||||
// static asset metadata
|
||||
if ("tiles" in snap) initialUpdate.tiles = snap.tiles;
|
||||
if ("pips" in snap) initialUpdate.pips = snap.pips;
|
||||
if ("borders" in snap) initialUpdate.borders = snap.borders;
|
||||
|
||||
if ("state" in data.update && data.update.state !== state) {
|
||||
setState(data.update.state);
|
||||
}
|
||||
|
||||
if ("rules" in data.update && !equal(data.update.rules, rules)) {
|
||||
setRules(data.update.rules);
|
||||
}
|
||||
|
||||
if ("color" in data.update && data.update.color !== color) {
|
||||
setColor(data.update.color);
|
||||
}
|
||||
|
||||
if ("longestRoadLength" in data.update && data.update.longestRoadLength !== longestRoadLength) {
|
||||
setLongestRoadLength(data.update.longestRoadLength);
|
||||
}
|
||||
|
||||
if ("turn" in data.update) {
|
||||
if (!equal(data.update.turn, turn)) {
|
||||
console.log(`board - turn`, data.update.turn);
|
||||
setTurn(data.update.turn);
|
||||
}
|
||||
}
|
||||
|
||||
if ("placements" in data.update && !equal(data.update.placements, placements)) {
|
||||
console.log(`board - placements`, data.update.placements);
|
||||
setPlacements(data.update.placements);
|
||||
}
|
||||
|
||||
/* The following are only updated if there is a new game
|
||||
* signature */
|
||||
|
||||
if ("pipOrder" in data.update && !equal(data.update.pipOrder, pipOrder)) {
|
||||
console.log(`board - setting new pipOrder`);
|
||||
setPipOrder(data.update.pipOrder);
|
||||
}
|
||||
|
||||
if ("borderOrder" in data.update && !equal(data.update.borderOrder, borderOrder)) {
|
||||
console.log(`board - setting new borderOrder`);
|
||||
setBorderOrder(data.update.borderOrder);
|
||||
}
|
||||
|
||||
if ("animationSeeds" in data.update && !equal(data.update.animationSeeds, animationSeeds)) {
|
||||
console.log(`board - setting new animationSeeds`);
|
||||
setAnimationSeeds(data.update.animationSeeds);
|
||||
}
|
||||
|
||||
if ("tileOrder" in data.update && !equal(data.update.tileOrder, tileOrder)) {
|
||||
console.log(`board - setting new tileOrder`);
|
||||
setTileOrder(data.update.tileOrder);
|
||||
}
|
||||
|
||||
if (data.update.signature !== signature) {
|
||||
console.log(`board - setting new signature`);
|
||||
setSignature(data.update.signature);
|
||||
}
|
||||
|
||||
/* This is permanent static data from the server -- do not update
|
||||
* once set */
|
||||
if ("pips" in data.update && !pips) {
|
||||
console.log(`board - setting new static pips`);
|
||||
setPips(data.update.pips);
|
||||
}
|
||||
if ("tiles" in data.update && !tiles) {
|
||||
console.log(`board - setting new static tiles`);
|
||||
setTiles(data.update.tiles);
|
||||
}
|
||||
if ("borders" in data.update && !borders) {
|
||||
console.log(`board - setting new static borders`);
|
||||
setBorders(data.update.borders);
|
||||
handleUpdate(initialUpdate);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@ -508,10 +545,6 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
||||
console.log(`board - Generate pip, border, and tile elements`);
|
||||
const Pip: React.FC<PipProps> = ({ pip, className }) => {
|
||||
const onPipClicked = (pip) => {
|
||||
if (!ws) {
|
||||
console.error(`board - sendPlacement - ws is NULL`);
|
||||
return;
|
||||
}
|
||||
sendJsonMessage({
|
||||
type: "place-robber",
|
||||
index: pip.index,
|
||||
@ -928,7 +961,7 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
||||
const el = document.querySelector(`.Pip[data-index="${robber}"]`);
|
||||
if (el) {
|
||||
el.classList.add("Robber");
|
||||
el.classList.add(robberName);
|
||||
if (robberName) el.classList.add(robberName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -351,10 +351,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
||||
<Placard
|
||||
sx={{
|
||||
float: "left",
|
||||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
||||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
||||
marginRight: "1rem",
|
||||
marginBottom: "1rem",
|
||||
marginRight: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
type="most-developed"
|
||||
/>
|
||||
@ -364,6 +362,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
||||
fields. Picture yourself snagging this beautifully illustrated card—featuring hardworking villagers and a
|
||||
majestic castle!
|
||||
</Typography>
|
||||
<Box sx={{ clear: "both" }}></Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
@ -378,10 +377,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
||||
<Placard
|
||||
sx={{
|
||||
float: "left",
|
||||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
||||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
||||
marginRight: "1rem",
|
||||
marginBottom: "1rem",
|
||||
marginRight: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
type="port-of-call"
|
||||
/>
|
||||
@ -392,6 +389,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
||||
moment someone else builds a larger network of harbors, they’ll steal both the card and the glory right
|
||||
from under your nose. Keep those ships moving and never let your rivals toast to your downfall!
|
||||
</Typography>
|
||||
<Box sx={{ clear: "both" }}></Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
@ -406,10 +404,8 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
||||
<Placard
|
||||
sx={{
|
||||
float: "left",
|
||||
shapeOutside: "inset(0)" /* Text wraps the full rectangle */,
|
||||
clipPath: "inset(0)" /* Ensures proper wrapping area */,
|
||||
marginRight: "1rem",
|
||||
marginBottom: "1rem",
|
||||
marginRight: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
type="longest-turn"
|
||||
/>
|
||||
@ -418,6 +414,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRules
|
||||
handed this charming card—featuring industrious villagers raking hay with a castle looming in the
|
||||
background—until someone even slower takes it from you with a sheepish grin!
|
||||
</Typography>
|
||||
<Box sx={{ clear: "both" }}></Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
@ -42,8 +42,8 @@
|
||||
left: 0; /* Start at left of container */
|
||||
width: 5rem;
|
||||
height: 3.75rem;
|
||||
min-width: 1.25rem;
|
||||
min-height: 0.9375rem;
|
||||
min-width: 3.5rem;
|
||||
min-height: 1.8725rem;
|
||||
z-index: 1200;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@ -68,30 +68,81 @@
|
||||
}
|
||||
|
||||
.MediaControl .Controls {
|
||||
display: flex;
|
||||
display: none; /* Hidden by default, shown on hover */
|
||||
position: absolute;
|
||||
gap: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
flex-direction: column;
|
||||
z-index: 1;
|
||||
align-items: flex-start;
|
||||
justify-content: center
|
||||
right: 0;
|
||||
min-width: fit-content;
|
||||
min-height: fit-content;
|
||||
z-index: 1251; /* Above the Indicators but below active move handles */
|
||||
background-color: rgba(64, 64, 64, 64);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.MediaControl.Small .Controls {
|
||||
.MediaControl:hover .Controls {
|
||||
display: flex; /* Show controls on hover */
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Indicators: visual, non-interactive icons anchored lower-left of the container.
|
||||
They are independent from the interactive Controls and scale responsively. */
|
||||
.Indicators {
|
||||
position: absolute;
|
||||
z-index: 1250; /* Above the video but below active move handles */
|
||||
/* Use percentage offsets so the indicators scale and stick to lower-left of the target */
|
||||
left: 4%;
|
||||
bottom: 4%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: none; /* non-interactive */
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.Indicators .IndicatorRow {
|
||||
display: flex;
|
||||
gap: 0.12rem;
|
||||
align-items: center;
|
||||
}
|
||||
.Indicators .IndicatorItem {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
padding: 0.12rem;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.Indicators .IndicatorItem svg {
|
||||
/* make svg scale relative to parent (which is sized by the Moveable target) */
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
}
|
||||
|
||||
/* Make indicator items size proportionally to the target using relative units */
|
||||
.Indicators .IndicatorItem {
|
||||
padding: 0.35rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Reduce absolute pixel values that may prevent scaling; use clamp for min/max */
|
||||
.Indicators .IndicatorItem svg {
|
||||
width: clamp(0.6rem, 6%, 1.2rem);
|
||||
height: clamp(0.6rem, 6%, 1.2rem);
|
||||
}
|
||||
|
||||
/* Ensure interactive Controls are reachable even when target is small: allow Controls to overflow
|
||||
the moveable target visually (they are positioned absolute inside the target). */
|
||||
.MediaControl .Controls {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.MediaControl .Controls > div {
|
||||
border-radius: 0.25em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.MediaControl .Controls > div:hover {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.moveable-control-box {
|
||||
border: none;
|
||||
--moveable-color: unset !important;
|
||||
|
@ -14,6 +14,7 @@ import { useContext } from "react";
|
||||
import WebRTCStatus from "./WebRTCStatus";
|
||||
import Moveable from "react-moveable";
|
||||
import { flushSync } from "react-dom";
|
||||
import { SxProps, Theme } from "@mui/material";
|
||||
|
||||
const debug = true;
|
||||
// When true, do not send host candidates to the signaling server. Keeps TURN relays preferred.
|
||||
@ -1304,6 +1305,7 @@ interface MediaControlProps {
|
||||
sendJsonMessage?: (msg: any) => void;
|
||||
remoteAudioMuted?: boolean;
|
||||
remoteVideoOff?: boolean;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const MediaControl: React.FC<MediaControlProps> = ({
|
||||
@ -1313,6 +1315,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
sendJsonMessage,
|
||||
remoteAudioMuted,
|
||||
remoteVideoOff,
|
||||
sx,
|
||||
}) => {
|
||||
const [muted, setMuted] = useState<boolean>(peer?.muted || false);
|
||||
const [videoOn, setVideoOn] = useState<boolean>(peer?.video_on !== false);
|
||||
@ -1333,8 +1336,13 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const targetRef = useRef<HTMLDivElement>(null);
|
||||
const spacerRef = useRef<HTMLDivElement>(null);
|
||||
const controlsRef = useRef<HTMLDivElement>(null);
|
||||
const indicatorsRef = useRef<HTMLDivElement>(null);
|
||||
const moveableRef = useRef<any>(null);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
// Controls expansion state for hover/tap compact mode
|
||||
const [controlsExpanded, setControlsExpanded] = useState<boolean>(false);
|
||||
const touchCollapseTimeoutRef = useRef<number | null>(null);
|
||||
// Get sendJsonMessage from props
|
||||
// Reset drag state on pointerup/touchend/mouseup anywhere in the document
|
||||
useEffect(() => {
|
||||
@ -1513,22 +1521,6 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]);
|
||||
|
||||
// Debug target element
|
||||
useEffect(() => {
|
||||
console.log("Target ref current:", targetRef.current, "for peer:", peer?.session_id);
|
||||
if (targetRef.current) {
|
||||
console.log("Target element rect:", targetRef.current.getBoundingClientRect());
|
||||
console.log("Target element computed style:", {
|
||||
position: getComputedStyle(targetRef.current).position,
|
||||
left: getComputedStyle(targetRef.current).left,
|
||||
top: getComputedStyle(targetRef.current).top,
|
||||
transform: getComputedStyle(targetRef.current).transform,
|
||||
width: getComputedStyle(targetRef.current).width,
|
||||
height: getComputedStyle(targetRef.current).height,
|
||||
});
|
||||
}
|
||||
}, [peer?.session_id]);
|
||||
|
||||
const toggleMute = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.stopPropagation();
|
||||
@ -1602,6 +1594,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
alignItems: "center",
|
||||
minWidth: "200px",
|
||||
minHeight: "100px",
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -1644,7 +1637,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
{/* Moveable element - positioned absolute relative to container */}
|
||||
<div
|
||||
ref={targetRef}
|
||||
className={`MediaControl ${className}`}
|
||||
className={`MediaControl ${className} ${controlsExpanded ? "Expanded" : "Small"}`}
|
||||
data-peer={peer.session_id}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
style={{
|
||||
@ -1655,8 +1648,67 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
height: frame.height ? `${frame.height}px` : undefined,
|
||||
transform: `translate(${frame.translate[0]}px, ${frame.translate[1]}px)`,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
// Expand controls for mouse
|
||||
setControlsExpanded(true);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
// Collapse when leaving with mouse, but keep expanded if the pointer
|
||||
// moved into the interactive controls or indicators (which are rendered
|
||||
// outside the target box to avoid disappearing when target is small).
|
||||
const related = (e as React.MouseEvent).relatedTarget as Node | null;
|
||||
try {
|
||||
if (related && controlsRef.current && controlsRef.current.contains(related)) {
|
||||
// Pointer moved into the controls; do not collapse
|
||||
return;
|
||||
}
|
||||
if (related && indicatorsRef.current && indicatorsRef.current.contains(related)) {
|
||||
// Pointer moved into the indicators; keep expanded
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// In some browsers relatedTarget may be null or inaccessible; fall back to collapsing
|
||||
}
|
||||
setControlsExpanded(false);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
// Expand on touch; stop propagation so Moveable doesn't interpret as drag start
|
||||
setControlsExpanded(true);
|
||||
// Prevent immediate drag when the user intends to tap the controls
|
||||
e.stopPropagation();
|
||||
// Start a collapse timeout for touch devices
|
||||
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
|
||||
touchCollapseTimeoutRef.current = window.setTimeout(() => setControlsExpanded(false), 4000);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Keep controls expanded while user interacts inside
|
||||
setControlsExpanded(true);
|
||||
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
|
||||
}}
|
||||
>
|
||||
<Box className="Controls">
|
||||
{/* Visual indicators: placed inside a clipped container that matches the
|
||||
Moveable target size so indicators scale with and are clipped by the target. */}
|
||||
<Box
|
||||
className="Indicators"
|
||||
sx={{ display: "flex", flexDirection: "row", color: "grey", pointerEvents: "none" }}
|
||||
ref={indicatorsRef}
|
||||
>
|
||||
{isSelf ? (
|
||||
<>
|
||||
{muted ? <MicOff sx={{ height: "100%" }} /> : <Mic />}
|
||||
{videoOn ? <Videocam /> : <VideocamOff />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
|
||||
{remoteAudioMuted && <MicOff />}
|
||||
{videoOn ? <Videocam /> : <VideocamOff />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Interactive controls: rendered inside target but referenced separately */}
|
||||
<Box className="Controls" ref={controlsRef}>
|
||||
{isSelf ? (
|
||||
<IconButton onClick={toggleMute}>
|
||||
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}
|
||||
|
@ -64,6 +64,20 @@ const PlayerList: React.FC = () => {
|
||||
[session]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!players) {
|
||||
return;
|
||||
}
|
||||
players.forEach((player) => {
|
||||
console.log("rabbit - player:", {
|
||||
name: player.name,
|
||||
live: player.live,
|
||||
in_peers: peers[player.session_id],
|
||||
local_or_media: player.local || player.has_media !== false,
|
||||
});
|
||||
});
|
||||
}, [players]);
|
||||
|
||||
// Use the WebSocket hook for room events with automatic reconnection
|
||||
useEffect(() => {
|
||||
if (!lastJsonMessage) {
|
||||
@ -121,7 +135,7 @@ const PlayerList: React.FC = () => {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`player-list - ignoring message: ${data.type}`);
|
||||
// console.log(`player-list - ignoring message: ${data.type}`);
|
||||
break;
|
||||
}
|
||||
}, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]);
|
||||
@ -149,96 +163,100 @@ const PlayerList: React.FC = () => {
|
||||
>
|
||||
<MediaAgent {...{ session, peers, setPeers }} />
|
||||
<List className="PlayerSelector">
|
||||
{players?.map((player) => (
|
||||
<Box
|
||||
key={player.session_id}
|
||||
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
|
||||
className={`PlayerEntry ${player.local ? "PlayerSelf" : ""}`}
|
||||
>
|
||||
<Box>
|
||||
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Box style={{ display: "flex-wrap", alignItems: "center" }}>
|
||||
<div className="Name">{player.name ? player.name : player.session_id}</div>
|
||||
{player.protected && (
|
||||
<div
|
||||
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
|
||||
title="This name is protected with a password"
|
||||
>
|
||||
🔒
|
||||
</div>
|
||||
)}
|
||||
{player.bot_instance_id && (
|
||||
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
|
||||
🤖
|
||||
</div>
|
||||
)}
|
||||
{players?.map((player) => {
|
||||
const peerObj = peers[player.session_id] || peers[player.name];
|
||||
return (
|
||||
<Box
|
||||
key={player.session_id}
|
||||
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
|
||||
className={`PlayerEntry ${player.local ? "PlayerSelf" : ""}`}
|
||||
>
|
||||
<Box>
|
||||
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Box style={{ display: "flex-wrap", alignItems: "center" }}>
|
||||
<div className="Name">{player.name ? player.name : player.session_id}</div>
|
||||
{player.protected && (
|
||||
<div
|
||||
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
|
||||
title="This name is protected with a password"
|
||||
>
|
||||
🔒
|
||||
</div>
|
||||
)}
|
||||
{player.bot_instance_id && (
|
||||
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
|
||||
🤖
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{player.name && !player.live && <div className="NoNetwork"></div>}
|
||||
</Box>
|
||||
{player.name && !player.live && <div className="NoNetwork"></div>}
|
||||
</Box>
|
||||
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? (
|
||||
<>
|
||||
<MediaControl
|
||||
className="Medium"
|
||||
key={player.session_id}
|
||||
peer={peers[player.session_id]}
|
||||
isSelf={player.local}
|
||||
sendJsonMessage={player.local ? sendJsonMessage : undefined}
|
||||
remoteAudioMuted={peers[player.session_id].muted}
|
||||
remoteVideoOff={peers[player.session_id].video_on === false}
|
||||
/>
|
||||
{player.name && player.live && peerObj && (player.local || player.has_media !== false) ? (
|
||||
<>
|
||||
<MediaControl
|
||||
sx={{ border: "3px solid blue" }}
|
||||
className="Medium"
|
||||
key={player.session_id}
|
||||
peer={peerObj}
|
||||
isSelf={player.local}
|
||||
sendJsonMessage={player.local ? sendJsonMessage : undefined}
|
||||
remoteAudioMuted={peerObj?.muted}
|
||||
remoteVideoOff={peerObj?.video_on === false}
|
||||
/>
|
||||
|
||||
{/* If this is the local player and they haven't picked a color, show a picker */}
|
||||
{player.local && !player.color && (
|
||||
<div style={{ marginTop: 8, width: "100%" }}>
|
||||
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{["orange", "red", "white", "blue"].map((c) => (
|
||||
<Box
|
||||
key={c}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #ccc",
|
||||
background: "#fff",
|
||||
cursor: sendJsonMessage ? "pointer" : "not-allowed",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!sendJsonMessage) return;
|
||||
sendJsonMessage({ type: "set", field: "color", value: c[0].toUpperCase() });
|
||||
}}
|
||||
>
|
||||
<PlayerColor color={c} />
|
||||
</Box>
|
||||
))}
|
||||
{/* If this is the local player and they haven't picked a color, show a picker */}
|
||||
{player.local && player.color === "unassigned" && (
|
||||
<div style={{ marginTop: 8, width: "100%" }}>
|
||||
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{["orange", "red", "white", "blue"].map((c) => (
|
||||
<Box
|
||||
key={c}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #ccc",
|
||||
background: "#fff",
|
||||
cursor: sendJsonMessage ? "pointer" : "not-allowed",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!sendJsonMessage) return;
|
||||
sendJsonMessage({ type: "set", field: "color", value: c[0].toUpperCase() });
|
||||
}}
|
||||
>
|
||||
<PlayerColor color={c} />
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : player.name && player.live && player.has_media === false ? (
|
||||
<div
|
||||
className="Video fade-in"
|
||||
style={{
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
💬 Chat Only
|
||||
</div>
|
||||
) : (
|
||||
<video className="Video"></video>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
) : player.name && player.live && player.has_media === false ? (
|
||||
<div
|
||||
className="Video fade-in"
|
||||
style={{
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
💬 Chat Only
|
||||
</div>
|
||||
) : (
|
||||
<video className="Video"></video>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
@ -48,14 +48,12 @@ const audioEffects: Record<string, AudioEffect | undefined> = {};
|
||||
const loadAudio = (src: string) => {
|
||||
const audio = document.createElement("audio") as AudioEffect;
|
||||
audio.src = audioFiles[src];
|
||||
console.log("Loading audio:", audio.src);
|
||||
audio.setAttribute("preload", "auto");
|
||||
audio.setAttribute("controls", "none");
|
||||
audio.style.display = "none";
|
||||
document.body.appendChild(audio);
|
||||
audio.load();
|
||||
audio.addEventListener("error", (e) => console.error("Audio load error:", e, audio.src));
|
||||
audio.addEventListener("canplay", () => console.log("Audio can play:", audio.src));
|
||||
return audio;
|
||||
};
|
||||
|
||||
@ -149,15 +147,15 @@ const RoomView = (props: RoomProps) => {
|
||||
switch (data.type) {
|
||||
case "ping":
|
||||
// Respond to server ping immediately to maintain connection
|
||||
console.log("App - Received ping from server, sending pong");
|
||||
console.log("room-view - Received ping from server, sending pong");
|
||||
sendJsonMessage({ type: "pong" });
|
||||
break;
|
||||
case "error":
|
||||
console.error(`App - error`, data.error);
|
||||
setError(data.error);
|
||||
console.error(`room-view - error`, data.error);
|
||||
setError(data.data.error || JSON.stringify(data));
|
||||
break;
|
||||
case "warning":
|
||||
console.warn(`App - warning`, data.warning);
|
||||
console.warn(`room-view - warning`, data.warning);
|
||||
setWarning(data.warning);
|
||||
setTimeout(() => {
|
||||
setWarning("");
|
||||
@ -223,7 +221,7 @@ const RoomView = (props: RoomProps) => {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [lastJsonMessage, session, setError, setSession]);
|
||||
}, [lastJsonMessage, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state === "volcano") {
|
||||
|
Before Width: | Height: | Size: 107 KiB |
Before Width: | Height: | Size: 323 KiB |
Before Width: | Height: | Size: 1.0 MiB |
Before Width: | Height: | Size: 305 KiB |
Before Width: | Height: | Size: 933 KiB |
Before Width: | Height: | Size: 339 KiB |
Before Width: | Height: | Size: 998 KiB |
Before Width: | Height: | Size: 316 KiB |
Before Width: | Height: | Size: 952 KiB |
Before Width: | Height: | Size: 306 KiB |
Before Width: | Height: | Size: 945 KiB |
Before Width: | Height: | Size: 338 KiB |
Before Width: | Height: | Size: 921 KiB |
Before Width: | Height: | Size: 412 KiB |
Before Width: | Height: | Size: 432 KiB |
Before Width: | Height: | Size: 442 KiB |
Before Width: | Height: | Size: 444 KiB |
Before Width: | Height: | Size: 433 KiB |
Before Width: | Height: | Size: 442 KiB |
Before Width: | Height: | Size: 434 KiB |
Before Width: | Height: | Size: 434 KiB |
Before Width: | Height: | Size: 428 KiB |
Before Width: | Height: | Size: 428 KiB |
Before Width: | Height: | Size: 436 KiB |
Before Width: | Height: | Size: 458 KiB |
Before Width: | Height: | Size: 458 KiB |
Before Width: | Height: | Size: 448 KiB |
Before Width: | Height: | Size: 438 KiB |
Before Width: | Height: | Size: 477 KiB |
Before Width: | Height: | Size: 484 KiB |
Before Width: | Height: | Size: 488 KiB |
Before Width: | Height: | Size: 446 KiB |
Before Width: | Height: | Size: 423 KiB |
Before Width: | Height: | Size: 468 KiB |
Before Width: | Height: | Size: 433 KiB |
Before Width: | Height: | Size: 462 KiB |
Before Width: | Height: | Size: 466 KiB |
Before Width: | Height: | Size: 452 KiB |
Before Width: | Height: | Size: 435 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 123 KiB |
Before Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 920 KiB |
Before Width: | Height: | Size: 983 KiB |
Before Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 1.0 MiB |
Before Width: | Height: | Size: 1014 KiB |
Before Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 453 KiB |
Before Width: | Height: | Size: 1.9 MiB |
Before Width: | Height: | Size: 1.8 MiB |
Before Width: | Height: | Size: 2.5 MiB |
Before Width: | Height: | Size: 2.3 MiB |
Before Width: | Height: | Size: 6.3 MiB |
Before Width: | Height: | Size: 2.2 MiB |
Before Width: | Height: | Size: 3.4 MiB |
Before Width: | Height: | Size: 1.0 MiB |
Before Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 14 MiB |
@ -300,7 +300,7 @@ const bestRoadPlacement = (game) => {
|
||||
return;
|
||||
}
|
||||
const placedRoad = game.placements.roads[roadIndex];
|
||||
if (placedRoad.color) {
|
||||
if (!placedRoad || placedRoad.color) {
|
||||
return;
|
||||
}
|
||||
attempt = roadIndex;
|
||||
|
@ -16,6 +16,9 @@ const processCorner = (game: any, color: string, cornerIndex: number, placedCorn
|
||||
let longest = 0;
|
||||
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
|
||||
const placedRoad = game.placements.roads[roadIndex];
|
||||
if (!placedRoad) {
|
||||
return;
|
||||
}
|
||||
if (placedRoad.walking) {
|
||||
return;
|
||||
}
|
||||
@ -42,11 +45,12 @@ const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedC
|
||||
if (placedCorner.walking) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
placedCorner.walking = true;
|
||||
/* Calculate the longest road branching from both corners */
|
||||
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
|
||||
const placedRoad = game.placements.roads[roadIndex];
|
||||
if (!placedRoad) return;
|
||||
buildRoadGraph(game, color, roadIndex, placedRoad, set);
|
||||
});
|
||||
};
|
||||
@ -54,7 +58,7 @@ const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedC
|
||||
const processRoad = (game: any, color: string, roadIndex: number, placedRoad: any): number => {
|
||||
/* If this road isn't assigned to the walking color, skip it */
|
||||
if (placedRoad.color !== color) {
|
||||
return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* If this road is already being walked, skip it */
|
||||
@ -65,8 +69,9 @@ const processRoad = (game: any, color: string, roadIndex: number, placedRoad: an
|
||||
placedRoad.walking = true;
|
||||
/* Calculate the longest road branching from both corners */
|
||||
let roadLength = 1;
|
||||
layout.roads[roadIndex].corners.forEach(cornerIndex => {
|
||||
layout.roads[roadIndex].corners.forEach((cornerIndex) => {
|
||||
const placedCorner = game.placements.corners[cornerIndex];
|
||||
if (!placedCorner) return;
|
||||
if (placedCorner.walking) {
|
||||
return;
|
||||
}
|
||||
@ -89,21 +94,26 @@ const buildRoadGraph = (game: any, color: string, roadIndex: number, placedRoad:
|
||||
placedRoad.walking = true;
|
||||
set.push(roadIndex);
|
||||
/* Calculate the longest road branching from both corners */
|
||||
layout.roads[roadIndex].corners.forEach(cornerIndex => {
|
||||
layout.roads[roadIndex].corners.forEach((cornerIndex) => {
|
||||
const placedCorner = game.placements.corners[cornerIndex];
|
||||
buildCornerGraph(game, color, cornerIndex, placedCorner, set)
|
||||
if (!placedCorner) return;
|
||||
buildCornerGraph(game, color, cornerIndex, placedCorner, set);
|
||||
});
|
||||
};
|
||||
|
||||
const clearRoadWalking = (game: any) => {
|
||||
/* Clear out walk markers on roads */
|
||||
layout.roads.forEach((item, itemIndex) => {
|
||||
delete game.placements.roads[itemIndex].walking;
|
||||
if (game.placements && game.placements.roads && game.placements.roads[itemIndex]) {
|
||||
delete game.placements.roads[itemIndex].walking;
|
||||
}
|
||||
});
|
||||
|
||||
/* Clear out walk markers on corners */
|
||||
layout.corners.forEach((item, itemIndex) => {
|
||||
delete game.placements.corners[itemIndex].walking;
|
||||
if (game.placements && game.placements.corners && game.placements.corners[itemIndex]) {
|
||||
delete game.placements.corners[itemIndex].walking;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -122,6 +132,7 @@ const calculateRoadLengths = (game: any) => {
|
||||
let graphs = [];
|
||||
layout.roads.forEach((_, roadIndex) => {
|
||||
const placedRoad = game.placements.roads[roadIndex];
|
||||
if (!placedRoad) return;
|
||||
if (placedRoad.color === color) {
|
||||
let set = [];
|
||||
buildRoadGraph(game, color, roadIndex, placedRoad, set);
|
||||
@ -133,13 +144,13 @@ const calculateRoadLengths = (game: any) => {
|
||||
|
||||
let final = {
|
||||
segments: 0,
|
||||
index: -1
|
||||
index: -1,
|
||||
};
|
||||
|
||||
clearRoadWalking(game);
|
||||
graphs.forEach(graph => {
|
||||
graphs.forEach((graph) => {
|
||||
graph.longestRoad = 0;
|
||||
graph.set.forEach(roadIndex => {
|
||||
graph.set.forEach((roadIndex) => {
|
||||
const placedRoad = game.placements.roads[roadIndex];
|
||||
clearRoadWalking(game);
|
||||
const length = processRoad(game, color, roadIndex, placedRoad);
|
||||
@ -154,7 +165,9 @@ const calculateRoadLengths = (game: any) => {
|
||||
});
|
||||
});
|
||||
|
||||
game.placements.roads.forEach(road => delete road.walking);
|
||||
game.placements.roads.forEach((road: any) => {
|
||||
if (road) delete road.walking;
|
||||
});
|
||||
|
||||
return final;
|
||||
};
|
||||
|
306
server/routes/games/gameFactory.ts
Normal 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;
|
||||
};
|
@ -1,5 +1,8 @@
|
||||
import type { Game, Session, Player } from "./types";
|
||||
import { type Game, type Session, type Player, type PlayerColor, RESOURCE_TYPES } from "./types";
|
||||
import { newPlayer } from "./playerFactory";
|
||||
import { debug, info, MAX_CITIES, MAX_SETTLEMENTS, SEND_THROTTLE_MS } from "./constants";
|
||||
import { shuffleBoard } from "./gameFactory";
|
||||
import { getVictoryPointRule } from "./rules";
|
||||
|
||||
export const addActivity = (game: Game, session: Session | null, message: string): void => {
|
||||
let date = Date.now();
|
||||
@ -7,7 +10,8 @@ export const addActivity = (game: Game, session: Session | null, message: string
|
||||
if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
|
||||
date++;
|
||||
}
|
||||
game.activities.push({ color: session ? session.color : "", message, date });
|
||||
const actColor = session && session.color && session.color !== "unassigned" ? session.color : "";
|
||||
game.activities.push({ color: actColor, message, date });
|
||||
if (game.activities.length > 30) {
|
||||
game.activities.splice(0, game.activities.length - 30);
|
||||
}
|
||||
@ -34,7 +38,7 @@ export const addChatMessage = (game: Game, session: Session | null, message: str
|
||||
if (session && session.name) {
|
||||
entry.from = session.name;
|
||||
}
|
||||
if (session && session.color) {
|
||||
if (session && session.color && session.color !== "unassigned") {
|
||||
entry.color = session.color;
|
||||
}
|
||||
game.chat.push(entry);
|
||||
@ -47,7 +51,7 @@ export const getColorFromName = (game: Game, name: string): string => {
|
||||
for (let id in game.sessions) {
|
||||
const s = game.sessions[id];
|
||||
if (s && s.name === name) {
|
||||
return s.color || "";
|
||||
return s.color && s.color !== "unassigned" ? s.color : "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
@ -79,7 +83,7 @@ export const getFirstPlayerName = (game: Game): string => {
|
||||
};
|
||||
|
||||
export const getNextPlayerSession = (game: Game, name: string): Session | undefined => {
|
||||
let color: string | undefined;
|
||||
let color: PlayerColor | undefined;
|
||||
for (let id in game.sessions) {
|
||||
const s = game.sessions[id];
|
||||
if (s && s.name === name) {
|
||||
@ -106,7 +110,7 @@ export const getNextPlayerSession = (game: Game, name: string): Session | undefi
|
||||
};
|
||||
|
||||
export const getPrevPlayerSession = (game: Game, name: string): Session | undefined => {
|
||||
let color: string | undefined;
|
||||
let color: PlayerColor | undefined;
|
||||
for (let id in game.sessions) {
|
||||
const s = game.sessions[id];
|
||||
if (s && s.name === name) {
|
||||
@ -163,7 +167,7 @@ export const setForCityPlacement = (game: Game, limits: any): void => {
|
||||
game.turn.limits = { corners: limits };
|
||||
};
|
||||
|
||||
export const setForSettlementPlacement = (game: Game, limits: number[] | undefined, _extra?: any): void => {
|
||||
export const setForSettlementPlacement = (game: Game, limits: number[]): void => {
|
||||
game.turn.actions = ["place-settlement"];
|
||||
game.turn.limits = { corners: limits };
|
||||
};
|
||||
@ -186,3 +190,363 @@ export const adjustResources = (player: Player, deltas: Partial<Record<string, n
|
||||
});
|
||||
player.resources = total;
|
||||
};
|
||||
|
||||
export const startTurnTimer = (game: Game, session: Session) => {
|
||||
const timeout = 90;
|
||||
if (!session.ws) {
|
||||
console.log(`${session.short}: Aborting turn timer as ${session.name} is disconnected.`);
|
||||
} else {
|
||||
console.log(`${session.short}: (Re)setting turn timer for ${session.name} to ${timeout} seconds.`);
|
||||
}
|
||||
if (game.turnTimer) {
|
||||
clearTimeout(game.turnTimer);
|
||||
}
|
||||
if (!session.connected) {
|
||||
game.turnTimer = 0;
|
||||
return;
|
||||
}
|
||||
game.turnTimer = setTimeout(() => {
|
||||
console.log(`${session.short}: Turn timer expired for ${session.name}`);
|
||||
if (session.player) {
|
||||
session.player.turnNotice = "It is still your turn.";
|
||||
}
|
||||
sendUpdateToPlayer(game, session, {
|
||||
private: session.player,
|
||||
});
|
||||
resetTurnTimer(game, session);
|
||||
}, timeout * 1000);
|
||||
};
|
||||
|
||||
const calculatePoints = (game: any, update: any): void => {
|
||||
if (game.state === "winner") {
|
||||
return;
|
||||
}
|
||||
/* Calculate points and determine if there is a winner */
|
||||
for (let key in game.players) {
|
||||
const player = game.players[key];
|
||||
if (player.status === "Not active") {
|
||||
continue;
|
||||
}
|
||||
const currentPoints = player.points;
|
||||
|
||||
player.points = 0;
|
||||
if (key === game.longestRoad) {
|
||||
player.points += 2;
|
||||
}
|
||||
if (key === game.largestArmy) {
|
||||
player.points += 2;
|
||||
}
|
||||
if (key === game.mostPorts) {
|
||||
player.points += 2;
|
||||
}
|
||||
if (key === game.mostDeveloped) {
|
||||
player.points += 2;
|
||||
}
|
||||
player.points += MAX_SETTLEMENTS - player.settlements;
|
||||
player.points += 2 * (MAX_CITIES - player.cities);
|
||||
|
||||
player.unplayed = 0;
|
||||
player.potential = 0;
|
||||
player.development.forEach((card: any) => {
|
||||
if (card.type === "vp") {
|
||||
if (card.played) {
|
||||
player.points++;
|
||||
} else {
|
||||
player.potential++;
|
||||
}
|
||||
}
|
||||
if (!card.played) {
|
||||
player.unplayed++;
|
||||
}
|
||||
});
|
||||
|
||||
if (player.points === currentPoints) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (player.points < getVictoryPointRule(game)) {
|
||||
update.players = getFilteredPlayers(game);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* This player has enough points! Check if they are the current
|
||||
* player and if so, declare victory! */
|
||||
console.log(`${info}: Whoa! ${player.name} has ${player.points}!`);
|
||||
for (let key in game.sessions) {
|
||||
if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") {
|
||||
continue;
|
||||
}
|
||||
const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`;
|
||||
addChatMessage(game, null, message);
|
||||
console.log(`${info}: ${message}`);
|
||||
update.winner = Object.assign({}, player, {
|
||||
state: "winner",
|
||||
stolen: game.stolen,
|
||||
chat: game.chat,
|
||||
turns: game.turns,
|
||||
players: game.players,
|
||||
elapsedTime: Date.now() - game.startTime,
|
||||
});
|
||||
game.winner = update.winner;
|
||||
game.state = "winner";
|
||||
game.waiting = [];
|
||||
stopTurnTimer(game);
|
||||
sendUpdateToPlayers(game, {
|
||||
state: game.state,
|
||||
winner: game.winner,
|
||||
players: game.players /* unfiltered */,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* If the game isn't in a win state, do not share development card information
|
||||
* with other players */
|
||||
if (game.state !== "winner") {
|
||||
for (let key in game.players) {
|
||||
const player = game.players[key];
|
||||
if (player.status === "Not active") {
|
||||
continue;
|
||||
}
|
||||
delete player.potential;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const resetTurnTimer = (game: Game, session: Session): void => {
|
||||
startTurnTimer(game, session);
|
||||
};
|
||||
|
||||
export const stopTurnTimer = (game: Game): void => {
|
||||
if (game.turnTimer) {
|
||||
console.log(`${info}: Stopping turn timer.`);
|
||||
try {
|
||||
clearTimeout(game.turnTimer);
|
||||
} catch (e) {
|
||||
/* ignore if not a real timeout */
|
||||
}
|
||||
game.turnTimer = 0;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const sendUpdateToPlayers = async (game: any, update: any): Promise<void> => {
|
||||
/* Ensure clearing of a field actually gets sent by setting
|
||||
* undefined to 'false'
|
||||
*/
|
||||
for (let key in update) {
|
||||
if (update[key] === undefined) {
|
||||
update[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
calculatePoints(game, update);
|
||||
|
||||
if (debug.update) {
|
||||
console.log(`[ all ]: -> sendUpdateToPlayers - `, update);
|
||||
} else {
|
||||
const keys = Object.getOwnPropertyNames(update);
|
||||
console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`);
|
||||
}
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: "game-update",
|
||||
update,
|
||||
});
|
||||
for (let key in game.sessions) {
|
||||
const session = game.sessions[key];
|
||||
/* Only send player and game data to named players */
|
||||
if (!session.name) {
|
||||
console.log(`${session.short}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`);
|
||||
if (session.ws) {
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "game-update",
|
||||
update: { name: "" },
|
||||
})
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!session.ws) {
|
||||
console.log(`${session.short}: -> sendUpdateToPlayers: ` + `Currently no connection.`);
|
||||
} else {
|
||||
queueSend(session, message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise<void> => {
|
||||
/* If this player does not have a name, *ONLY* send the name, regardless
|
||||
* of what is requested */
|
||||
if (!session.name) {
|
||||
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - only sending empty name`);
|
||||
update = { name: "" };
|
||||
}
|
||||
|
||||
/* Ensure clearing of a field actually gets sent by setting
|
||||
* undefined to 'false'
|
||||
*/
|
||||
for (let key in update) {
|
||||
if (update[key] === undefined) {
|
||||
update[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
calculatePoints(game, update);
|
||||
|
||||
if (debug.update) {
|
||||
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - `, update);
|
||||
} else {
|
||||
const keys = Object.getOwnPropertyNames(update);
|
||||
console.log(`${session.short}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`);
|
||||
}
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: "game-update",
|
||||
update,
|
||||
});
|
||||
|
||||
if (!session.ws) {
|
||||
console.log(`${session.short}: -> sendUpdateToPlayer: ` + `Currently no connection.`);
|
||||
} else {
|
||||
queueSend(session, message);
|
||||
}
|
||||
};
|
||||
|
||||
export const queueSend = (session: any, message: any): void => {
|
||||
if (!session || !session.ws) return;
|
||||
try {
|
||||
// Ensure we compare a stable serialization: if message is JSON text,
|
||||
// parse it and re-serialize with sorted keys so semantically-equal
|
||||
// objects compare equal even when property order differs.
|
||||
const stableStringify = (msg: any): string => {
|
||||
try {
|
||||
const obj = typeof msg === "string" ? JSON.parse(msg) : msg;
|
||||
const ordered = (v: any): any => {
|
||||
if (v === null || typeof v !== "object") return v;
|
||||
if (Array.isArray(v)) return v.map(ordered);
|
||||
const keys = Object.keys(v).sort();
|
||||
const out: any = {};
|
||||
for (const k of keys) out[k] = ordered(v[k]);
|
||||
return out;
|
||||
};
|
||||
return JSON.stringify(ordered(obj));
|
||||
} catch (e) {
|
||||
// If parsing fails, fall back to original string representation
|
||||
return typeof msg === "string" ? msg : JSON.stringify(msg);
|
||||
}
|
||||
};
|
||||
const stableMessage = stableStringify(message);
|
||||
const now = Date.now();
|
||||
if (!session._lastSent) session._lastSent = 0;
|
||||
const elapsed = now - session._lastSent;
|
||||
// If the exact same message (in stable form) was sent last time and
|
||||
// nothing is pending, skip sending to avoid pointless duplicate
|
||||
// traffic.
|
||||
if (!session._pendingTimeout && session._lastMessage === stableMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we haven't sent recently and there's no pending timer, send now
|
||||
if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) {
|
||||
try {
|
||||
session.ws.send(typeof message === "string" ? message : JSON.stringify(message));
|
||||
session._lastSent = Date.now();
|
||||
session._lastMessage = stableMessage;
|
||||
} catch (e) {
|
||||
console.warn(`${session.id}: queueSend immediate send failed:`, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, store latest message and schedule a send
|
||||
// If the pending message would equal the last-sent message, don't bother
|
||||
// storing/scheduling it.
|
||||
if (session._lastMessage === stableMessage) {
|
||||
return;
|
||||
}
|
||||
session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message);
|
||||
if (session._pendingTimeout) {
|
||||
// already scheduled; newest message will be sent when timer fires
|
||||
return;
|
||||
}
|
||||
const delay = Math.max(1, SEND_THROTTLE_MS - elapsed);
|
||||
session._pendingTimeout = setTimeout(() => {
|
||||
try {
|
||||
if (session.ws && session._pendingMessage) {
|
||||
session.ws.send(session._pendingMessage);
|
||||
session._lastSent = Date.now();
|
||||
// compute stable form of what we actually sent
|
||||
try {
|
||||
session._lastMessage = stableStringify(session._pendingMessage);
|
||||
} catch (e) {
|
||||
session._lastMessage = session._pendingMessage;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`${session.id}: queueSend delayed send failed:`, e);
|
||||
}
|
||||
// clear pending fields
|
||||
session._pendingMessage = undefined;
|
||||
clearTimeout(session._pendingTimeout);
|
||||
session._pendingTimeout = undefined;
|
||||
}, delay);
|
||||
} catch (e) {
|
||||
console.warn(`${session.id}: queueSend exception:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
export const shuffle = (game: Game, session: Session): string | undefined => {
|
||||
if (game.state !== "lobby") {
|
||||
return `Game no longer in lobby (${game.state}). Can not shuffle board.`;
|
||||
}
|
||||
if (game.turns > 0) {
|
||||
return `Game already in progress (${game.turns} so far!) and cannot be shuffled.`;
|
||||
}
|
||||
shuffleBoard(game);
|
||||
console.log(`${session.short}: Shuffled to new signature: ${game.signature}`);
|
||||
|
||||
sendUpdateToPlayers(game, {
|
||||
pipOrder: game.pipOrder,
|
||||
tileOrder: game.tileOrder,
|
||||
borderOrder: game.borderOrder,
|
||||
robber: game.robber,
|
||||
robberName: game.robberName,
|
||||
signature: game.signature,
|
||||
animationSeeds: game.animationSeeds,
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getName = (session: Session): string => {
|
||||
return session ? (session.name ? session.name : session.id) : "Admin";
|
||||
};
|
||||
|
||||
export const getFilteredPlayers = (game: Game): Record<string, Player> => {
|
||||
const filtered: Record<string, Player> = {};
|
||||
for (let color in game.players) {
|
||||
const player = Object.assign({}, game.players[color]);
|
||||
filtered[color] = player;
|
||||
if (player.status === "Not active") {
|
||||
if (game.state !== "lobby") {
|
||||
delete filtered[color];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
player.resources = 0;
|
||||
RESOURCE_TYPES.forEach((resource) => {
|
||||
switch (resource) {
|
||||
case "wood":
|
||||
case "brick":
|
||||
case "sheep":
|
||||
case "wheat":
|
||||
case "stone":
|
||||
player.resources += player[resource];
|
||||
player[resource] = 0;
|
||||
break;
|
||||
}
|
||||
});
|
||||
player.development = [];
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { MAX_ROADS, MAX_CITIES, MAX_SETTLEMENTS } from "./constants";
|
||||
import type { Player } from "./types";
|
||||
import type { Player, PlayerColor } from "./types";
|
||||
|
||||
export const newPlayer = (color: string): Player => {
|
||||
export const newPlayer = (color: PlayerColor): Player => {
|
||||
return {
|
||||
roads: MAX_ROADS,
|
||||
cities: MAX_CITIES,
|
||||
@ -24,7 +24,16 @@ export const newPlayer = (color: string): Player => {
|
||||
turnStart: 0,
|
||||
ports: 0,
|
||||
developmentCards: 0,
|
||||
} as Player;
|
||||
orderRoll: 0,
|
||||
position: "",
|
||||
orderStatus: "none",
|
||||
tied: false,
|
||||
mustDiscard: 0,
|
||||
live: true,
|
||||
turnNotice: "",
|
||||
longestRoad: 0,
|
||||
banks: [],
|
||||
};
|
||||
};
|
||||
|
||||
export default newPlayer;
|
||||
|
17
server/routes/games/robber.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Game } from "./types";
|
||||
|
||||
export const pickRobber = (game: Game): void => {
|
||||
const selection = Math.floor(Math.random() * 3);
|
||||
switch (selection) {
|
||||
case 0:
|
||||
game.robberName = "Robert";
|
||||
break;
|
||||
case 1:
|
||||
game.robberName = "Roberta";
|
||||
break;
|
||||
case 2:
|
||||
game.robberName = "Velocirobber";
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
139
server/routes/games/rules.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { isRuleEnabled } from "../../util/validLocations";
|
||||
import { addChatMessage, getName, sendUpdateToPlayers, shuffle } from "./helpers";
|
||||
|
||||
export const getVictoryPointRule = (game: any): number => {
|
||||
const minVP = 10;
|
||||
if (!isRuleEnabled(game, "victory-points") || !("points" in game.rules["victory-points"])) {
|
||||
return minVP;
|
||||
}
|
||||
return game.rules["victory-points"].points;
|
||||
};
|
||||
|
||||
export const supportedRules: Record<string, (game: any, session: any, rule: any, rules: any) => string | void | undefined> = {
|
||||
"victory-points": (game: any, session: any, rule: any, rules: any) => {
|
||||
if (!("points" in rules[rule])) {
|
||||
return `No points specified for victory-points`;
|
||||
}
|
||||
if (!rules[rule].enabled) {
|
||||
addChatMessage(game, null, `${getName(session)} has disabled the Victory Point ` + `house rule.`);
|
||||
} else {
|
||||
addChatMessage(game, null, `${getName(session)} set the minimum Victory Points to ` + `${rules[rule].points}`);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
"roll-double-roll-again": (game: any, session: any, rule: any, rules: any) => {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Roll Double, Roll Again house rule.`
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
volcano: (game: any, session: any, rule: any, rules: any) => {
|
||||
if (!rules[rule].enabled) {
|
||||
addChatMessage(game, null, `${getName(session)} has disabled the Volcano ` + `house rule.`);
|
||||
} else {
|
||||
if (!(rule in game.rules) || !game.rules[rule].enabled) {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} enabled the Volcano ` +
|
||||
`house rule with roll set to ` +
|
||||
`${rules[rule].number} and 'Volanoes have gold' mode ` +
|
||||
`${rules[rule].gold ? "en" : "dis"}abled.`
|
||||
);
|
||||
} else {
|
||||
if (game.rules[rule].number !== rules[rule].number) {
|
||||
addChatMessage(game, null, `${getName(session)} set the Volcano roll to ` + `${rules[rule].number}`);
|
||||
}
|
||||
|
||||
if (game.rules[rule].gold !== rules[rule].gold) {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ` + `${rules[rule].gold ? "en" : "dis"}abled the ` + `'Volcanoes have gold' mode.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"twelve-and-two-are-synonyms": (game: any, session: any, rule: any, rules: any) => {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Twelve and Two are Synonyms house rule.`
|
||||
);
|
||||
game.rules[rule] = rules[rule];
|
||||
},
|
||||
"most-developed": (game: any, session: any, rule: any, rules: any) => {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Most Developed house rule.`
|
||||
);
|
||||
},
|
||||
"port-of-call": (game: any, session: any, rule: any, rules: any) => {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Another Round of Port house rule.`
|
||||
);
|
||||
},
|
||||
"slowest-turn": (game: any, session: any, rule: any, rules: any) => {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Slowest Turn house rule.`
|
||||
);
|
||||
},
|
||||
"tiles-start-facing-down": (game: any, session: any, rule: any, rules: any) => {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Tiles Start Facing Down house rule.`
|
||||
);
|
||||
if (rules[rule].enabled) {
|
||||
shuffle(game, session);
|
||||
}
|
||||
},
|
||||
"robin-hood-robber": (game: any, session: any, rule: any, rules: any) => {
|
||||
addChatMessage(
|
||||
game,
|
||||
null,
|
||||
`${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Robin Hood Robber house rule.`
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const setRules = (game: any, session: any, rules: any): string | undefined => {
|
||||
if (game.state !== "lobby") {
|
||||
return `You can not modify House Rules once the game has started.`;
|
||||
}
|
||||
|
||||
for (let rule in rules) {
|
||||
if (equal(game.rules[rule], rules[rule])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule in supportedRules) {
|
||||
const handler = supportedRules[rule];
|
||||
if (handler) {
|
||||
const warning = handler(game, session, rule, rules);
|
||||
if (warning) {
|
||||
return warning;
|
||||
}
|
||||
}
|
||||
game.rules[rule] = rules[rule];
|
||||
} else {
|
||||
return `Rule ${rule} not recognized.`;
|
||||
}
|
||||
}
|
||||
|
||||
sendUpdateToPlayers(game, {
|
||||
rules: game.rules,
|
||||
chat: game.chat,
|
||||
});
|
||||
return undefined;
|
||||
};
|
@ -1,19 +1,20 @@
|
||||
import type { GameState } from './state';
|
||||
import { createGame } from "./gameFactory";
|
||||
import type { Game } from "./types";
|
||||
|
||||
export function serializeGame(game: GameState): string {
|
||||
export const serializeGame = (game: Game): string => {
|
||||
// Use a deterministic JSON serializer for snapshots; currently use JSON.stringify
|
||||
return JSON.stringify(game);
|
||||
}
|
||||
};
|
||||
|
||||
export function deserializeGame(serialized: string): GameState {
|
||||
export const deserializeGame = async (serialized: string): Promise<Game> => {
|
||||
try {
|
||||
return JSON.parse(serialized) as GameState;
|
||||
return JSON.parse(serialized) as Game;
|
||||
} catch (e) {
|
||||
// If parsing fails, return a minimal empty game state to avoid crashes
|
||||
return { players: [], placements: { corners: [], roads: [] } } as GameState;
|
||||
return await createGame();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function cloneGame(game: GameState): GameState {
|
||||
export const cloneGame = async (game: Game): Promise<Game> => {
|
||||
return deserializeGame(serializeGame(game));
|
||||
}
|
||||
};
|
||||
|
@ -1,34 +0,0 @@
|
||||
import { Player } from './types';
|
||||
|
||||
export interface PlacementCorner {
|
||||
color?: string | null;
|
||||
type?: string | null; // settlement/city
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface PlacementRoad {
|
||||
color?: string | null;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface Placements {
|
||||
corners: PlacementCorner[];
|
||||
roads: PlacementRoad[];
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
players: Player[];
|
||||
placements: Placements;
|
||||
rules?: Record<string, any>;
|
||||
state?: string;
|
||||
robber?: number;
|
||||
turn?: number;
|
||||
history?: any[];
|
||||
createdAt?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type GameId = string | number;
|
||||
|
@ -1,184 +1,211 @@
|
||||
import type { GameState } from './state.js';
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import type { Game } from "./types";
|
||||
import { promises as fsp } from "fs";
|
||||
import path from "path";
|
||||
import { transientState } from "./sessionState";
|
||||
|
||||
export interface GameDB {
|
||||
sequelize?: any;
|
||||
Sequelize?: any;
|
||||
getGameById(id: string | number): Promise<GameState | null>;
|
||||
saveGameState(id: string | number, state: GameState): Promise<void>;
|
||||
deleteGame?(id: string | number): Promise<void>;
|
||||
[k: string]: any;
|
||||
interface GameDB {
|
||||
db: any | null;
|
||||
init(): Promise<void>;
|
||||
getGameById(id: string): Promise<Game | null>;
|
||||
saveGame(game: Game): Promise<void>;
|
||||
deleteGame?(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin game DB initializer / accessor.
|
||||
* This currently returns the underlying db module (for runtime compatibility)
|
||||
* and is the single place to add typed helper methods for game persistence.
|
||||
*/
|
||||
export async function initGameDB(): Promise<GameDB> {
|
||||
// dynamic import to preserve original runtime ordering
|
||||
// path is relative to this file (routes/games)
|
||||
// Prefer synchronous require at runtime when available to avoid TS module resolution
|
||||
// issues during type-checking. Declare require to keep TypeScript happy.
|
||||
let mod: any;
|
||||
try {
|
||||
// Use runtime require to load the DB module. This runs under Node (ts-node)
|
||||
// so a direct require is appropriate and avoids relying on globalThis.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
mod = require('../../db/games');
|
||||
} catch (e) {
|
||||
// DB-only mode: fail fast so callers know persistence is required.
|
||||
throw new Error('Game DB module could not be loaded: ' + String(e));
|
||||
}
|
||||
// If the module uses default export, prefer it
|
||||
let db: any = (mod && (mod.default || mod));
|
||||
// If the required module returned a Promise (the db initializer may), await it.
|
||||
if (db && typeof db.then === 'function') {
|
||||
export const gameDB: GameDB = {
|
||||
db: null,
|
||||
init: async () => {
|
||||
let mod: any;
|
||||
try {
|
||||
db = await db;
|
||||
// Use runtime require to load the DB module. This runs under Node (ts-node)
|
||||
// so a direct require is appropriate and avoids relying on globalThis.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
mod = require("../../db/games");
|
||||
} catch (e) {
|
||||
throw new Error('Game DB initializer promise rejected: ' + String(e));
|
||||
// DB-only mode: fail fast so callers know persistence is required.
|
||||
throw new Error("Game DB module could not be loaded: " + String(e));
|
||||
}
|
||||
// If the module uses default export, prefer it
|
||||
let db = mod.default || mod;
|
||||
// If the required module returned a Promise (the db initializer may), await it.
|
||||
if (db && typeof db.then === "function") {
|
||||
try {
|
||||
db = await db;
|
||||
} catch (e) {
|
||||
throw new Error("Game DB initializer promise rejected: " + String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attach typed helper placeholders (will be implemented incrementally)
|
||||
if (!db.getGameById) {
|
||||
db.getGameById = async (id: string | number): Promise<GameState | null> => {
|
||||
// fallback: try to query by id using raw SQL if sequelize is available
|
||||
if (db && db.sequelize) {
|
||||
try {
|
||||
const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
|
||||
replacements: { id },
|
||||
type: db.Sequelize.QueryTypes.SELECT
|
||||
});
|
||||
if (rows && rows.length) {
|
||||
const r = rows[0] as any;
|
||||
// state may be stored as text or JSON
|
||||
if (typeof r.state === 'string') {
|
||||
try {
|
||||
return JSON.parse(r.state) as GameState;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
gameDB.db = db;
|
||||
return db;
|
||||
},
|
||||
|
||||
getGameById: async (id: string | number): Promise<Game | null> => {
|
||||
if (!gameDB.db) {
|
||||
await gameDB.init();
|
||||
}
|
||||
const db = gameDB.db;
|
||||
// fallback: try to query by id using raw SQL if sequelize is available
|
||||
if (db && db.sequelize) {
|
||||
try {
|
||||
const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
|
||||
replacements: { id },
|
||||
type: db.Sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
if (rows && rows.length) {
|
||||
const r = rows[0];
|
||||
// state may be stored as text or JSON
|
||||
if (typeof r.state === "string") {
|
||||
try {
|
||||
return JSON.parse(r.state) as Game;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return r.state as GameState;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and fallthrough
|
||||
return r.state as Game;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and fallthrough
|
||||
}
|
||||
// If DB didn't have a state or query failed, attempt to read from the
|
||||
// filesystem copy at db/games/<id> or <id>.json so the state remains editable.
|
||||
try {
|
||||
const gamesDir = path.resolve(__dirname, '../../../db/games');
|
||||
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')];
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as GameState;
|
||||
} catch (e) {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!db.saveGameState) {
|
||||
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
|
||||
// Always persist a JSON file so game state is inspectable/editable.
|
||||
try {
|
||||
const gamesDir = path.resolve(__dirname, '../../../db/games');
|
||||
await fsp.mkdir(gamesDir, { recursive: true });
|
||||
// Write extensionless filename to match existing files
|
||||
const filePath = path.join(gamesDir, String(id));
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
|
||||
await fsp.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
// Log but continue to attempt DB persistence
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to write game JSON file for', id, err);
|
||||
}
|
||||
|
||||
// Now attempt DB persistence if sequelize is present.
|
||||
if (db && db.sequelize) {
|
||||
const payload = JSON.stringify(state);
|
||||
// Try an UPDATE; if it errors due to missing column, try to add the
|
||||
// column and retry. If update affects no rows, try INSERT.
|
||||
}
|
||||
// If DB didn't have a state or query failed, attempt to read from the
|
||||
// filesystem copy at db/games/<id> or <id>.json so the state remains editable.
|
||||
try {
|
||||
const gamesDir = path.resolve(__dirname, "../../../db/games");
|
||||
const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")];
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
try {
|
||||
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
|
||||
replacements: { id, state: payload }
|
||||
const raw = await fsp.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as Game;
|
||||
} catch (e) {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveGame: async (game: Game): Promise<void> => {
|
||||
if (!gameDB.db) {
|
||||
await gameDB.init();
|
||||
}
|
||||
const db = gameDB.db;
|
||||
/* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then
|
||||
* delete the player field from them */
|
||||
const reducedGame = Object.assign({}, game, { sessions: {} }),
|
||||
reducedSessions = [];
|
||||
|
||||
for (let id in game.sessions) {
|
||||
const reduced = Object.assign({}, game.sessions[id]);
|
||||
|
||||
// Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth)
|
||||
transientState.stripSessionTransients(reduced);
|
||||
|
||||
reducedGame.sessions[id] = reduced;
|
||||
|
||||
/* Do not send session-id as those are secrets */
|
||||
reducedSessions.push(reduced);
|
||||
}
|
||||
|
||||
// Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA)
|
||||
transientState.stripGameTransients(reducedGame);
|
||||
|
||||
/* Save per turn while debugging... */
|
||||
game.step = game.step ? game.step : 0;
|
||||
|
||||
// Always persist a JSON file so game state is inspectable/editable.
|
||||
try {
|
||||
const gamesDir = path.resolve(__dirname, "../../../db/games");
|
||||
await fsp.mkdir(gamesDir, { recursive: true });
|
||||
// Write extensionless filename to match existing files
|
||||
const filePath = path.join(gamesDir, reducedGame.id);
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(reducedGame, null, 2), "utf8");
|
||||
await fsp.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
// Log but continue to attempt DB persistence
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to write game JSON file for", reducedGame.id, err);
|
||||
}
|
||||
|
||||
// Now attempt DB persistence if sequelize is present.
|
||||
if (db && db.sequelize) {
|
||||
const payload = JSON.stringify(reducedGame);
|
||||
// Try an UPDATE; if it errors due to missing column, try to add the
|
||||
// column and retry. If update affects no rows, try INSERT.
|
||||
try {
|
||||
try {
|
||||
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
|
||||
replacements: { id: reducedGame.id, state: payload },
|
||||
});
|
||||
// Some dialects don't return affectedRows consistently; we'll
|
||||
// still attempt insert if no row exists by checking select.
|
||||
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
|
||||
replacements: { id: reducedGame.id },
|
||||
type: db.Sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
if (!check || check.length === 0) {
|
||||
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
|
||||
replacements: { id: reducedGame.id, state: payload },
|
||||
});
|
||||
// Some dialects don't return affectedRows consistently; we'll
|
||||
// still attempt insert if no row exists by checking select.
|
||||
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
|
||||
replacements: { id },
|
||||
type: db.Sequelize.QueryTypes.SELECT
|
||||
});
|
||||
if (!check || check.length === 0) {
|
||||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
|
||||
replacements: { id, state: payload }
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e && e.message ? e.message : e);
|
||||
// If the column doesn't exist (SQLite: no such column: state), add it.
|
||||
if (
|
||||
/no such column: state/i.test(msg) ||
|
||||
/has no column named state/i.test(msg) ||
|
||||
/unknown column/i.test(msg)
|
||||
) {
|
||||
try {
|
||||
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
|
||||
// retry insert/update after adding column
|
||||
await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", {
|
||||
replacements: { id: reducedGame.id, state: payload },
|
||||
});
|
||||
const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", {
|
||||
replacements: { id: reducedGame.id },
|
||||
type: db.Sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
if (!check || check.length === 0) {
|
||||
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
|
||||
replacements: { id: reducedGame.id, state: payload },
|
||||
});
|
||||
}
|
||||
} catch (inner) {
|
||||
// swallow; callers should handle missing persistence
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e && e.message ? e.message : e);
|
||||
// If the column doesn't exist (SQLite: no such column: state), add it.
|
||||
if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) {
|
||||
try {
|
||||
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
|
||||
// retry insert/update after adding column
|
||||
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
|
||||
replacements: { id, state: payload }
|
||||
});
|
||||
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
|
||||
replacements: { id },
|
||||
type: db.Sequelize.QueryTypes.SELECT
|
||||
});
|
||||
if (!check || check.length === 0) {
|
||||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
|
||||
replacements: { id, state: payload }
|
||||
});
|
||||
}
|
||||
} catch (inner) {
|
||||
// swallow; callers should handle missing persistence
|
||||
}
|
||||
} else {
|
||||
// For other errors, attempt insert as a fallback
|
||||
try {
|
||||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
|
||||
replacements: { id, state: payload }
|
||||
});
|
||||
} catch (err) {
|
||||
// swallow; callers should handle missing persistence
|
||||
}
|
||||
} else {
|
||||
// For other errors, attempt insert as a fallback
|
||||
try {
|
||||
await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", {
|
||||
replacements: { id: reducedGame.id, state: payload },
|
||||
});
|
||||
} catch (err) {
|
||||
// swallow; callers should handle missing persistence
|
||||
}
|
||||
}
|
||||
} catch (finalErr) {
|
||||
// swallow; we don't want persistence errors to crash the server
|
||||
}
|
||||
} catch (finalErr) {
|
||||
// swallow; we don't want persistence errors to crash the server
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!db.deleteGame) {
|
||||
db.deleteGame = async (id: string | number): Promise<void> => {
|
||||
if (db && db.sequelize) {
|
||||
try {
|
||||
await db.sequelize.query('DELETE FROM games WHERE id=:id', {
|
||||
replacements: { id }
|
||||
});
|
||||
} catch (e) {
|
||||
// swallow; callers should handle missing persistence
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteGame: async (id: string | number): Promise<void> => {
|
||||
if (!gameDB.db) {
|
||||
await gameDB.init();
|
||||
}
|
||||
const db = gameDB.db;
|
||||
if (db && db.sequelize) {
|
||||
try {
|
||||
await db.sequelize.query("DELETE FROM games WHERE id=:id", {
|
||||
replacements: { id },
|
||||
});
|
||||
} catch (e) {
|
||||
// swallow; callers should handle missing persistence
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return db as GameDB;
|
||||
}
|
||||
export const games: Record<string, Game> = {};
|
||||
|
@ -8,59 +8,66 @@ export interface TransientGameState {
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
name?: string;
|
||||
color?: string;
|
||||
name: string;
|
||||
color: PlayerColor;
|
||||
order: number;
|
||||
orderRoll?: number;
|
||||
position?: string;
|
||||
orderStatus?: string;
|
||||
tied?: boolean;
|
||||
roads?: number;
|
||||
settlements?: number;
|
||||
cities?: number;
|
||||
longestRoad?: number;
|
||||
orderRoll: number;
|
||||
position: string;
|
||||
orderStatus: string;
|
||||
tied: boolean;
|
||||
roads: number;
|
||||
settlements: number;
|
||||
cities: number;
|
||||
longestRoad: number;
|
||||
mustDiscard?: number;
|
||||
sheep?: number;
|
||||
wheat?: number;
|
||||
stone?: number;
|
||||
brick?: number;
|
||||
wood?: number;
|
||||
points?: number;
|
||||
resources?: number;
|
||||
lastActive?: number;
|
||||
live?: boolean;
|
||||
status?: string;
|
||||
developmentCards?: number;
|
||||
development?: DevelopmentCard[];
|
||||
turnNotice?: string;
|
||||
turnStart?: number;
|
||||
totalTime?: number;
|
||||
[key: string]: any; // allow incremental fields until fully typed
|
||||
sheep: number;
|
||||
wheat: number;
|
||||
stone: number;
|
||||
brick: number;
|
||||
wood: number;
|
||||
army: number;
|
||||
points: number;
|
||||
ports: number;
|
||||
resources: number;
|
||||
lastActive: number;
|
||||
live: boolean;
|
||||
status: string;
|
||||
developmentCards: number;
|
||||
development: DevelopmentCard[];
|
||||
turnNotice: string;
|
||||
turnStart: number;
|
||||
totalTime: number;
|
||||
banks: ResourceType[];
|
||||
}
|
||||
|
||||
export type CornerType = "settlement" | "city" | "none";
|
||||
export const CORNER_TYPES: CornerType[] = ["settlement", "city", "none"];
|
||||
|
||||
export interface CornerPlacement {
|
||||
color?: string;
|
||||
type?: "settlement" | "city";
|
||||
color: PlayerColor;
|
||||
type: "settlement" | "city" | "none";
|
||||
walking?: boolean;
|
||||
longestRoad?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type RoadType = "road" | "ship";
|
||||
export const ROAD_TYPES: RoadType[] = ["road", "ship"];
|
||||
|
||||
export interface RoadPlacement {
|
||||
color?: string;
|
||||
color?: PlayerColor;
|
||||
walking?: boolean;
|
||||
[key: string]: any;
|
||||
type?: "road" | "ship";
|
||||
longestRoad?: number;
|
||||
}
|
||||
|
||||
export interface Placements {
|
||||
corners: CornerPlacement[];
|
||||
roads: RoadPlacement[];
|
||||
[key: string]: any;
|
||||
corners: Array<CornerPlacement | undefined>;
|
||||
roads: Array<RoadPlacement | undefined>;
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
name?: string;
|
||||
color?: string;
|
||||
color: PlayerColor;
|
||||
actions?: string[];
|
||||
limits?: any;
|
||||
roll?: number;
|
||||
@ -71,6 +78,7 @@ export interface Turn {
|
||||
active?: string;
|
||||
robberInAction?: boolean;
|
||||
placedRobber?: number;
|
||||
offer?: Offer;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@ -81,7 +89,7 @@ export interface DevelopmentCard {
|
||||
}
|
||||
|
||||
// Import from schema for DRY compliance
|
||||
import { TransientSessionState } from './transientSchema';
|
||||
import { TransientSessionState } from "./transientSchema";
|
||||
|
||||
/**
|
||||
* Persistent Session data (saved to DB)
|
||||
@ -89,7 +97,7 @@ import { TransientSessionState } from './transientSchema';
|
||||
export interface PersistentSessionData {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
color: PlayerColor;
|
||||
lastActive: number;
|
||||
userId?: number;
|
||||
player?: Player;
|
||||
@ -97,6 +105,9 @@ export interface PersistentSessionData {
|
||||
resources?: number;
|
||||
}
|
||||
|
||||
export type PlayerColor = "R" | "B" | "O" | "W" | "robber" | "unassigned";
|
||||
export const PLAYER_COLORS: PlayerColor[] = ["R", "B", "O", "W", "robber", "unassigned"];
|
||||
|
||||
/**
|
||||
* Runtime Session type = Persistent + Transient
|
||||
* At runtime, sessions have both persistent and transient fields
|
||||
@ -114,6 +125,21 @@ export interface Offer {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert" | "bank";
|
||||
export const RESOURCE_TYPES: ResourceType[] = ["wood", "brick", "sheep", "wheat", "stone", "desert", "bank"];
|
||||
|
||||
export interface Tile {
|
||||
robber: boolean;
|
||||
index: number;
|
||||
type: ResourceType;
|
||||
resource?: ResourceKey | null;
|
||||
roll?: number | null;
|
||||
corners: number[];
|
||||
pip: number;
|
||||
roads: number[];
|
||||
asset: number;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: string;
|
||||
developmentCards: DevelopmentCard[];
|
||||
@ -127,32 +153,38 @@ export interface Game {
|
||||
step?: number;
|
||||
placements: Placements;
|
||||
turn: Turn;
|
||||
pipOrder?: number[];
|
||||
tileOrder?: number[];
|
||||
pipOrder: number[];
|
||||
tileOrder: number[];
|
||||
resources?: number;
|
||||
tiles?: any[];
|
||||
pips?: any[];
|
||||
dice?: number[];
|
||||
chat?: any[];
|
||||
activities?: any[];
|
||||
playerOrder?: string[];
|
||||
playerOrder: PlayerColor[];
|
||||
state?: string;
|
||||
robber?: number;
|
||||
robberName?: string;
|
||||
turns?: number;
|
||||
turns: number;
|
||||
longestRoad?: string | false;
|
||||
longestRoadLength?: number;
|
||||
borderOrder?: number[];
|
||||
borderOrder: number[];
|
||||
largestArmy?: string | false;
|
||||
largestArmySize?: number;
|
||||
mostPorts?: string | false;
|
||||
mostPorts: PlayerColor | null;
|
||||
mostPortCount: number;
|
||||
mostDeveloped?: string | false;
|
||||
private?: boolean;
|
||||
created?: number;
|
||||
lastActivity?: number;
|
||||
signature?: string;
|
||||
animationSeeds?: number[];
|
||||
[key: string]: any;
|
||||
startTime?: number;
|
||||
direction?: "forward" | "backward";
|
||||
winner?: string | false;
|
||||
history?: any[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export type GameId = string;
|
||||
|
||||
export type IncomingMessage = { type: string | null; data: any };
|
||||
|
@ -1,30 +1,14 @@
|
||||
export function normalizeIncoming(msg: unknown): { type: string | null, data: unknown } {
|
||||
if (!msg) return { type: null, data: null };
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
if (typeof msg === 'string') {
|
||||
parsed = JSON.parse(msg);
|
||||
} else {
|
||||
parsed = msg;
|
||||
}
|
||||
} catch (e) {
|
||||
return { type: null, data: null };
|
||||
}
|
||||
if (!parsed) return { type: null, data: null };
|
||||
const type = (parsed as any).type || (parsed as any).action || null;
|
||||
const data = (parsed as any).data || (Object.keys(parsed as any).length ? Object.assign({}, parsed as any) : null);
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
export function shuffleArray<T>(array: T[]): T[] {
|
||||
let currentIndex = array.length, temporaryValue: T | undefined, randomIndex: number;
|
||||
let currentIndex = array.length,
|
||||
temporaryValue: T | undefined,
|
||||
randomIndex: number;
|
||||
while (0 !== currentIndex) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex -= 1;
|
||||
// use non-null assertions because we're swapping indices that exist
|
||||
temporaryValue = array[currentIndex] as T;
|
||||
array[currentIndex] = array[randomIndex] as T;
|
||||
array[randomIndex] = temporaryValue as T;
|
||||
// use non-null assertions because we're swapping indices that exist
|
||||
temporaryValue = array[currentIndex] as T;
|
||||
array[currentIndex] = array[randomIndex] as T;
|
||||
array[randomIndex] = temporaryValue as T;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
@ -1,19 +1,32 @@
|
||||
/* WebRTC signaling helpers extracted from games.ts
|
||||
* Exports:
|
||||
* - audio: map of gameId -> peers
|
||||
* - join(peers, session, config, safeSend)
|
||||
* - part(peers, session, safeSend)
|
||||
* - handleRelayICECandidate(gameId, cfg, session, safeSend, debug)
|
||||
* - handleRelaySessionDescription(gameId, cfg, session, safeSend, debug)
|
||||
* - broadcastPeerStateUpdate(gameId, cfg, session, safeSend)
|
||||
* - join(peers, session, config)
|
||||
* - part(peers, session)
|
||||
* - handleRelayICECandidate(gameId, cfg, session, debug)
|
||||
* - handleRelaySessionDescription(gameId, cfg, session, debug)
|
||||
* - broadcastPeerStateUpdate(gameId, cfg, session)
|
||||
*/
|
||||
|
||||
export const audio: Record<string, any> = {};
|
||||
import { Session } from "./games/types";
|
||||
|
||||
interface Peer {
|
||||
ws: any;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/* Map of session => peer_id => peer */
|
||||
export const audio: Record<string, Record<string, Peer>> = {};
|
||||
|
||||
// Default send helper used when caller doesn't provide a safeSend implementation.
|
||||
const defaultSend = (targetOrSession: any, message: any): boolean => {
|
||||
try {
|
||||
const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null;
|
||||
const target =
|
||||
targetOrSession && typeof targetOrSession.send === "function"
|
||||
? targetOrSession
|
||||
: targetOrSession && targetOrSession.ws
|
||||
? targetOrSession.ws
|
||||
: null;
|
||||
if (!target) return false;
|
||||
target.send(typeof message === "string" ? message : JSON.stringify(message));
|
||||
return true;
|
||||
@ -22,17 +35,12 @@ const defaultSend = (targetOrSession: any, message: any): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
export const join = (
|
||||
peers: any,
|
||||
session: any,
|
||||
{ hasVideo, hasAudio, has_media }: { hasVideo?: boolean; hasAudio?: boolean; has_media?: boolean },
|
||||
safeSend?: (targetOrSession: any, message: any) => boolean
|
||||
): void => {
|
||||
const send = safeSend ? safeSend : defaultSend;
|
||||
export const join = (peers: Record<string, Peer>, session: Session): void => {
|
||||
const send = defaultSend;
|
||||
const ws = session.ws;
|
||||
|
||||
if (!session.name) {
|
||||
console.error(`${session.id}: <- join - No name set yet. Audio not available.`);
|
||||
console.error(`${session.short}: <- join - No name set yet. Audio not available.`);
|
||||
send(ws, {
|
||||
type: "join_status",
|
||||
status: "Error",
|
||||
@ -41,25 +49,21 @@ export const join = (
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${session.id}: <- join - ${session.name}`);
|
||||
console.log(`${session.short}: <- join - ${session.name}`);
|
||||
|
||||
// Determine media capability - prefer has_media if provided
|
||||
const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio;
|
||||
|
||||
if (session.name in peers) {
|
||||
console.log(`${session.id}:${session.name} - Already joined to Audio, updating WebSocket reference.`);
|
||||
const peer = peers[session.id];
|
||||
// Use session.id as the canonical peer key
|
||||
if (peer) {
|
||||
console.log(`${session.short}:${session.id} - Already joined to Audio, updating WebSocket reference.`);
|
||||
try {
|
||||
const prev = peers[session.name] && peers[session.name].ws;
|
||||
const prev = peer.ws;
|
||||
if (prev && prev._pingInterval) {
|
||||
clearInterval(prev._pingInterval);
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
peers[session.name].ws = ws;
|
||||
peers[session.name].has_media = peerHasMedia;
|
||||
peers[session.name].hasAudio = hasAudio;
|
||||
peers[session.name].hasVideo = hasVideo;
|
||||
peer.ws = ws;
|
||||
|
||||
send(ws, {
|
||||
type: "join_status",
|
||||
@ -67,34 +71,30 @@ export const join = (
|
||||
message: "Reconnected",
|
||||
});
|
||||
|
||||
for (const peer in peers) {
|
||||
if (peer === session.name) continue;
|
||||
// Tell the reconnecting client about existing peers
|
||||
for (const peerId in peers) {
|
||||
if (peerId === session.id) continue;
|
||||
|
||||
send(ws, {
|
||||
type: "addPeer",
|
||||
data: {
|
||||
peer_id: peer,
|
||||
peer_name: peer,
|
||||
has_media: peers[peer].has_media,
|
||||
peer_id: peerId,
|
||||
peer_name: peers[peerId]!.name,
|
||||
should_create_offer: true,
|
||||
hasAudio: peers[peer].hasAudio,
|
||||
hasVideo: peers[peer].hasVideo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const peer in peers) {
|
||||
if (peer === session.name) continue;
|
||||
// Tell existing peers about the reconnecting client
|
||||
for (const peerId in peers) {
|
||||
if (peerId === session.id) continue;
|
||||
|
||||
send(peers[peer].ws, {
|
||||
send(peers[peerId]!.ws, {
|
||||
type: "addPeer",
|
||||
data: {
|
||||
peer_id: session.name,
|
||||
peer_id: session.id,
|
||||
peer_name: session.name,
|
||||
has_media: peerHasMedia,
|
||||
should_create_offer: false,
|
||||
hasAudio,
|
||||
hasVideo,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -102,37 +102,32 @@ export const join = (
|
||||
return;
|
||||
}
|
||||
|
||||
for (let peer in peers) {
|
||||
send(peers[peer].ws, {
|
||||
for (let peerId in peers) {
|
||||
// notify existing peers about the new client
|
||||
send(peers[peerId]!.ws, {
|
||||
type: "addPeer",
|
||||
data: {
|
||||
peer_id: session.name,
|
||||
peer_id: session.id,
|
||||
peer_name: session.name,
|
||||
has_media: peers[session.name]?.has_media ?? peerHasMedia,
|
||||
should_create_offer: false,
|
||||
hasAudio,
|
||||
hasVideo,
|
||||
},
|
||||
});
|
||||
|
||||
// tell the new client about existing peers
|
||||
send(ws, {
|
||||
type: "addPeer",
|
||||
data: {
|
||||
peer_id: peer,
|
||||
peer_name: peer,
|
||||
has_media: peers[peer].has_media,
|
||||
peer_id: peerId,
|
||||
peer_name: peers[peerId]!.name || peerId,
|
||||
should_create_offer: true,
|
||||
hasAudio: peers[peer].hasAudio,
|
||||
hasVideo: peers[peer].hasVideo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
peers[session.name] = {
|
||||
// Store peer keyed by session.id and keep the display name
|
||||
peers[session.id] = {
|
||||
ws,
|
||||
hasAudio,
|
||||
hasVideo,
|
||||
has_media: peerHasMedia,
|
||||
name: session.name,
|
||||
};
|
||||
|
||||
send(ws, {
|
||||
@ -142,53 +137,46 @@ export const join = (
|
||||
});
|
||||
};
|
||||
|
||||
export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => {
|
||||
export const part = (peers: Record<string, Peer>, session: Session): void => {
|
||||
const ws = session.ws;
|
||||
const send = safeSend
|
||||
? safeSend
|
||||
: defaultSend;
|
||||
const send = defaultSend;
|
||||
|
||||
if (!session.name) {
|
||||
console.error(`${session.id}: <- part - No name set yet. Audio not available.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(session.name in peers)) {
|
||||
console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`);
|
||||
if (!(session.id in peers)) {
|
||||
console.log(`${session.short}: <- ${session.name} - Does not exist in game audio.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${session.id}: <- ${session.name} - Audio part.`);
|
||||
console.log(`-> removePeer - ${session.name}`);
|
||||
console.log(`${session.short}: <- ${session.name} - Audio part.`);
|
||||
console.log(`${session.short}: -> removePeer - ${session.name}`);
|
||||
|
||||
delete peers[session.name];
|
||||
// Remove this peer
|
||||
delete peers[session.id];
|
||||
|
||||
for (let peer in peers) {
|
||||
send(peers[peer].ws, {
|
||||
for (let peerId in peers) {
|
||||
send(peers[peerId]!.ws, {
|
||||
type: "removePeer",
|
||||
data: {
|
||||
peer_id: session.name,
|
||||
peer_id: session.id,
|
||||
peer_name: session.name,
|
||||
},
|
||||
});
|
||||
send(ws, {
|
||||
type: "removePeer",
|
||||
data: {
|
||||
peer_id: peer,
|
||||
peer_name: peer,
|
||||
peer_id: peerId,
|
||||
peer_name: peers[peerId]!.name || peerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRelayICECandidate = (
|
||||
gameId: string,
|
||||
cfg: any,
|
||||
session: any,
|
||||
safeSend?: (targetOrSession: any, message: any) => boolean,
|
||||
debug?: any
|
||||
) => {
|
||||
const send = safeSend ? safeSend : defaultSend;
|
||||
export const handleRelayICECandidate = (gameId: string, cfg: any, session: Session, debug?: any) => {
|
||||
const send = defaultSend;
|
||||
|
||||
const ws = session && session.ws;
|
||||
if (!cfg) {
|
||||
@ -202,19 +190,20 @@ export const handleRelayICECandidate = (
|
||||
return;
|
||||
}
|
||||
const { peer_id, candidate } = cfg;
|
||||
if (debug && debug.audio) console.log(`${session.id}:${gameId} <- relayICECandidate ${session.name} to ${peer_id}`, candidate);
|
||||
if (debug && debug.audio)
|
||||
console.log(`${session.id}:${gameId} <- relayICECandidate ${session.name} to ${peer_id}`, candidate);
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: "iceCandidate",
|
||||
data: {
|
||||
peer_id: session.name,
|
||||
peer_id: session.id,
|
||||
peer_name: session.name,
|
||||
candidate,
|
||||
},
|
||||
});
|
||||
|
||||
if (peer_id in audio[gameId]) {
|
||||
const target = audio[gameId][peer_id] as any;
|
||||
if (peer_id in audio[gameId]!) {
|
||||
const target = audio[gameId]![peer_id] as any;
|
||||
if (!target || !target.ws) {
|
||||
console.warn(`${session.id}:${gameId} relayICECandidate - target ${peer_id} has no ws`);
|
||||
} else if (!send(target.ws, message)) {
|
||||
@ -223,14 +212,8 @@ export const handleRelayICECandidate = (
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRelaySessionDescription = (
|
||||
gameId: string,
|
||||
cfg: any,
|
||||
session: any,
|
||||
safeSend?: (targetOrSession: any, message: any) => boolean,
|
||||
debug?: any
|
||||
) => {
|
||||
const send = safeSend ? safeSend : defaultSend;
|
||||
export const handleRelaySessionDescription = (gameId: string, cfg: any, session: any, debug?: any) => {
|
||||
const send = defaultSend;
|
||||
|
||||
const ws = session && session.ws;
|
||||
if (!cfg) {
|
||||
@ -247,17 +230,21 @@ export const handleRelaySessionDescription = (
|
||||
send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } });
|
||||
return;
|
||||
}
|
||||
if (debug && debug.audio) console.log(`${session.id}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`, session_description);
|
||||
if (debug && debug.audio)
|
||||
console.log(
|
||||
`${session.short}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`,
|
||||
session_description
|
||||
);
|
||||
const message = JSON.stringify({
|
||||
type: "sessionDescription",
|
||||
data: {
|
||||
peer_id: session.name,
|
||||
peer_id: session.id,
|
||||
peer_name: session.name,
|
||||
session_description,
|
||||
},
|
||||
});
|
||||
if (peer_id in audio[gameId]) {
|
||||
const target = audio[gameId][peer_id] as any;
|
||||
if (peer_id in audio[gameId]!) {
|
||||
const target = audio[gameId]![peer_id] as any;
|
||||
if (!target || !target.ws) {
|
||||
console.warn(`${session.id}:${gameId} relaySessionDescription - target ${peer_id} has no ws`);
|
||||
} else if (!send(target.ws, message)) {
|
||||
@ -266,19 +253,22 @@ export const handleRelaySessionDescription = (
|
||||
}
|
||||
};
|
||||
|
||||
export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean) => {
|
||||
const send = safeSend
|
||||
? safeSend
|
||||
: (targetOrSession: any, message: any) => {
|
||||
try {
|
||||
const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null;
|
||||
if (!target) return false;
|
||||
target.send(typeof message === "string" ? message : JSON.stringify(message));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any) => {
|
||||
const send = (targetOrSession: any, message: any) => {
|
||||
try {
|
||||
const target =
|
||||
targetOrSession && typeof targetOrSession.send === "function"
|
||||
? targetOrSession
|
||||
: targetOrSession && targetOrSession.ws
|
||||
? targetOrSession.ws
|
||||
: null;
|
||||
if (!target) return false;
|
||||
target.send(typeof message === "string" ? message : JSON.stringify(message));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!(gameId in audio)) {
|
||||
console.error(`${session.id}:${gameId} <- peer_state_update - Does not have Audio`);
|
||||
@ -294,24 +284,24 @@ export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any,
|
||||
const messagePayload = JSON.stringify({
|
||||
type: "peer_state_update",
|
||||
data: {
|
||||
peer_id: session.name,
|
||||
peer_id: session.id,
|
||||
peer_name: session.name,
|
||||
muted,
|
||||
video_on,
|
||||
},
|
||||
});
|
||||
|
||||
for (const other in audio[gameId]) {
|
||||
if (other === session.name) continue;
|
||||
for (const otherId in audio[gameId]) {
|
||||
if (otherId === session.id) continue;
|
||||
try {
|
||||
const tgt = audio[gameId][other] as any;
|
||||
const tgt = audio[gameId][otherId] as any;
|
||||
if (!tgt || !tgt.ws) {
|
||||
console.warn(`${session.id}:${gameId} peer_state_update - target ${other} has no ws`);
|
||||
console.warn(`${session.id}:${gameId} peer_state_update - target ${otherId} has no ws`);
|
||||
} else if (!send(tgt.ws, messagePayload)) {
|
||||
console.warn(`${session.id}:${gameId} peer_state_update - send failed to ${other}`);
|
||||
console.warn(`${session.id}:${gameId} peer_state_update - send failed to ${otherId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed sending peer_state_update to ${other}:`, e);
|
||||
console.warn(`Failed sending peer_state_update to ${otherId}:`, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -1,11 +1,11 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import config from 'config';
|
||||
import basePath from '../basepath';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import http from 'http';
|
||||
import expressWs from 'express-ws';
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import config from "config";
|
||||
import basePath from "../basepath";
|
||||
import cookieParser from "cookie-parser";
|
||||
import http from "http";
|
||||
import expressWs from "express-ws";
|
||||
|
||||
process.env.TZ = "Etc/GMT";
|
||||
|
||||
@ -29,7 +29,7 @@ try {
|
||||
const debugRouter = require("../routes/debug").default || require("../routes/debug");
|
||||
app.use(basePath, debugRouter);
|
||||
} catch (e: any) {
|
||||
console.error('Failed to mount debug routes (src):', e && e.stack || e);
|
||||
console.error("Failed to mount debug routes (src):", (e && e.stack) || e);
|
||||
}
|
||||
|
||||
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
|
||||
@ -87,34 +87,18 @@ app.use(basePath, index);
|
||||
*/
|
||||
app.set("port", serverConfig.port);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
process.on("SIGINT", () => {
|
||||
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
|
||||
setTimeout(() => process.exit(-1), 2000);
|
||||
server.close(() => process.exit(1));
|
||||
});
|
||||
|
||||
// database initializers
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
import { initGameDB } from '../routes/games/store';
|
||||
|
||||
initGameDB().then(function(_db: any) {
|
||||
// games DB initialized via store facade
|
||||
}).then(function() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return Promise.resolve((require("../db/users") as any).default || require("../db/users")).then(function(_db: any) {
|
||||
// users DB initialized
|
||||
});
|
||||
}).then(function() {
|
||||
console.log("DB connected. Opening server.");
|
||||
server.listen(serverConfig.port, () => {
|
||||
console.log(`http/ws server listening on ${serverConfig.port}`);
|
||||
});
|
||||
}).catch(function(error: any) {
|
||||
console.error(error);
|
||||
process.exit(-1);
|
||||
console.log("Opening server.");
|
||||
server.listen(serverConfig.port, () => {
|
||||
console.log(`http/ws server listening on ${serverConfig.port}`);
|
||||
});
|
||||
|
||||
server.on("error", function(error: any) {
|
||||
server.on("error", function (error: any) {
|
||||
if (error.syscall !== "listen") {
|
||||
throw error;
|
||||
}
|
||||
|
@ -1,67 +1,67 @@
|
||||
#!/usr/bin/env ts-node
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { initGameDB } from '../routes/games/store';
|
||||
import { gameDB } from "../routes/games/store";
|
||||
|
||||
async function main() {
|
||||
const gamesDir = path.resolve(__dirname, '../../db/games');
|
||||
const gamesDir = path.resolve(__dirname, "../../db/games");
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(gamesDir);
|
||||
} catch (e) {
|
||||
console.error('Failed to read games dir', gamesDir, e);
|
||||
console.error("Failed to read games dir", gamesDir, e);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let db: any;
|
||||
try {
|
||||
db = await initGameDB();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize DB', e);
|
||||
process.exit(3);
|
||||
if (!gameDB.db) {
|
||||
await gameDB.init();
|
||||
}
|
||||
|
||||
if (!db || !db.sequelize) {
|
||||
console.error('DB did not expose sequelize; cannot proceed.');
|
||||
let db = gameDB.db;
|
||||
|
||||
if (!db.sequelize) {
|
||||
console.error("DB did not expose sequelize; cannot proceed.");
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// ignore dotfiles and .bk backup files (we don't want to import backups)
|
||||
if (f.startsWith('.') || f.endsWith('.bk')) continue;
|
||||
// ignore dotfiles and .bk backup files (we don't want to import backups)
|
||||
if (f.startsWith(".") || f.endsWith(".bk")) continue;
|
||||
const full = path.join(gamesDir, f);
|
||||
try {
|
||||
const stat = await fs.stat(full);
|
||||
if (!stat.isFile()) continue;
|
||||
const raw = await fs.readFile(full, 'utf8');
|
||||
const state = JSON.parse(raw);
|
||||
const raw = await fs.readFile(full, "utf8");
|
||||
const game = JSON.parse(raw);
|
||||
// Derive id from filename (strip .json if present)
|
||||
const idStr = f.endsWith('.json') ? f.slice(0, -5) : f;
|
||||
const idStr = f.endsWith(".json") ? f.slice(0, -5) : f;
|
||||
const id = isNaN(Number(idStr)) ? idStr : Number(idStr);
|
||||
|
||||
// derive a friendly name from the saved state when present
|
||||
const nameCandidate = (state && (state.name || state.id)) ? String(state.name || state.id) : undefined;
|
||||
// derive a friendly name from the saved game when present
|
||||
const nameCandidate = game && (game.name || game.id) ? String(game.name || game.id) : undefined;
|
||||
|
||||
try {
|
||||
if (typeof id === 'number') {
|
||||
if (typeof id === "number") {
|
||||
// numeric filename: use the typed helper
|
||||
await db.saveGameState(id, state);
|
||||
await db.saveGame(game);
|
||||
console.log(`Saved game id=${id}`);
|
||||
if (nameCandidate) {
|
||||
try {
|
||||
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id, name: nameCandidate } });
|
||||
await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", {
|
||||
replacements: { id, name: nameCandidate },
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore name update failure
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// string filename: try to find an existing row by path and save via id;
|
||||
// otherwise insert a new row with path and the JSON state.
|
||||
// otherwise insert a new row with path and the JSON game.
|
||||
let found: any[] = [];
|
||||
try {
|
||||
found = await db.sequelize.query('SELECT id FROM games WHERE path=:path', {
|
||||
found = await db.sequelize.query("SELECT id FROM games WHERE path=:path", {
|
||||
replacements: { path: idStr },
|
||||
type: db.Sequelize.QueryTypes.SELECT
|
||||
type: db.Sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
} catch (qe) {
|
||||
found = [];
|
||||
@ -69,11 +69,13 @@ async function main() {
|
||||
|
||||
if (found && found.length) {
|
||||
const foundId = found[0].id;
|
||||
await db.saveGameState(foundId, state);
|
||||
await db.saveGame(game);
|
||||
console.log(`Saved game path=${idStr} -> id=${foundId}`);
|
||||
if (nameCandidate) {
|
||||
try {
|
||||
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id: foundId, name: nameCandidate } });
|
||||
await db.sequelize.query("UPDATE games SET name=:name WHERE id=:id", {
|
||||
replacements: { id: foundId, name: nameCandidate },
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
@ -81,28 +83,32 @@ async function main() {
|
||||
} else {
|
||||
// ensure state column exists before inserting a new row
|
||||
try {
|
||||
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
|
||||
await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT");
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
const payload = JSON.stringify(state);
|
||||
const payload = JSON.stringify(game);
|
||||
if (nameCandidate) {
|
||||
await db.sequelize.query('INSERT INTO games (path, state, name) VALUES(:path, :state, :name)', { replacements: { path: idStr, state: payload, name: nameCandidate } });
|
||||
await db.sequelize.query("INSERT INTO games (path, state, name) VALUES(:path, :state, :name)", {
|
||||
replacements: { path: idStr, state: payload, name: nameCandidate },
|
||||
});
|
||||
} else {
|
||||
await db.sequelize.query('INSERT INTO games (path, state) VALUES(:path, :state)', { replacements: { path: idStr, state: payload } });
|
||||
await db.sequelize.query("INSERT INTO games (path, state) VALUES(:path, :state)", {
|
||||
replacements: { path: idStr, state: payload },
|
||||
});
|
||||
}
|
||||
console.log(`Inserted game path=${idStr}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save game', idStr, e);
|
||||
console.error("Failed to save game", idStr, e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to read/parse', full, e);
|
||||
console.error("Failed to read/parse", full, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Import complete');
|
||||
console.log("Import complete");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env ts-node
|
||||
import { initGameDB } from '../routes/games/store';
|
||||
import { gameDB } from "../routes/games/store";
|
||||
|
||||
type Args = {
|
||||
gameId?: string;
|
||||
@ -10,8 +10,8 @@ function parseArgs(): Args {
|
||||
const res: Args = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if ((a === '-g' || a === '--game') && args[i+1]) {
|
||||
res.gameId = String(args[i+1]);
|
||||
if ((a === "-g" || a === "--game") && args[i + 1]) {
|
||||
res.gameId = String(args[i + 1]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
@ -21,57 +21,56 @@ function parseArgs(): Args {
|
||||
async function main() {
|
||||
const { gameId } = parseArgs();
|
||||
|
||||
let db: any;
|
||||
try {
|
||||
db = await initGameDB();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize game DB:', e);
|
||||
process.exit(1);
|
||||
if (!gameDB.db) {
|
||||
await gameDB.init();
|
||||
}
|
||||
let db = gameDB.db;
|
||||
|
||||
if (!db || !db.sequelize) {
|
||||
console.error('DB does not expose sequelize; cannot run queries.');
|
||||
if (!db.sequelize) {
|
||||
console.error("DB does not expose sequelize; cannot run queries.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!gameId) {
|
||||
// List all game ids
|
||||
try {
|
||||
const rows: any[] = await db.sequelize.query('SELECT id, name FROM games', { type: db.Sequelize.QueryTypes.SELECT });
|
||||
const rows: any[] = await db.sequelize.query("SELECT id, name FROM games", {
|
||||
type: db.Sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
if (!rows || rows.length === 0) {
|
||||
console.log('No games found.');
|
||||
console.log("No games found.");
|
||||
return;
|
||||
}
|
||||
console.log('Games:');
|
||||
rows.forEach(r => console.log(`${r.id} - ${r.name}`));
|
||||
console.log("Games:");
|
||||
rows.forEach((r) => console.log(`${r.id} - ${r.name}`));
|
||||
} catch (e) {
|
||||
console.error('Failed to list games:', e);
|
||||
console.error("Failed to list games:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// For a given game ID, try to print the turns history from the state
|
||||
try {
|
||||
const rows: any[] = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
|
||||
const rows: any[] = await db.sequelize.query("SELECT state FROM games WHERE id=:id", {
|
||||
replacements: { id: gameId },
|
||||
type: db.Sequelize.QueryTypes.SELECT
|
||||
type: db.Sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
if (!rows || rows.length === 0) {
|
||||
console.error('Game not found:', gameId);
|
||||
console.error("Game not found:", gameId);
|
||||
process.exit(2);
|
||||
}
|
||||
const r = rows[0] as any;
|
||||
let state = r.state;
|
||||
if (typeof state === 'string') {
|
||||
if (typeof state === "string") {
|
||||
try {
|
||||
state = JSON.parse(state);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored state JSON:', e);
|
||||
console.error("Failed to parse stored state JSON:", e);
|
||||
process.exit(3);
|
||||
}
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
console.error('Empty state for game', gameId);
|
||||
console.error("Empty state for game", gameId);
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
@ -79,21 +78,21 @@ async function main() {
|
||||
console.log(` - turns: ${state.turns || 0}`);
|
||||
if (state.turnHistory || state.turnsData || state.turns_list) {
|
||||
const turns = state.turnHistory || state.turnsData || state.turns_list;
|
||||
console.log('Turns:');
|
||||
console.log("Turns:");
|
||||
turns.forEach((t: any, idx: number) => {
|
||||
console.log(`${idx}: ${JSON.stringify(t)}`);
|
||||
});
|
||||
} else if (state.turns && state.turns > 0) {
|
||||
console.log('No explicit turn history found inside state; showing snapshot metadata.');
|
||||
console.log("No explicit turn history found inside state; showing snapshot metadata.");
|
||||
// Print limited snapshot details per turn if available
|
||||
if (state.turnsData) {
|
||||
state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`));
|
||||
}
|
||||
} else {
|
||||
console.log('No turn history recorded in state.');
|
||||
console.log("No turn history recorded in state.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load game state for', gameId, e);
|
||||
console.error("Failed to load game state for", gameId, e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
@ -1,73 +1,81 @@
|
||||
"use strict";
|
||||
|
||||
import { Tile } from "../routes/games/types";
|
||||
import { ResourceType } from "../routes/games/types";
|
||||
|
||||
/* Board Tiles:
|
||||
* 0 1 2
|
||||
* 3 4 5 6
|
||||
* 7 8 9 10 11
|
||||
* 12 13 14 15
|
||||
* 16 17 18
|
||||
*/
|
||||
* 0 1 2
|
||||
* 3 4 5 6
|
||||
* 7 8 9 10 11
|
||||
* 12 13 14 15
|
||||
* 16 17 18
|
||||
*/
|
||||
|
||||
/*
|
||||
* c0
|
||||
* /\
|
||||
* r0 / \r1
|
||||
* c6 / \ c1
|
||||
* | |
|
||||
* | |
|
||||
* r6| p,a | r2
|
||||
* c5| | c3
|
||||
* \ /
|
||||
* \ /
|
||||
* r5 \ / r3
|
||||
* \/
|
||||
* c4
|
||||
*/
|
||||
* c4
|
||||
*/
|
||||
|
||||
/* |
|
||||
* 0 1 2 |
|
||||
* 1| 3| 5|
|
||||
* \. / \ / \ / \ 3
|
||||
* \. 0/ 1\ 3/ 4\ 6/ 7\
|
||||
* \./ \ / \ / \
|
||||
* 0| 2| 4| |6
|
||||
* 17 2| 0 5| 1 8| 2 |9 4
|
||||
* 8| 10| 12| |14
|
||||
* / \ / \ / \ / \
|
||||
* 10/ 11\ 13/ 14\ 16/ 17\ 19/ 20\
|
||||
* / \ / \ / \ / \
|
||||
* 7| 9| 11| 13| |15
|
||||
* 16 12| 3 15| 4 18| 5 21| 6 |22
|
||||
* 17| 19| 21| 23| |25 5
|
||||
* / \ / \ / \ / \ / \ ,/
|
||||
* 23/ 24\ 26/ 27\ 29/ 30\ 32/ 33\ 35/ 36\ ,/
|
||||
* / \ / \ / \ / \ / \ ,/
|
||||
* 16| 18| 20| 22| 24| |26
|
||||
* 15 25| 7 28| 8 31| 9 34| 10 37| 11 |38 6
|
||||
* 27| 29| 31| 33| 35| |37
|
||||
* /' \ / \ / \ / \ / \ /
|
||||
* /' 39\ 40/ 41\ 43/ 44\ 46/ 47\ 49/ 50\ /53
|
||||
* /' \ / \ / \ / \ / \ / 7
|
||||
* 28| 30| 32| 34| |36
|
||||
* 14 42| 12 45| 13 48| 14 51| 15 |52
|
||||
* 38| 40| 42| 44| |46
|
||||
* \ / \ / \ / \ /
|
||||
* 54\ 55/ 56\ 58/ 59\ 61/ 62\ /65
|
||||
* \ / \ / \ / \ / 8
|
||||
* 39| 41| 43| |45
|
||||
* 13 57| 16 60| 17 63| 18 |64
|
||||
* 47| 49| 51| |53
|
||||
* \ / \ / \ / `\
|
||||
* 66\ 67/ 68\ 69/ 70\ /71 `\
|
||||
* \ / \ / \ / `\
|
||||
* 48| 50| 52| 9
|
||||
* |
|
||||
* 12 | 11 10
|
||||
*/
|
||||
const Tile = (corners: number[], roads: number[]) => {
|
||||
* 0 1 2 |
|
||||
* 1| 3| 5|
|
||||
* \. / \ / \ / \ 3
|
||||
* \. 0/ 1\ 3/ 4\ 6/ 7\
|
||||
* \./ \ / \ / \
|
||||
* 0| 2| 4| |6
|
||||
* 17 2| 0 5| 1 8| 2 |9 4
|
||||
* 8| 10| 12| |14
|
||||
* / \ / \ / \ / \
|
||||
* 10/ 11\ 13/ 14\ 16/ 17\ 19/ 20\
|
||||
* / \ / \ / \ / \
|
||||
* 7| 9| 11| 13| |15
|
||||
* 16 12| 3 15| 4 18| 5 21| 6 |22
|
||||
* 17| 19| 21| 23| |25 5
|
||||
* / \ / \ / \ / \ / \ ,/
|
||||
* 23/ 24\ 26/ 27\ 29/ 30\ 32/ 33\ 35/ 36\ ,/
|
||||
* / \ / \ / \ / \ / \ ,/
|
||||
* 16| 18| 20| 22| 24| |26
|
||||
* 15 25| 7 28| 8 31| 9 34| 10 37| 11 |38 6
|
||||
* 27| 29| 31| 33| 35| |37
|
||||
* /' \ / \ / \ / \ / \ /
|
||||
* /' 39\ 40/ 41\ 43/ 44\ 46/ 47\ 49/ 50\ /53
|
||||
* /' \ / \ / \ / \ / \ / 7
|
||||
* 28| 30| 32| 34| |36
|
||||
* 14 42| 12 45| 13 48| 14 51| 15 |52
|
||||
* 38| 40| 42| 44| |46
|
||||
* \ / \ / \ / \ /
|
||||
* 54\ 55/ 56\ 58/ 59\ 61/ 62\ /65
|
||||
* \ / \ / \ / \ / 8
|
||||
* 39| 41| 43| |45
|
||||
* 13 57| 16 60| 17 63| 18 |64
|
||||
* 47| 49| 51| |53
|
||||
* \ / \ / \ / `\
|
||||
* 66\ 67/ 68\ 69/ 70\ /71 `\
|
||||
* \ / \ / \ / `\
|
||||
* 48| 50| 52| 9
|
||||
* |
|
||||
* 12 | 11 10
|
||||
*/
|
||||
const newTile = (corners: number[], roads: number[]): Tile => {
|
||||
return {
|
||||
corners: corners, /* 6 */
|
||||
robber: false,
|
||||
index: -1,
|
||||
type: "desert",
|
||||
resource: null,
|
||||
roll: null,
|
||||
corners: corners /* 6 */,
|
||||
pip: -1,
|
||||
roads: roads,
|
||||
asset: -1
|
||||
asset: -1,
|
||||
};
|
||||
};
|
||||
|
||||
@ -76,44 +84,44 @@ const Tile = (corners: number[], roads: number[]) => {
|
||||
|
||||
const Corner = (roads: number[], banks: number[]) => {
|
||||
return {
|
||||
roads: roads, /* max of 3 */
|
||||
banks: banks, /* max of 2 */
|
||||
data: undefined
|
||||
roads: roads /* max of 3 */,
|
||||
banks: banks /* max of 2 */,
|
||||
data: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const Road = (corners: number[]) => {
|
||||
return {
|
||||
corners: corners, /* 2 */
|
||||
data: undefined
|
||||
}
|
||||
corners: corners /* 2 */,
|
||||
data: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const layout = {
|
||||
tiles: [
|
||||
Tile([ 0, 1, 2, 10, 9, 8], [ 0, 1, 5, 13, 11, 2]),
|
||||
Tile([ 2, 3, 4, 12, 11, 10], [ 3, 4, 8, 16, 14, 5]),
|
||||
Tile([ 4, 5, 6, 14, 13, 12], [ 6, 7, 9, 19, 17, 8]),
|
||||
newTile([0, 1, 2, 10, 9, 8], [0, 1, 5, 13, 11, 2]),
|
||||
newTile([2, 3, 4, 12, 11, 10], [3, 4, 8, 16, 14, 5]),
|
||||
newTile([4, 5, 6, 14, 13, 12], [6, 7, 9, 19, 17, 8]),
|
||||
|
||||
Tile([ 7, 8, 9, 19, 18, 17], [ 10, 11, 15, 26, 24, 12]),
|
||||
Tile([ 9, 10, 11, 21, 20, 19], [ 13, 14, 18, 29, 27, 15]),
|
||||
Tile([ 11, 12, 13, 23, 22, 21], [ 16, 17, 21, 32, 30, 18]),
|
||||
Tile([ 13, 14, 15, 25, 24, 23], [ 19, 20, 22, 35, 33, 21]),
|
||||
newTile([7, 8, 9, 19, 18, 17], [10, 11, 15, 26, 24, 12]),
|
||||
newTile([9, 10, 11, 21, 20, 19], [13, 14, 18, 29, 27, 15]),
|
||||
newTile([11, 12, 13, 23, 22, 21], [16, 17, 21, 32, 30, 18]),
|
||||
newTile([13, 14, 15, 25, 24, 23], [19, 20, 22, 35, 33, 21]),
|
||||
|
||||
Tile([ 16, 17, 18, 29, 28, 27], [ 23, 24, 28, 40, 39, 25]),
|
||||
Tile([ 18, 19, 20, 31, 30, 29], [ 26, 27, 31, 43, 41, 28]),
|
||||
Tile([ 20, 21, 22, 33, 32, 31], [ 29, 30, 34, 46, 44, 31]),
|
||||
Tile([ 22, 23, 24, 35, 34, 33], [ 32, 33, 37, 49, 47, 34]),
|
||||
Tile([ 24, 25, 26, 37, 36, 35], [ 35, 36, 38, 53, 50, 37]),
|
||||
newTile([16, 17, 18, 29, 28, 27], [23, 24, 28, 40, 39, 25]),
|
||||
newTile([18, 19, 20, 31, 30, 29], [26, 27, 31, 43, 41, 28]),
|
||||
newTile([20, 21, 22, 33, 32, 31], [29, 30, 34, 46, 44, 31]),
|
||||
newTile([22, 23, 24, 35, 34, 33], [32, 33, 37, 49, 47, 34]),
|
||||
newTile([24, 25, 26, 37, 36, 35], [35, 36, 38, 53, 50, 37]),
|
||||
|
||||
Tile([ 28, 29, 30, 40, 39, 38], [ 40, 41, 45, 55, 54, 42]),
|
||||
Tile([ 30, 31, 32, 42, 41, 40], [ 43, 44, 48, 58, 56, 45]),
|
||||
Tile([ 32, 33, 34, 44, 43, 42], [ 46, 47, 51, 61, 59, 48]),
|
||||
Tile([ 34, 35, 36, 46, 45, 44], [ 49, 50, 52, 65, 62, 51]),
|
||||
newTile([28, 29, 30, 40, 39, 38], [40, 41, 45, 55, 54, 42]),
|
||||
newTile([30, 31, 32, 42, 41, 40], [43, 44, 48, 58, 56, 45]),
|
||||
newTile([32, 33, 34, 44, 43, 42], [46, 47, 51, 61, 59, 48]),
|
||||
newTile([34, 35, 36, 46, 45, 44], [49, 50, 52, 65, 62, 51]),
|
||||
|
||||
Tile([ 39, 40, 41, 49, 48, 47], [ 55, 56, 60, 67, 66, 57]),
|
||||
Tile([ 41, 42, 43, 51, 50, 49], [ 58, 59, 63, 69, 68, 60]),
|
||||
Tile([ 43, 44, 45, 53, 52, 51], [ 61, 62, 64, 71, 70, 63])
|
||||
newTile([39, 40, 41, 49, 48, 47], [55, 56, 60, 67, 66, 57]),
|
||||
newTile([41, 42, 43, 51, 50, 49], [58, 59, 63, 69, 68, 60]),
|
||||
newTile([43, 44, 45, 53, 52, 51], [61, 62, 64, 71, 70, 63]),
|
||||
],
|
||||
roads: [
|
||||
/* 0 */
|
||||
@ -140,7 +148,7 @@ const layout = {
|
||||
Road([14, 13]),
|
||||
/* 20 */
|
||||
Road([14, 15]),
|
||||
Road([13,23 ]),
|
||||
Road([13, 23]),
|
||||
Road([15, 25]),
|
||||
Road([17, 16]),
|
||||
Road([17, 18]),
|
||||
@ -154,113 +162,117 @@ const layout = {
|
||||
Road([20, 31]),
|
||||
Road([23, 22]),
|
||||
Road([23, 24]),
|
||||
Road([22,33]),
|
||||
Road([25,24]),
|
||||
Road([25,26]),
|
||||
Road([22, 33]),
|
||||
Road([25, 24]),
|
||||
Road([25, 26]),
|
||||
Road([24, 35]),
|
||||
Road([26,37]),
|
||||
Road([27,28]),
|
||||
Road([26, 37]),
|
||||
Road([27, 28]),
|
||||
/* 40 */
|
||||
Road([29,28]),
|
||||
Road([29,30]),
|
||||
Road([28,38]),
|
||||
Road([31,30]),
|
||||
Road([31,32]),
|
||||
Road([30,40]),
|
||||
Road([33,32]),
|
||||
Road([33,34]),
|
||||
Road([32,42]),
|
||||
Road([35,34]),
|
||||
Road([29, 28]),
|
||||
Road([29, 30]),
|
||||
Road([28, 38]),
|
||||
Road([31, 30]),
|
||||
Road([31, 32]),
|
||||
Road([30, 40]),
|
||||
Road([33, 32]),
|
||||
Road([33, 34]),
|
||||
Road([32, 42]),
|
||||
Road([35, 34]),
|
||||
/* 50 */
|
||||
Road([35,36]),
|
||||
Road([34,44]),
|
||||
Road([36,46]),
|
||||
Road([37,36]),
|
||||
Road([38,39]),
|
||||
Road([40,39]),
|
||||
Road([40,41]),
|
||||
Road([39,47]),
|
||||
Road([41,42]),
|
||||
Road([42,43]),
|
||||
Road([35, 36]),
|
||||
Road([34, 44]),
|
||||
Road([36, 46]),
|
||||
Road([37, 36]),
|
||||
Road([38, 39]),
|
||||
Road([40, 39]),
|
||||
Road([40, 41]),
|
||||
Road([39, 47]),
|
||||
Road([41, 42]),
|
||||
Road([42, 43]),
|
||||
/* 60 */
|
||||
Road([41,49]),
|
||||
Road([44,43]),
|
||||
Road([44,45]),
|
||||
Road([43,51]),
|
||||
Road([45,53]),
|
||||
Road([46,45]),
|
||||
Road([47,48]),
|
||||
Road([49,48]),
|
||||
Road([49,50]),
|
||||
Road([51,50]),
|
||||
Road([41, 49]),
|
||||
Road([44, 43]),
|
||||
Road([44, 45]),
|
||||
Road([43, 51]),
|
||||
Road([45, 53]),
|
||||
Road([46, 45]),
|
||||
Road([47, 48]),
|
||||
Road([49, 48]),
|
||||
Road([49, 50]),
|
||||
Road([51, 50]),
|
||||
/* 70 */
|
||||
Road([51,52]),
|
||||
Road([53,52]),
|
||||
Road([51, 52]),
|
||||
Road([53, 52]),
|
||||
],
|
||||
corners: [
|
||||
/* 0 */
|
||||
/* 0 */ Corner([2, 0],[0]),
|
||||
/* 1 */ Corner([0, 1],[0]),
|
||||
/* 2 */ Corner([1,3,5],[1]),
|
||||
/* 3 */ Corner([3,4],[1,2]),
|
||||
/* 4 */ Corner([8,4,6],[2]),
|
||||
/* 5 */ Corner([6,7],[3]),
|
||||
/* 6 */ Corner([7,9],[3]),
|
||||
/* 7 */ Corner([12,10],[16,17]),
|
||||
/* 8 */ Corner([2,10,11],[17]),
|
||||
/* 9 */ Corner([11,13,15],[]),
|
||||
/* 0 */ Corner([2, 0], [0]),
|
||||
/* 1 */ Corner([0, 1], [0]),
|
||||
/* 2 */ Corner([1, 3, 5], [1]),
|
||||
/* 3 */ Corner([3, 4], [1, 2]),
|
||||
/* 4 */ Corner([8, 4, 6], [2]),
|
||||
/* 5 */ Corner([6, 7], [3]),
|
||||
/* 6 */ Corner([7, 9], [3]),
|
||||
/* 7 */ Corner([12, 10], [16, 17]),
|
||||
/* 8 */ Corner([2, 10, 11], [17]),
|
||||
/* 9 */ Corner([11, 13, 15], []),
|
||||
/* 10 */
|
||||
/* 10 */ Corner([5,13,14],[]),
|
||||
/* 11 */ Corner([14,16,18],[]),
|
||||
/* 12 */ Corner([8,16,17],[]),
|
||||
/* 13 */ Corner([17,19,21],[]),
|
||||
/* 14 */ Corner([9,19,20],[4]),
|
||||
/* 15 */ Corner([20,22],[4,5]),
|
||||
/* 16 */ Corner([23,25],[15]),
|
||||
/* 17 */ Corner([12,23,24],[16]),
|
||||
/* 18 */ Corner([24,26,28],[]),
|
||||
/* 19 */ Corner([15,26,27],[]),
|
||||
/* 10 */ Corner([5, 13, 14], []),
|
||||
/* 11 */ Corner([14, 16, 18], []),
|
||||
/* 12 */ Corner([8, 16, 17], []),
|
||||
/* 13 */ Corner([17, 19, 21], []),
|
||||
/* 14 */ Corner([9, 19, 20], [4]),
|
||||
/* 15 */ Corner([20, 22], [4, 5]),
|
||||
/* 16 */ Corner([23, 25], [15]),
|
||||
/* 17 */ Corner([12, 23, 24], [16]),
|
||||
/* 18 */ Corner([24, 26, 28], []),
|
||||
/* 19 */ Corner([15, 26, 27], []),
|
||||
/* 20 */
|
||||
/* 20 */ Corner([27,29,31],[]),
|
||||
/* 21 */ Corner([18,29,30],[]),
|
||||
/* 22 */ Corner([30,32,34],[]),
|
||||
/* 23 */ Corner([21,32,33],[]),
|
||||
/* 24 */ Corner([33,35,37],[]),
|
||||
/* 25 */ Corner([22,35,36],[5]),
|
||||
/* 26 */ Corner([36,38],[6]),
|
||||
/* 27 */ Corner([25,39],[15]),
|
||||
/* 28 */ Corner([39,40,42],[14]),
|
||||
/* 29 */ Corner([28,40,41],[]),
|
||||
/* 20 */ Corner([27, 29, 31], []),
|
||||
/* 21 */ Corner([18, 29, 30], []),
|
||||
/* 22 */ Corner([30, 32, 34], []),
|
||||
/* 23 */ Corner([21, 32, 33], []),
|
||||
/* 24 */ Corner([33, 35, 37], []),
|
||||
/* 25 */ Corner([22, 35, 36], [5]),
|
||||
/* 26 */ Corner([36, 38], [6]),
|
||||
/* 27 */ Corner([25, 39], [15]),
|
||||
/* 28 */ Corner([39, 40, 42], [14]),
|
||||
/* 29 */ Corner([28, 40, 41], []),
|
||||
/* 30 */
|
||||
/* 30 */ Corner([41,43,45],[]),
|
||||
/* 31 */ Corner([31,43,44],[]),
|
||||
/* 32 */ Corner([44,46,48],[]),
|
||||
/* 33 */ Corner([34,46,47],[]),
|
||||
/* 34 */ Corner([47,49,51],[]),
|
||||
/* 35 */ Corner([37,49,50],[]),
|
||||
/* 36 */ Corner([50,53,52],[7]),
|
||||
/* 37 */ Corner([38,53],[6]),
|
||||
/* 38 */ Corner([42,54],[14,13]),
|
||||
/* 39 */ Corner([54,55,57],[13]),
|
||||
/* 30 */ Corner([41, 43, 45], []),
|
||||
/* 31 */ Corner([31, 43, 44], []),
|
||||
/* 32 */ Corner([44, 46, 48], []),
|
||||
/* 33 */ Corner([34, 46, 47], []),
|
||||
/* 34 */ Corner([47, 49, 51], []),
|
||||
/* 35 */ Corner([37, 49, 50], []),
|
||||
/* 36 */ Corner([50, 53, 52], [7]),
|
||||
/* 37 */ Corner([38, 53], [6]),
|
||||
/* 38 */ Corner([42, 54], [14, 13]),
|
||||
/* 39 */ Corner([54, 55, 57], [13]),
|
||||
/* 40 */
|
||||
/* 40 */ Corner([45,55,56],[]),
|
||||
/* 41 */ Corner([56,58,60],[]),
|
||||
/* 42 */ Corner([48,58,59],[]),
|
||||
/* 43 */ Corner([59,61,63],[]),
|
||||
/* 44 */ Corner([51,61,62],[]),
|
||||
/* 45 */ Corner([62,65,64],[8]),
|
||||
/* 46 */ Corner([52,65],[7,8]),
|
||||
/* 47 */ Corner([57,66],[12]),
|
||||
/* 48 */ Corner([67,66],[12]),
|
||||
/* 49 */ Corner([60,67,68],[11]),
|
||||
/* 40 */ Corner([45, 55, 56], []),
|
||||
/* 41 */ Corner([56, 58, 60], []),
|
||||
/* 42 */ Corner([48, 58, 59], []),
|
||||
/* 43 */ Corner([59, 61, 63], []),
|
||||
/* 44 */ Corner([51, 61, 62], []),
|
||||
/* 45 */ Corner([62, 65, 64], [8]),
|
||||
/* 46 */ Corner([52, 65], [7, 8]),
|
||||
/* 47 */ Corner([57, 66], [12]),
|
||||
/* 48 */ Corner([67, 66], [12]),
|
||||
/* 49 */ Corner([60, 67, 68], [11]),
|
||||
/* 50 */
|
||||
/* 50 */ Corner([68,69],[11,10]),
|
||||
/* 51 */ Corner([69,70,63],[10]),
|
||||
/* 52 */ Corner([70,71],[9]),
|
||||
/* 53 */ Corner([64,71],[9]),
|
||||
]
|
||||
/* 50 */ Corner([68, 69], [11, 10]),
|
||||
/* 51 */ Corner([69, 70, 63], [10]),
|
||||
/* 52 */ Corner([70, 71], [9]),
|
||||
/* 53 */ Corner([64, 71], [9]),
|
||||
],
|
||||
};
|
||||
|
||||
interface StaticDataTile {
|
||||
type: ResourceType;
|
||||
card: number;
|
||||
}
|
||||
const staticData = {
|
||||
tiles: [
|
||||
{ type: "desert", card: 0 },
|
||||
@ -281,8 +293,8 @@ const staticData = {
|
||||
{ type: "sheep", card: 3 },
|
||||
{ type: "brick", card: 0 },
|
||||
{ type: "brick", card: 1 },
|
||||
{ type: "brick", card: 2 }
|
||||
],
|
||||
{ type: "brick", card: 2 },
|
||||
] as StaticDataTile[],
|
||||
pips: [
|
||||
{ roll: 5, pips: 4 },
|
||||
{ roll: 2, pips: 1 },
|
||||
@ -302,16 +314,16 @@ const staticData = {
|
||||
{ roll: 6, pips: 5 },
|
||||
{ roll: 3, pips: 2 },
|
||||
{ roll: 11, pips: 2 },
|
||||
{ roll: 7, pips: 0 }, /* Robber is at the end or indexing gets off */
|
||||
{ roll: 7, pips: 0 } /* Robber is at the end or indexing gets off */,
|
||||
],
|
||||
borders: [
|
||||
["bank", undefined, "sheep"],
|
||||
[undefined, "bank", undefined],
|
||||
["bank", undefined, "brick"],
|
||||
[undefined, "wood", undefined],
|
||||
["bank", undefined, "wheat"],
|
||||
[undefined, "stone", undefined]
|
||||
]
|
||||
["bank", "none", "sheep"],
|
||||
["none", "bank", "none"],
|
||||
["bank", "none", "brick"],
|
||||
["none", "wood", "none"],
|
||||
["bank", "none", "wheat"],
|
||||
["none", "stone", "none"],
|
||||
] as ResourceType[][],
|
||||
};
|
||||
|
||||
export {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { layout } from './layout';
|
||||
import { CornerType, Game, PlayerColor } from "../routes/games/types";
|
||||
import { layout } from "./layout";
|
||||
|
||||
const isRuleEnabled = (game: any, rule: string): boolean => {
|
||||
return rule in game.rules && game.rules[rule].enabled;
|
||||
@ -12,19 +13,33 @@ const getValidRoads = (game: any, color: string): number[] => {
|
||||
* has a matching color, add this to the set. Otherwise skip.
|
||||
*/
|
||||
layout.roads.forEach((road, roadIndex) => {
|
||||
if (!game.placements || !game.placements.roads || game.placements.roads[roadIndex]?.color) {
|
||||
// Skip if placements or roads missing, or if this road is already occupied
|
||||
// Treat the explicit sentinel "unassigned" as "not available" so only
|
||||
// consider a road occupied when a color is present and not "unassigned".
|
||||
if (
|
||||
!game.placements ||
|
||||
!game.placements.roads ||
|
||||
(game.placements.roads[roadIndex] &&
|
||||
game.placements.roads[roadIndex].color &&
|
||||
game.placements.roads[roadIndex].color !== "unassigned")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let valid = false;
|
||||
for (let c = 0; !valid && c < road.corners.length; c++) {
|
||||
for (let c = 0; !valid && c < road.corners.length; c++) {
|
||||
const cornerIndex = road.corners[c] as number;
|
||||
if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) {
|
||||
continue;
|
||||
}
|
||||
const corner = (layout as any).corners[cornerIndex];
|
||||
const cornerColor = (game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cornerIndex] && (game as any).placements.corners[cornerIndex].color;
|
||||
/* Roads do not pass through other player's settlements */
|
||||
if (cornerColor && cornerColor !== color) {
|
||||
const cornerColor =
|
||||
(game as any).placements &&
|
||||
(game as any).placements.corners &&
|
||||
(game as any).placements.corners[cornerIndex] &&
|
||||
(game as any).placements.corners[cornerIndex].color;
|
||||
/* Roads do not pass through other player's settlements.
|
||||
* Consider a corner with color === "unassigned" as empty. */
|
||||
if (cornerColor && cornerColor !== "unassigned" && cornerColor !== color) {
|
||||
continue;
|
||||
}
|
||||
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
|
||||
@ -33,7 +48,9 @@ const getValidRoads = (game: any, color: string): number[] => {
|
||||
continue;
|
||||
}
|
||||
const rr = corner.roads[r];
|
||||
if (rr == null) { continue; }
|
||||
if (rr == null) {
|
||||
continue;
|
||||
}
|
||||
const placementsRoads = (game as any).placements && (game as any).placements.roads;
|
||||
if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) {
|
||||
valid = true;
|
||||
@ -46,89 +63,111 @@ const getValidRoads = (game: any, color: string): number[] => {
|
||||
});
|
||||
|
||||
return limits;
|
||||
}
|
||||
};
|
||||
|
||||
const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
||||
const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: CornerType): number[] => {
|
||||
const limits: number[] = [];
|
||||
|
||||
console.log("getValidCorners", color, type);
|
||||
/* For each corner, if the corner already has a color set, skip it if type
|
||||
* isn't set. If type is set, if it is a match, and the color is a match,
|
||||
* isn't set. If type is set and is a match, and the color is a match,
|
||||
* add it to the list.
|
||||
*
|
||||
*
|
||||
* If we are limiting based on active player, a corner is only valid
|
||||
* 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
|
||||
* 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
|
||||
* Volcano is enabled, verify the tile is not the Volcano.
|
||||
*/
|
||||
layout.corners.forEach((corner, cornerIndex) => {
|
||||
const placement = game.placements.corners[cornerIndex];
|
||||
const placement = game.placements && game.placements.corners ? game.placements.corners[cornerIndex] : undefined;
|
||||
if (!placement) {
|
||||
// Treat a missing placement as unassigned (no owner)
|
||||
// Continue processing using a falsy placement where appropriate
|
||||
}
|
||||
if (type) {
|
||||
if (placement.color === color && placement.type === type) {
|
||||
if (placement && placement.color === color && placement.type === type) {
|
||||
limits.push(cornerIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.color) {
|
||||
// If the corner has a color set and it's not the explicit sentinel
|
||||
// "unassigned" then it's occupied and should be skipped.
|
||||
// Note: placement.color may be undefined (initial state), treat that
|
||||
// the same as unassigned.
|
||||
if (placement && placement.color && placement.color !== "unassigned") {
|
||||
return;
|
||||
}
|
||||
|
||||
let valid;
|
||||
if (!color) {
|
||||
// Treat either a falsy color (""/undefined) or the explicit sentinel
|
||||
// "unassigned" as meaning "do not filter by player".
|
||||
if (!color || color === "unassigned") {
|
||||
valid = true; /* Not filtering based on current player */
|
||||
} else {
|
||||
valid = false;
|
||||
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
|
||||
const rr = corner.roads[r];
|
||||
if (rr == null) { continue; }
|
||||
const placementsRoads = (game as any).placements && (game as any).placements.roads;
|
||||
if (rr == null) {
|
||||
continue;
|
||||
}
|
||||
const placementsRoads = game.placements && game.placements.roads;
|
||||
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
|
||||
}
|
||||
}
|
||||
|
||||
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
|
||||
if (!corner.roads) { break; }
|
||||
const ridx = corner.roads[r] as number;
|
||||
if (ridx == null || (layout as any).roads[ridx] == null) { continue; }
|
||||
const road = (layout as any).roads[ridx];
|
||||
for (let c = 0; valid && c < (road.corners || []).length; c++) {
|
||||
/* This side of the road is pointing to the corner being validated.
|
||||
* Skip it. */
|
||||
if (road.corners[c] === cornerIndex) {
|
||||
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
|
||||
if (!corner.roads) {
|
||||
break;
|
||||
}
|
||||
const ridx = corner.roads[r];
|
||||
if (ridx == null || layout.roads[ridx] == null) {
|
||||
continue;
|
||||
}
|
||||
/* There is a settlement within one segment from this
|
||||
* corner, so it is invalid for settlement placement */
|
||||
const cc = road.corners[c] as number;
|
||||
if ((game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cc] && (game as any).placements.corners[cc].color) {
|
||||
valid = false;
|
||||
const road = layout.roads[ridx];
|
||||
for (let c = 0; valid && c < (road.corners || []).length; c++) {
|
||||
/* This side of the road is pointing to the corner being validated.
|
||||
* Skip it. */
|
||||
if (road.corners[c] === cornerIndex) {
|
||||
continue;
|
||||
}
|
||||
/* There is a settlement within one segment from this
|
||||
* corner, so it is invalid for settlement placement */
|
||||
const cc = road.corners[c] as number;
|
||||
const ccPlacement = game.placements && game.placements.corners ? game.placements.corners[cc] : undefined;
|
||||
const ccColor = ccPlacement ? ccPlacement.color : undefined;
|
||||
if (ccColor && ccColor !== "unassigned") {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
/* During initial placement, if volcano is enabled, do not allow
|
||||
* placement on a corner connected to the volcano (robber starts
|
||||
* on the volcano) */
|
||||
if (!(game.state === 'initial-placement'
|
||||
&& isRuleEnabled(game, 'volcano')
|
||||
&& (layout as any).tiles && (layout as any).tiles[(game as any).robber] && Array.isArray((layout as any).tiles[(game as any).robber].corners) && (layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1
|
||||
)) {
|
||||
if (
|
||||
!(
|
||||
game.state === "initial-placement" &&
|
||||
isRuleEnabled(game, "volcano") &&
|
||||
game.robber &&
|
||||
layout.tiles &&
|
||||
layout.tiles[game.robber] &&
|
||||
Array.isArray(layout.tiles[game.robber]?.corners) &&
|
||||
layout.tiles[game.robber]?.corners.indexOf(cornerIndex as number) !== -1
|
||||
)
|
||||
) {
|
||||
limits.push(cornerIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return limits;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
getValidCorners,
|
||||
getValidRoads,
|
||||
isRuleEnabled
|
||||
};
|
||||
export { getValidCorners, getValidRoads, isRuleEnabled };
|
||||
|