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