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