Compare commits
13 Commits
888688a019
...
f1580970f9
Author | SHA1 | Date | |
---|---|---|---|
f1580970f9 | |||
45e01d5e89 | |||
5acf71b2a3 | |||
b3386bab1d | |||
1469282199 | |||
69ccaa7560 | |||
bc8c1b1c54 | |||
c0e9b9a23c | |||
2c15f57ca9 | |||
873c229275 | |||
7df6a9a75c | |||
70bbaed6e5 | |||
a2cb68b421 |
@ -7,13 +7,6 @@ import "./Actions.css";
|
|||||||
import { PlayerName } from "./PlayerName";
|
import { PlayerName } from "./PlayerName";
|
||||||
import { GlobalContext } from "./GlobalContext";
|
import { GlobalContext } from "./GlobalContext";
|
||||||
|
|
||||||
type LocalGlobalContext = {
|
|
||||||
ws?: WebSocket | null;
|
|
||||||
gameId?: string | null;
|
|
||||||
name?: string | undefined;
|
|
||||||
sendJsonMessage?: (message: any) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PrivateData = {
|
type PrivateData = {
|
||||||
orderRoll?: boolean;
|
orderRoll?: boolean;
|
||||||
resources?: number;
|
resources?: number;
|
||||||
@ -50,25 +43,28 @@ const Actions: React.FC<ActionsProps> = ({
|
|||||||
houseRulesActive,
|
houseRulesActive,
|
||||||
setHouseRulesActive,
|
setHouseRulesActive,
|
||||||
}) => {
|
}) => {
|
||||||
console.log("Actions component rendered");
|
const { lastJsonMessage, sendJsonMessage, name, roomName } = useContext(GlobalContext);
|
||||||
const ctx = useContext(GlobalContext) as LocalGlobalContext;
|
|
||||||
const ws = ctx.ws ?? null;
|
|
||||||
const gameId = ctx.gameId ?? null;
|
|
||||||
const name = ctx.name ?? undefined;
|
|
||||||
const [state, setState] = useState<string>("lobby");
|
const [state, setState] = useState<string>("lobby");
|
||||||
const [color, setColor] = useState<string | undefined>(undefined);
|
const [color, setColor] = useState<string | undefined>(undefined);
|
||||||
const [priv, setPriv] = useState<PrivateData | undefined>(undefined);
|
const [priv, setPriv] = useState<PrivateData | undefined>(undefined);
|
||||||
const [turn, setTurn] = useState<TurnData>({});
|
const [turn, setTurn] = useState<TurnData>({});
|
||||||
const [edit, setEdit] = useState<string | undefined>(name);
|
const [edit, setEdit] = useState<string | undefined>(name);
|
||||||
console.log("Actions: name =", name, "edit =", edit);
|
|
||||||
const [active, setActive] = useState<number>(0);
|
const [active, setActive] = useState<number>(0);
|
||||||
const [players, setPlayers] = useState<Record<string, PlayerData>>({});
|
const [players, setPlayers] = useState<Record<string, PlayerData>>({});
|
||||||
const [alive, setAlive] = useState<number>(0);
|
const [alive, setAlive] = useState<number>(0);
|
||||||
|
|
||||||
const fields = useMemo(() => ["state", "turn", "private", "active", "color", "players"], []);
|
const fields = useMemo(() => ["state", "turn", "private", "active", "color", "players"], []);
|
||||||
|
|
||||||
const onWsMessage = (event: MessageEvent) => {
|
useEffect(() => {
|
||||||
const data = JSON.parse(event.data);
|
sendJsonMessage({ type: "get", fields });
|
||||||
|
}, [sendJsonMessage, fields]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastJsonMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = lastJsonMessage;
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "game-update":
|
case "game-update":
|
||||||
console.log(`actions - game update`, data.update);
|
console.log(`actions - game update`, data.update);
|
||||||
@ -99,39 +95,7 @@ const Actions: React.FC<ActionsProps> = ({
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
}, [lastJsonMessage, state, color, edit, turn, active, players, priv]);
|
||||||
|
|
||||||
const refWsMessage = useRef(onWsMessage);
|
|
||||||
useEffect(() => {
|
|
||||||
refWsMessage.current = onWsMessage;
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ctx.ws) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
|
|
||||||
ctx.ws.addEventListener("message", cbMessage as EventListener);
|
|
||||||
return () => {
|
|
||||||
ctx.ws.removeEventListener("message", cbMessage as EventListener);
|
|
||||||
};
|
|
||||||
}, [ctx.ws, refWsMessage]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ctx.sendJsonMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.sendJsonMessage({ type: "get", fields });
|
|
||||||
}, [ctx.sendJsonMessage, fields]);
|
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
|
||||||
(data: Record<string, unknown>) => {
|
|
||||||
if (!ctx.sendJsonMessage) {
|
|
||||||
console.warn(`No sendJsonMessage`);
|
|
||||||
} else {
|
|
||||||
ctx.sendJsonMessage(data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[ctx.sendJsonMessage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildClicked = () => {
|
const buildClicked = () => {
|
||||||
setBuildActive(!buildActive);
|
setBuildActive(!buildActive);
|
||||||
@ -151,7 +115,7 @@ const Actions: React.FC<ActionsProps> = ({
|
|||||||
|
|
||||||
const setName = (update: string) => {
|
const setName = (update: string) => {
|
||||||
if (update !== name) {
|
if (update !== name) {
|
||||||
sendMessage({ type: "player-name", name: update });
|
sendJsonMessage({ type: "player-name", name: update });
|
||||||
}
|
}
|
||||||
setEdit(name);
|
setEdit(name);
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
@ -171,47 +135,47 @@ const Actions: React.FC<ActionsProps> = ({
|
|||||||
discards[t] = (discards[t] || 0) + 1;
|
discards[t] = (discards[t] || 0) + 1;
|
||||||
nodes[i].classList.remove("Selected");
|
nodes[i].classList.remove("Selected");
|
||||||
}
|
}
|
||||||
sendMessage({ type: "discard", discards });
|
sendJsonMessage({ type: "discard", discards });
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const newTableClick = () => {
|
const newTableClick = () => {
|
||||||
sendMessage({ type: "shuffle" });
|
sendJsonMessage({ type: "shuffle" });
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tradeClick = () => {
|
const tradeClick = () => {
|
||||||
if (!tradeActive) {
|
if (!tradeActive) {
|
||||||
setTradeActive(true);
|
setTradeActive(true);
|
||||||
sendMessage({ type: "trade" });
|
sendJsonMessage({ type: "trade" });
|
||||||
} else {
|
} else {
|
||||||
setTradeActive(false);
|
setTradeActive(false);
|
||||||
sendMessage({ type: "trade", action: "cancel", offer: undefined });
|
sendJsonMessage({ type: "trade", action: "cancel", offer: undefined });
|
||||||
}
|
}
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rollClick = () => {
|
const rollClick = () => {
|
||||||
sendMessage({ type: "roll" });
|
sendJsonMessage({ type: "roll" });
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
};
|
};
|
||||||
const passClick = () => {
|
const passClick = () => {
|
||||||
sendMessage({ type: "pass" });
|
sendJsonMessage({ type: "pass" });
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
};
|
};
|
||||||
const houseRulesClick = () => {
|
const houseRulesClick = () => {
|
||||||
setHouseRulesActive(!houseRulesActive);
|
setHouseRulesActive(!houseRulesActive);
|
||||||
};
|
};
|
||||||
const startClick = () => {
|
const startClick = () => {
|
||||||
sendMessage({ type: "set", field: "state", value: "game-order" });
|
sendJsonMessage({ type: "set", field: "state", value: "game-order" });
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
};
|
};
|
||||||
const resetGame = () => {
|
const resetGame = () => {
|
||||||
sendMessage({ type: "clear-game" });
|
sendJsonMessage({ type: "clear-game" });
|
||||||
if (buildActive) setBuildActive(false);
|
if (buildActive) setBuildActive(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!gameId) {
|
if (!roomName) {
|
||||||
return <Paper className="Actions" />;
|
return <Paper className="Actions" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,22 +229,8 @@ const Actions: React.FC<ActionsProps> = ({
|
|||||||
disableRoll = true;
|
disableRoll = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("actions - ", {
|
|
||||||
disableRoll,
|
|
||||||
robberActions,
|
|
||||||
turn,
|
|
||||||
inGame,
|
|
||||||
isTurn,
|
|
||||||
hasRolled,
|
|
||||||
volcanoActive,
|
|
||||||
inGameOrder,
|
|
||||||
hasGameOrderRolled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const disableDone = volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled;
|
const disableDone = volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled;
|
||||||
|
|
||||||
console.log("Actions render: edit =", edit, "name =", name);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="Actions">
|
<Paper className="Actions">
|
||||||
{edit === "" && <PlayerName name={name} setName={setName} />}
|
{edit === "" && <PlayerName name={name} setName={setName} />}
|
||||||
|
@ -1,229 +1,7 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'Droid Sans', 'Arial Narrow', Arial, sans-serif;
|
font-family: 'Droid Sans', 'Arial Narrow', Arial, sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 100vw;
|
|
||||||
/* height: 100vh; breaks on mobile -- not needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
bottom: 0;
|
|
||||||
flex-direction: row;
|
|
||||||
background-image: url("./assets/tabletop.png");
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs {
|
|
||||||
z-index: 10000;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs .Dialog {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
flex-shrink: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.25rem;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 60000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs .Dialog > div {
|
|
||||||
display: flex;
|
|
||||||
padding: 1rem;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs .Dialog > div > div:first-child {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs .TurnNoticeDialog {
|
|
||||||
background-color: #7a680060;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs .ErrorDialog {
|
|
||||||
background-color: #40000060;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs .WarningDialog {
|
|
||||||
background-color: #00000060;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Game {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Board {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
flex-grow: 1;
|
|
||||||
z-index: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .PlayersStatus {
|
|
||||||
z-index: 500; /* Under Hand */
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .PlayersStatus.ActivePlayer {
|
|
||||||
z-index: 1500; /* On top of Hand */
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Hand {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
height: 11rem;
|
|
||||||
z-index: 10000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 25rem;
|
|
||||||
max-width: 25rem;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Sidebar .Chat {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Trade {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
z-index: 25000;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 20000;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Dialogs > * {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .ViewCard {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .Winner {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.Table .HouseRules {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .ChooseCard {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table button {
|
|
||||||
margin: 0.25rem;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid black; /* why !important */
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .MuiButton-text {
|
|
||||||
padding: 0.25rem 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
border: 1px solid #ccc; /* why !important */
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .ActivitiesBox {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: absolute;
|
|
||||||
left: 1em;
|
|
||||||
top: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .DiceRoll {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
/*
|
|
||||||
left: 1rem;
|
|
||||||
top: 5rem;*/
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: left;
|
|
||||||
align-items: left;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .DiceRoll div:not(:last-child) {
|
|
||||||
border: 1px solid black;
|
|
||||||
background-color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.Table .DiceRoll div:last-child {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Table .DiceRoll .Dice {
|
|
||||||
margin: 0.25rem;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 2.75rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
@ -1,593 +1,127 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { BrowserRouter as Router, Route, Routes, useParams, useNavigate } from "react-router-dom";
|
import { Paper, Typography } from "@mui/material";
|
||||||
|
|
||||||
import Paper from "@mui/material/Paper";
|
import { Session } from "./GlobalContext";
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
|
|
||||||
import { GlobalContext } from "./GlobalContext";
|
|
||||||
import { PlayerList } from "./PlayerList";
|
|
||||||
import { Chat } from "./Chat";
|
|
||||||
import { Board } from "./Board";
|
|
||||||
import { Actions } from "./Actions";
|
|
||||||
import { base } from "./Common";
|
|
||||||
import { GameOrder } from "./GameOrder";
|
|
||||||
import { Activities } from "./Activities";
|
|
||||||
import { SelectPlayer } from "./SelectPlayer";
|
|
||||||
import { PlayersStatus } from "./PlayersStatus";
|
|
||||||
import { ViewCard } from "./ViewCard";
|
|
||||||
import { ChooseCard } from "./ChooseCard";
|
|
||||||
import { Hand } from "./Hand";
|
|
||||||
import { Trade } from "./Trade";
|
|
||||||
import { Winner } from "./Winner";
|
|
||||||
import { HouseRules } from "./HouseRules";
|
|
||||||
import { Dice } from "./Dice";
|
|
||||||
import { assetsPath } from "./Common";
|
|
||||||
|
|
||||||
// history replaced by react-router's useNavigate
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import equal from "fast-deep-equal";
|
import { base } from "./Common";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { BrowserRouter as Router, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
|
import { ReadyState } from "react-use-websocket";
|
||||||
|
import { ConnectionStatus } from "./ConnectionStatus";
|
||||||
|
import { RoomView } from "./RoomView";
|
||||||
|
import { sessionApi } from "./api-client";
|
||||||
|
|
||||||
import itsYourTurnAudio from './assets/its-your-turn.mp3';
|
console.log(`Peddlers of Ketran Build: ${import.meta.env.VITE_APP_POK_BUILD}`);
|
||||||
import robberAudio from './assets/robber.mp3';
|
|
||||||
import knightsAudio from './assets/the-knights-who-say-ni.mp3';
|
|
||||||
import volcanoAudio from './assets/volcano-eruption.mp3';
|
|
||||||
|
|
||||||
const audioFiles: Record<string, string> = {
|
interface LoadingProps {
|
||||||
'its-your-turn.mp3': itsYourTurnAudio,
|
setError: (error: string | null) => void;
|
||||||
'robber.mp3': robberAudio,
|
}
|
||||||
'the-knights-who-say-ni.mp3': knightsAudio,
|
|
||||||
'volcano-eruption.mp3': volcanoAudio,
|
|
||||||
};
|
|
||||||
|
|
||||||
type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean };
|
const Loading = (props: LoadingProps) => {
|
||||||
const audioEffects: Record<string, AudioEffect | undefined> = {};
|
|
||||||
|
|
||||||
const loadAudio = (src: string) => {
|
|
||||||
const audio = document.createElement("audio") as AudioEffect;
|
|
||||||
audio.src = audioFiles[src];
|
|
||||||
console.log("Loading audio:", audio.src);
|
|
||||||
audio.setAttribute("preload", "auto");
|
|
||||||
audio.setAttribute("controls", "none");
|
|
||||||
audio.style.display = "none";
|
|
||||||
document.body.appendChild(audio);
|
|
||||||
audio.load();
|
|
||||||
audio.addEventListener('error', (e) => console.error("Audio load error:", e, audio.src));
|
|
||||||
audio.addEventListener('canplay', () => console.log("Audio can play:", audio.src));
|
|
||||||
return audio;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Table: React.FC = () => {
|
|
||||||
const params = useParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [gameId, setGameId] = useState<string | undefined>(params.gameId ? (params.gameId as string) : undefined);
|
const { setError } = props;
|
||||||
const [ws, setWs] = useState<WebSocket | undefined>(undefined); /* tracks full websocket lifetime */
|
|
||||||
const [connection, setConnection] = useState<WebSocket | undefined>(undefined); /* set after ws is in OPEN */
|
|
||||||
const [retryConnection, setRetryConnection] =
|
|
||||||
useState<boolean>(true); /* set when connection should be re-established */
|
|
||||||
const [name, setName] = useState<string>("");
|
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
|
||||||
const [warning, setWarning] = useState<string | undefined>(undefined);
|
|
||||||
const [loaded, setLoaded] = useState<boolean>(false);
|
|
||||||
|
|
||||||
type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record<string, number> };
|
useEffect(() => {
|
||||||
type PrivateType = { name?: string; color?: string; turnNotice?: string };
|
const createRoom = async () => {
|
||||||
|
try {
|
||||||
|
const room = await sessionApi.createRoom();
|
||||||
|
console.log(`Loading - created room`, room);
|
||||||
|
navigate(`${base}/${room.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||||
|
console.error("Failed to create room:", errorMessage);
|
||||||
|
setError(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
createRoom();
|
||||||
|
}, [setError]);
|
||||||
|
|
||||||
const [dice, setDice] = useState<number[] | undefined>(undefined);
|
return (
|
||||||
const [state, setState] = useState<string | undefined>(undefined);
|
<Paper sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ddddff" }}>
|
||||||
const [color, setColor] = useState<string | undefined>(undefined);
|
<Typography>Loading...</Typography>
|
||||||
const [priv, setPriv] = useState<PrivateType | undefined>(undefined);
|
</Paper>
|
||||||
const [turn, setTurn] = useState<Turn | undefined>(undefined);
|
|
||||||
const [buildActive, setBuildActive] = useState<boolean>(false);
|
|
||||||
const [tradeActive, setTradeActive] = useState<boolean>(false);
|
|
||||||
const [cardActive, setCardActive] = useState<unknown>(undefined);
|
|
||||||
const [houseRulesActive, setHouseRulesActive] = useState<boolean>(false);
|
|
||||||
const [winnerDismissed, setWinnerDismissed] = useState<boolean>(false);
|
|
||||||
const [global, setGlobal] = useState<Record<string, unknown>>({});
|
|
||||||
const [count, setCount] = useState<number>(0);
|
|
||||||
const [audio, setAudio] = useState<boolean>(
|
|
||||||
localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false
|
|
||||||
);
|
);
|
||||||
const [animations, setAnimations] = useState<boolean>(
|
|
||||||
localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false
|
|
||||||
);
|
|
||||||
const [volume, setVolume] = useState<number>(
|
|
||||||
localStorage.getItem("volume") ? parseFloat(localStorage.getItem("volume") as string) : 0.5
|
|
||||||
);
|
|
||||||
const fields = ["id", "state", "color", "name", "private", "dice", "turn"];
|
|
||||||
|
|
||||||
const onWsOpen = (event: Event) => {
|
|
||||||
console.log(`ws: open`);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
setConnection(ws);
|
|
||||||
const sock = event.target as WebSocket;
|
|
||||||
sock.send(JSON.stringify({ type: "game-update" }));
|
|
||||||
sock.send(JSON.stringify({ type: "get", fields }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onWsMessage = (event: MessageEvent) => {
|
const App = () => {
|
||||||
const data = JSON.parse(event.data as string);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
switch (data.type) {
|
const [error, setError] = useState<string | null>(null);
|
||||||
case "error":
|
const [sessionRetryAttempt, setSessionRetryAttempt] = useState<number>(0);
|
||||||
console.error(`App - error`, data.error);
|
|
||||||
setError(data.error);
|
useEffect(() => {
|
||||||
break;
|
if (error) {
|
||||||
case "warning":
|
setTimeout(() => setError(null), 5000);
|
||||||
console.warn(`App - warning`, data.warning);
|
}
|
||||||
setWarning(data.warning);
|
}, [error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`App - sessionId`, session.id);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const getSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const session = await sessionApi.getCurrent();
|
||||||
|
console.log(`App - got sessionId`, session.id);
|
||||||
|
setSession(session);
|
||||||
|
setSessionRetryAttempt(0);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||||
|
console.error("Failed to get session:", errorMessage);
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
// Schedule retry after 5 seconds
|
||||||
|
setSessionRetryAttempt((prev) => prev + 1);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setWarning("");
|
getSession(); // Retry
|
||||||
}, 3000);
|
}, 5000);
|
||||||
break;
|
|
||||||
case "game-update":
|
|
||||||
if (!loaded) {
|
|
||||||
setLoaded(true);
|
|
||||||
}
|
}
|
||||||
console.log(`app - message - ${data.type}`, data.update);
|
}, []);
|
||||||
|
|
||||||
if ("private" in data.update && !equal(priv, data.update.private)) {
|
|
||||||
const priv = data.update.private;
|
|
||||||
if (priv.name !== name) {
|
|
||||||
setName(priv.name);
|
|
||||||
}
|
|
||||||
if (priv.color !== color) {
|
|
||||||
setColor(priv.color);
|
|
||||||
}
|
|
||||||
setPriv(priv);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("name" in data.update) {
|
|
||||||
if (data.update.name) {
|
|
||||||
setName(data.update.name);
|
|
||||||
} else {
|
|
||||||
setWarning("");
|
|
||||||
setError("");
|
|
||||||
setPriv(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ("id" in data.update && data.update.id !== gameId) {
|
|
||||||
setGameId(data.update.id);
|
|
||||||
}
|
|
||||||
if ("state" in data.update && data.update.state !== state) {
|
|
||||||
if (data.update.state !== "winner" && winnerDismissed) {
|
|
||||||
setWinnerDismissed(false);
|
|
||||||
}
|
|
||||||
setState(data.update.state);
|
|
||||||
}
|
|
||||||
if ("dice" in data.update && !equal(data.update.dice, dice)) {
|
|
||||||
setDice(data.update.dice);
|
|
||||||
}
|
|
||||||
if ("turn" in data.update && !equal(data.update.turn, turn)) {
|
|
||||||
setTurn(data.update.turn);
|
|
||||||
}
|
|
||||||
if ("color" in data.update && data.update.color !== color) {
|
|
||||||
setColor(data.update.color);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendUpdate = (update: unknown) => {
|
|
||||||
if (ws) ws.send(JSON.stringify(update));
|
|
||||||
};
|
|
||||||
|
|
||||||
const cbResetConnection = useCallback(() => {
|
|
||||||
let timer: number | null = null;
|
|
||||||
function reset() {
|
|
||||||
timer = null;
|
|
||||||
setRetryConnection(true);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
timer = window.setTimeout(reset, 5000);
|
|
||||||
};
|
|
||||||
}, [setRetryConnection]);
|
|
||||||
|
|
||||||
const resetConnection = cbResetConnection();
|
|
||||||
|
|
||||||
if (global.ws !== connection || global.name !== name || global.gameId !== gameId) {
|
|
||||||
setGlobal({
|
|
||||||
ws: connection,
|
|
||||||
name,
|
|
||||||
gameId,
|
|
||||||
sendJsonMessage: sendUpdate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const onWsError = () => {
|
|
||||||
const error =
|
|
||||||
`Connection to Ketr Ketran game server failed! ` + `Connection attempt will be retried every 5 seconds.`;
|
|
||||||
setError(error);
|
|
||||||
setGlobal(Object.assign({}, global, { ws: undefined, sendJsonMessage: undefined }));
|
|
||||||
setWs(undefined); /* clear the socket */
|
|
||||||
setConnection(undefined); /* clear the connection */
|
|
||||||
resetConnection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onWsClose = () => {
|
|
||||||
const error = `Connection to Ketr Ketran game was lost. ` + `Attempting to reconnect...`;
|
|
||||||
setError(error);
|
|
||||||
setGlobal(Object.assign({}, global, { ws: undefined, sendJsonMessage: undefined }));
|
|
||||||
setWs(undefined); /* clear the socket */
|
|
||||||
setConnection(undefined); /* clear the connection */
|
|
||||||
resetConnection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const refWsOpen = useRef<(e: Event) => void>(() => {});
|
|
||||||
useEffect(() => {
|
|
||||||
refWsOpen.current = onWsOpen;
|
|
||||||
}, [onWsOpen]);
|
|
||||||
const refWsMessage = useRef<(e: MessageEvent) => void>(() => {});
|
|
||||||
useEffect(() => {
|
|
||||||
refWsMessage.current = onWsMessage;
|
|
||||||
}, [onWsMessage]);
|
|
||||||
const refWsClose = useRef<(e: CloseEvent) => void>(() => {});
|
|
||||||
useEffect(() => {
|
|
||||||
refWsClose.current = onWsClose;
|
|
||||||
}, [onWsClose]);
|
|
||||||
const refWsError = useRef<(e: Event) => void>(() => {});
|
|
||||||
useEffect(() => {
|
|
||||||
refWsError.current = onWsError;
|
|
||||||
}, [onWsError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameId) {
|
if (session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
getSession();
|
||||||
window
|
}, [session, getSession]);
|
||||||
.fetch(`${base}/api/v1/games/`, {
|
|
||||||
method: "POST",
|
|
||||||
cache: "no-cache",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.status >= 400) {
|
|
||||||
const error =
|
|
||||||
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
|
|
||||||
setError(error);
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((update) => {
|
|
||||||
if (update.id !== gameId) {
|
|
||||||
navigate(`/${update.id}`);
|
|
||||||
setGameId(update.id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
}, [gameId, setGameId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!gameId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unbind = () => {
|
|
||||||
console.log(`table - unbind`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!ws && !connection && retryConnection) {
|
|
||||||
const loc = window.location;
|
|
||||||
let new_uri = "";
|
|
||||||
if (loc.protocol === "https:") {
|
|
||||||
new_uri = "wss";
|
|
||||||
} else {
|
|
||||||
new_uri = "ws";
|
|
||||||
}
|
|
||||||
new_uri = `${new_uri}://${loc.host}${base}/api/v1/games/ws/${gameId}?${count}`;
|
|
||||||
setWs(new WebSocket(new_uri));
|
|
||||||
setConnection(undefined);
|
|
||||||
setRetryConnection(false);
|
|
||||||
setCount(count + 1);
|
|
||||||
return unbind;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ws) {
|
|
||||||
return unbind;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cbOpen = (e: Event) => refWsOpen.current(e);
|
|
||||||
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
|
|
||||||
const cbClose = (e: CloseEvent) => refWsClose.current(e);
|
|
||||||
const cbError = (e: Event) => refWsError.current(e);
|
|
||||||
|
|
||||||
ws.addEventListener("open", cbOpen);
|
|
||||||
ws.addEventListener("close", cbClose);
|
|
||||||
ws.addEventListener("error", cbError);
|
|
||||||
ws.addEventListener("message", cbMessage);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unbind();
|
|
||||||
ws.removeEventListener("open", cbOpen);
|
|
||||||
ws.removeEventListener("close", cbClose);
|
|
||||||
ws.removeEventListener("error", cbError);
|
|
||||||
ws.removeEventListener("message", cbMessage);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
ws,
|
|
||||||
setWs,
|
|
||||||
connection,
|
|
||||||
setConnection,
|
|
||||||
retryConnection,
|
|
||||||
setRetryConnection,
|
|
||||||
gameId,
|
|
||||||
refWsOpen,
|
|
||||||
refWsMessage,
|
|
||||||
refWsClose,
|
|
||||||
refWsError,
|
|
||||||
count,
|
|
||||||
setCount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state === "volcano") {
|
|
||||||
if (!audioEffects.volcano) {
|
|
||||||
audioEffects.volcano = loadAudio("volcano-eruption.mp3");
|
|
||||||
audioEffects.volcano.volume = volume * volume;
|
|
||||||
} else {
|
|
||||||
if (!audioEffects.volcano.hasPlayed && audioEffects.volcano.readyState >= 2) {
|
|
||||||
audioEffects.volcano.hasPlayed = true;
|
|
||||||
audioEffects.volcano.play().catch((e) => console.error("Audio play failed:", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (audioEffects.volcano) {
|
|
||||||
audioEffects.volcano.hasPlayed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state, volume]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (turn && turn.color === color && state !== "lobby") {
|
|
||||||
if (!audioEffects.yourTurn) {
|
|
||||||
audioEffects.yourTurn = loadAudio("its-your-turn.mp3");
|
|
||||||
audioEffects.yourTurn.volume = volume * volume;
|
|
||||||
} else {
|
|
||||||
if (!audioEffects.yourTurn.hasPlayed && audioEffects.yourTurn.readyState >= 2) {
|
|
||||||
audioEffects.yourTurn.hasPlayed = true;
|
|
||||||
audioEffects.yourTurn.play().catch((e) => console.error("Audio play failed:", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (turn) {
|
|
||||||
if (audioEffects.yourTurn) {
|
|
||||||
audioEffects.yourTurn.hasPlayed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (turn && turn.roll === 7) {
|
|
||||||
if (!audioEffects.robber) {
|
|
||||||
audioEffects.robber = loadAudio("robber.mp3");
|
|
||||||
audioEffects.robber.volume = volume * volume;
|
|
||||||
} else {
|
|
||||||
if (!audioEffects.robber.hasPlayed && audioEffects.robber.readyState >= 2) {
|
|
||||||
audioEffects.robber.hasPlayed = true;
|
|
||||||
audioEffects.robber.play().catch((e) => console.error("Audio play failed:", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (turn) {
|
|
||||||
if (audioEffects.robber) {
|
|
||||||
audioEffects.robber.hasPlayed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (turn && turn.actions && turn.actions.indexOf("playing-knight") !== -1) {
|
|
||||||
if (!audioEffects.knights) {
|
|
||||||
audioEffects.knights = loadAudio("the-knights-who-say-ni.mp3");
|
|
||||||
audioEffects.knights.volume = volume * volume;
|
|
||||||
} else {
|
|
||||||
if (!audioEffects.knights.hasPlayed && audioEffects.knights.readyState >= 2) {
|
|
||||||
audioEffects.knights.hasPlayed = true;
|
|
||||||
audioEffects.knights.play().catch((e) => console.error("Audio play failed:", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (turn && turn.actions && turn.actions.indexOf("playing-knight") === -1) {
|
|
||||||
if (audioEffects.knights) {
|
|
||||||
audioEffects.knights.hasPlayed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state, turn, color, volume]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const key in audioEffects) {
|
|
||||||
audioEffects[key].volume = volume * volume;
|
|
||||||
}
|
|
||||||
}, [volume]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalContext.Provider value={global}>
|
<Box
|
||||||
{/* <PingPong/> */}
|
sx={{
|
||||||
<div className="Table">
|
p: { xs: 1, sm: 2 },
|
||||||
<div className="ActivitiesBox">
|
// maxWidth: { xs: "100%", sm: 800 },
|
||||||
<Activities />
|
margin: "0 auto",
|
||||||
{dice && dice.length && (
|
height: "100vh",
|
||||||
<div className="DiceRoll">
|
overflowY: "auto",
|
||||||
{dice.length === 1 && <div>Volcano roll!</div>}
|
|
||||||
{dice.length === 2 && <div>Current roll</div>}
|
|
||||||
<div>
|
|
||||||
<Dice pips={dice[0]} />
|
|
||||||
{dice.length === 2 && <Dice pips={dice[1]} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="Game">
|
|
||||||
<div className="Dialogs">
|
|
||||||
{error && (
|
|
||||||
<div className="Dialog ErrorDialog">
|
|
||||||
<Paper className="Error">
|
|
||||||
<div>{error}</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setError("");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
dismiss
|
{!session && (
|
||||||
</Button>
|
<ConnectionStatus
|
||||||
</Paper>
|
readyState={sessionRetryAttempt > 0 ? ReadyState.CLOSED : ReadyState.CONNECTING}
|
||||||
</div>
|
reconnectAttempt={sessionRetryAttempt}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
{session && (
|
||||||
{priv && priv.turnNotice && (
|
<Router
|
||||||
<div className="Dialog TurnNoticeDialog">
|
future={{
|
||||||
<Paper className="TurnNotice">
|
v7_startTransition: true,
|
||||||
<div>{priv.turnNotice}</div>
|
v7_relativeSplatPath: true,
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
sendUpdate({ type: "turn-notice" });
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
dismiss
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{warning && (
|
|
||||||
<div className="Dialog WarningDialog">
|
|
||||||
<Paper className="Warning">
|
|
||||||
<div>{warning}</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setWarning("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
dismiss
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{state === "normal" && <SelectPlayer />}
|
|
||||||
{color && state === "game-order" && <GameOrder />}
|
|
||||||
|
|
||||||
{!winnerDismissed && <Winner {...{ winnerDismissed, setWinnerDismissed }} />}
|
|
||||||
{houseRulesActive && <HouseRules {...{ houseRulesActive, setHouseRulesActive }} />}
|
|
||||||
<ViewCard {...{ cardActive, setCardActive }} />
|
|
||||||
<ChooseCard />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Board animations={animations} />
|
|
||||||
<PlayersStatus active={false} />
|
|
||||||
<PlayersStatus active={true} />
|
|
||||||
<Hand {...{ buildActive, setBuildActive, setCardActive }} />
|
|
||||||
</div>
|
|
||||||
<div className="Sidebar">
|
|
||||||
{name !== "" && volume !== undefined && (
|
|
||||||
<Paper className="Volume">
|
|
||||||
<div>Audio effects</div>{" "}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="audio"
|
|
||||||
name="audio"
|
|
||||||
defaultChecked={audio ? true : false}
|
|
||||||
onInput={() => {
|
|
||||||
const value = !audio;
|
|
||||||
localStorage.setItem("audio", JSON.stringify(value));
|
|
||||||
setAudio(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div>Sound effects volume</div>{" "}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="volume"
|
|
||||||
name="volume"
|
|
||||||
value={volume * 100}
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
onInput={(e) => {
|
|
||||||
const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100;
|
|
||||||
|
|
||||||
localStorage.setItem("volume", alpha.toString());
|
|
||||||
setVolume(alpha);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div>Animations</div>{" "}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="animations"
|
|
||||||
name="animations"
|
|
||||||
defaultChecked={animations ? true : false}
|
|
||||||
onInput={() => {
|
|
||||||
const value = !animations;
|
|
||||||
localStorage.setItem("animations", JSON.stringify(value));
|
|
||||||
setAnimations(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
{name !== "" && <PlayerList />}
|
|
||||||
{/* Trade is an untyped JS component; assert its type to avoid `any` */}
|
|
||||||
{(() => {
|
|
||||||
const TradeComponent = Trade as unknown as React.ComponentType<{
|
|
||||||
tradeActive: boolean;
|
|
||||||
setTradeActive: (v: boolean) => void;
|
|
||||||
}>;
|
|
||||||
return <TradeComponent tradeActive={tradeActive} setTradeActive={setTradeActive} />;
|
|
||||||
})()}
|
|
||||||
{name !== "" && <Chat />}
|
|
||||||
{/* name !== "" && <VideoFeeds/> */}
|
|
||||||
{loaded && (
|
|
||||||
<Actions
|
|
||||||
{...{ buildActive, setBuildActive, tradeActive, setTradeActive, houseRulesActive, setHouseRulesActive }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GlobalContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
|
||||||
const [playerId, setPlayerId] = useState<string | undefined>(undefined);
|
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (playerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window
|
|
||||||
.fetch(`${base}/api/v1/games/`, {
|
|
||||||
method: "GET",
|
|
||||||
cache: "no-cache",
|
|
||||||
credentials: "same-origin" /* include cookies */,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.status >= 400) {
|
|
||||||
const error =
|
|
||||||
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
|
|
||||||
setError(error);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
setPlayerId(data.player);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [playerId, setPlayerId]);
|
|
||||||
|
|
||||||
if (!playerId) {
|
|
||||||
return <>{error}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Router basename={base}>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Table />} path="/:gameId" />
|
<Route element={<RoomView {...{ setError, session, setSession }} />} path={`${base}/:roomName`} />
|
||||||
<Route element={<Table />} path="/" />
|
<Route element={<Loading {...{ setError }} />} path={`${base}`} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
|
||||||
|
<Typography color="red">{error}</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -95,7 +95,7 @@ const clearTooltip = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Board: React.FC<BoardProps> = ({ animations }) => {
|
const Board: React.FC<BoardProps> = ({ animations }) => {
|
||||||
const { ws, sendJsonMessage } = useContext(GlobalContext);
|
const { sendJsonMessage, lastJsonMessage } = useContext(GlobalContext);
|
||||||
const board = useRef();
|
const board = useRef();
|
||||||
const [transform, setTransform] = useState(1);
|
const [transform, setTransform] = useState(1);
|
||||||
const [pipElements, setPipElements] = useState<React.ReactElement[]>([]);
|
const [pipElements, setPipElements] = useState<React.ReactElement[]>([]);
|
||||||
@ -142,11 +142,11 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onWsMessage = (event) => {
|
useEffect(() => {
|
||||||
if (ws && ws !== event.target) {
|
if (!lastJsonMessage) {
|
||||||
console.error(`Disconnect occur?`);
|
return;
|
||||||
}
|
}
|
||||||
const data = JSON.parse(event.data);
|
const data = lastJsonMessage;
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "game-update":
|
case "game-update":
|
||||||
console.log(`board - game update`, data.update);
|
console.log(`board - game update`, data.update);
|
||||||
@ -232,23 +232,7 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
}, [lastJsonMessage, robber, robberName]);
|
||||||
const refWsMessage = useRef(onWsMessage);
|
|
||||||
useEffect(() => {
|
|
||||||
refWsMessage.current = onWsMessage;
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ws) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("board - bind");
|
|
||||||
const cbMessage = (e) => refWsMessage.current(e);
|
|
||||||
ws.addEventListener("message", cbMessage);
|
|
||||||
return () => {
|
|
||||||
console.log("board - unbind");
|
|
||||||
ws.removeEventListener("message", cbMessage);
|
|
||||||
};
|
|
||||||
}, [ws]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sendJsonMessage) {
|
if (!sendJsonMessage) {
|
||||||
return;
|
return;
|
||||||
@ -305,10 +289,6 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
onResize();
|
onResize();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Generating static corner data... should only occur once per reload or socket reconnect.`);
|
console.log(`Generating static corner data... should only occur once per reload or socket reconnect.`);
|
||||||
const onCornerClicked = (event, corner) => {
|
const onCornerClicked = (event, corner) => {
|
||||||
let type;
|
let type;
|
||||||
@ -317,12 +297,10 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
} else {
|
} else {
|
||||||
type = "place-settlement";
|
type = "place-settlement";
|
||||||
}
|
}
|
||||||
ws.send(
|
sendJsonMessage({
|
||||||
JSON.stringify({
|
|
||||||
type,
|
type,
|
||||||
index: corner.index,
|
index: corner.index,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const Corner: React.FC<CornerProps> = ({ corner }) => {
|
const Corner: React.FC<CornerProps> = ({ corner }) => {
|
||||||
return (
|
return (
|
||||||
@ -411,27 +389,17 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setCornerElements(generateCorners());
|
setCornerElements(generateCorners());
|
||||||
}, [ws, setCornerElements]);
|
}, [setCornerElements]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Generating static road data... should only occur once per reload or socket reconnect.`);
|
console.log(`Generating static road data... should only occur once per reload or socket reconnect.`);
|
||||||
const Road: React.FC<RoadProps> = ({ road }) => {
|
const Road: React.FC<RoadProps> = ({ road }) => {
|
||||||
const onRoadClicked = (road) => {
|
const onRoadClicked = (road) => {
|
||||||
console.log(`Road clicked: ${road.index}`);
|
console.log(`Road clicked: ${road.index}`);
|
||||||
if (!ws) {
|
sendJsonMessage({
|
||||||
console.error(`board - onRoadClicked - ws is NULL`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "place-road",
|
type: "place-road",
|
||||||
index: road.index,
|
index: road.index,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -533,13 +501,10 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
return corners;
|
return corners;
|
||||||
};
|
};
|
||||||
setRoadElements(generateRoads());
|
setRoadElements(generateRoads());
|
||||||
}, [ws, setRoadElements]);
|
}, [setRoadElements]);
|
||||||
|
|
||||||
/* Generate Pip, Tile, and Border elements */
|
/* Generate Pip, Tile, and Border elements */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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) => {
|
||||||
@ -547,12 +512,10 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
console.error(`board - sendPlacement - ws is NULL`);
|
console.error(`board - sendPlacement - ws is NULL`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ws.send(
|
sendJsonMessage({
|
||||||
JSON.stringify({
|
|
||||||
type: "place-robber",
|
type: "place-robber",
|
||||||
index: pip.index,
|
index: pip.index,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -830,7 +793,6 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
|
|||||||
tiles,
|
tiles,
|
||||||
tileOrder,
|
tileOrder,
|
||||||
animationSeeds,
|
animationSeeds,
|
||||||
ws,
|
|
||||||
state,
|
state,
|
||||||
rules,
|
rules,
|
||||||
animations,
|
animations,
|
||||||
|
@ -1,86 +1,57 @@
|
|||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
|
||||||
let timer: any = null;
|
|
||||||
return function(...args: Parameters<T>) {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
timer = null;
|
|
||||||
fn.apply(this, args);
|
|
||||||
}, ms);
|
|
||||||
} as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prefer an explicit API/base provided via environment variable. Different
|
|
||||||
// deployments and scripts historically used different variable names
|
|
||||||
// (VITE_API_BASE, VITE_BASEPATH, PUBLIC_URL). Try them in a sensible order
|
|
||||||
// so the client correctly computes its `base` (router basename and asset
|
|
||||||
// prefix) regardless of which one is defined.
|
|
||||||
|
|
||||||
// Defensive handling: some env consumers or docker-compose YAML authors may
|
// Defensive handling: some env consumers or docker-compose YAML authors may
|
||||||
// accidentally include literal quotes when setting env vars (for example,
|
// accidentally include literal quotes when setting env vars (for example,
|
||||||
// `VITE_API_BASE=""`). That results in the string `""` being present at
|
// `VITE_API_BASE=""`). That results in the string `""` being present at
|
||||||
// runtime and ends up URL-encoded as `%22%22` in fetches. Normalize here so
|
// runtime and ends up URL-encoded as `%22%22` in fetches. Normalize here so
|
||||||
// an accidental quoted-empty value becomes an empty string.
|
// an accidental quoted-empty value becomes an empty string.
|
||||||
const candidateEnvVars = [
|
const candidateEnvVars = [import.meta.env.VITE_API_BASE, import.meta.env.VITE_BASEPATH, import.meta.env.PUBLIC_URL];
|
||||||
import.meta.env.VITE_API_BASE,
|
let rawEnvApiBase = "";
|
||||||
// Some deployments (server-side) set VITE_BASEPATH (note the case).
|
|
||||||
import.meta.env.VITE_BASEPATH,
|
|
||||||
// Older scripts or build systems sometimes populate PUBLIC_URL.
|
|
||||||
import.meta.env.PUBLIC_URL,
|
|
||||||
];
|
|
||||||
|
|
||||||
let rawEnvApiBase = '';
|
|
||||||
for (const candidate of candidateEnvVars) {
|
for (const candidate of candidateEnvVars) {
|
||||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
if (typeof candidate === "string" && candidate.trim() !== "") {
|
||||||
rawEnvApiBase = candidate;
|
rawEnvApiBase = candidate;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let envApiBase = typeof rawEnvApiBase === 'string' ? rawEnvApiBase.trim() : '';
|
let envApiBase = typeof rawEnvApiBase === "string" ? rawEnvApiBase.trim() : "";
|
||||||
|
|
||||||
// If someone set the literal value '""' or "''", treat it as empty.
|
// If someone set the literal value '""' or "''", treat it as empty.
|
||||||
if (envApiBase === '""' || envApiBase === "''") {
|
if (envApiBase === '""' || envApiBase === "''") {
|
||||||
envApiBase = '';
|
envApiBase = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove surrounding single or double quotes if present.
|
// Remove surrounding single or double quotes if present.
|
||||||
if ((envApiBase.startsWith('"') && envApiBase.endsWith('"')) ||
|
if (
|
||||||
(envApiBase.startsWith("'") && envApiBase.endsWith("'"))) {
|
(envApiBase.startsWith('"') && envApiBase.endsWith('"')) ||
|
||||||
|
(envApiBase.startsWith("'") && envApiBase.endsWith("'"))
|
||||||
|
) {
|
||||||
envApiBase = envApiBase.slice(1, -1);
|
envApiBase = envApiBase.slice(1, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicBase = import.meta.env.BASE_URL || '';
|
const publicBase = import.meta.env.BASE_URL || "";
|
||||||
|
|
||||||
// Normalize base: treat '/' as empty, and strip any trailing slash so
|
// Normalize base: treat '/' as empty, and strip any trailing slash so
|
||||||
// constructing `${base}/api/...` never produces a protocol-relative
|
// constructing `${base}/api/...` never produces a protocol-relative
|
||||||
// URL like `//api/...` which the browser resolves to `https://api/...`.
|
// URL like `//api/...` which the browser resolves to `https://api/...`.
|
||||||
let baseCandidate = envApiBase || publicBase || '';
|
let baseCandidate = envApiBase || publicBase || "";
|
||||||
if (baseCandidate === '/') {
|
if (baseCandidate === "/") {
|
||||||
baseCandidate = '';
|
baseCandidate = "";
|
||||||
}
|
}
|
||||||
// Remove trailing slash if present (but keep leading slash for path bases).
|
// Remove trailing slash if present (but keep leading slash for path bases).
|
||||||
if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) {
|
if (baseCandidate.length > 1 && baseCandidate.endsWith("/")) {
|
||||||
baseCandidate = baseCandidate.replace(/\/+$/, '');
|
baseCandidate = baseCandidate.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime safeguard: when the app is opened at a URL that does not include
|
// Runtime safeguard: when the app is opened at a URL that does not include
|
||||||
// the configured base path (for example, dev server serving at `/` while
|
// the configured base path (for example, dev server serving at `/` while
|
||||||
// VITE_BASEPATH is `/ketr.ketran`), React Router's <Router basename="...">
|
// VITE_BASEPATH is `/ketr.ketran`), React Router's <Router basename="...">
|
||||||
// will refuse to render because the current pathname doesn't start with the
|
// will refuse to render because the current pathname doesn't start with the
|
||||||
// basename. In that situation prefer to fall back to an empty basename so
|
// basename. In that situation throw an error!
|
||||||
// the client still renders correctly in local/dev setups.
|
|
||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined' && baseCandidate) {
|
if (typeof window !== "undefined" && baseCandidate) {
|
||||||
const pathname = window.location && window.location.pathname ? window.location.pathname : '';
|
const pathname = window.location && window.location.pathname ? window.location.pathname : "";
|
||||||
// Accept either exact prefix or prefix followed by a slash
|
if (!(pathname === baseCandidate || pathname.startsWith(baseCandidate + "/"))) {
|
||||||
if (!(pathname === baseCandidate || pathname.startsWith(baseCandidate + '/'))) {
|
// Mismatch: FAIL!
|
||||||
// Mismatch: fallback to empty base so router can match the URL.
|
throw Error(`Configured base '${baseCandidate}' does not match current pathname '${pathname}'`);
|
||||||
// Keep a console message to aid debugging in browsers.
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`Configured base '${baseCandidate}' does not match current pathname '${pathname}'; falling back to ''`);
|
|
||||||
baseCandidate = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -97,4 +68,8 @@ const base = baseCandidate;
|
|||||||
const assetsPath = base;
|
const assetsPath = base;
|
||||||
const gamesPath = `${base}`;
|
const gamesPath = `${base}`;
|
||||||
|
|
||||||
export { base, debounce, assetsPath, gamesPath };
|
const ws_base: string = `${window.location.protocol === "https:" ? "wss" : "ws"}://${
|
||||||
|
window.location.host
|
||||||
|
}${base}/api/v1/games/ws`;
|
||||||
|
|
||||||
|
export { base, ws_base, assetsPath, gamesPath };
|
||||||
|
94
client/src/ConnectionStatus.tsx
Normal file
94
client/src/ConnectionStatus.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Box, Typography, CircularProgress, Paper, LinearProgress } from "@mui/material";
|
||||||
|
import { ReadyState } from "react-use-websocket";
|
||||||
|
|
||||||
|
interface ConnectionStatusProps {
|
||||||
|
readyState: ReadyState;
|
||||||
|
reconnectAttempt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectionStatus: React.FC<ConnectionStatusProps> = ({ readyState, reconnectAttempt = 0 }) => {
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
|
||||||
|
// Start countdown when connection is closed and we're attempting to reconnect
|
||||||
|
useEffect(() => {
|
||||||
|
if (readyState === ReadyState.CLOSED && reconnectAttempt > 0) {
|
||||||
|
setCountdown(5);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [readyState, reconnectAttempt]);
|
||||||
|
|
||||||
|
const getConnectionStatusText = () => {
|
||||||
|
switch (readyState) {
|
||||||
|
case ReadyState.CONNECTING:
|
||||||
|
return reconnectAttempt > 0 ? `Reconnecting... (attempt ${reconnectAttempt})` : "Connecting to server...";
|
||||||
|
case ReadyState.OPEN:
|
||||||
|
return "Connected";
|
||||||
|
case ReadyState.CLOSING:
|
||||||
|
return "Disconnecting...";
|
||||||
|
case ReadyState.CLOSED:
|
||||||
|
if (reconnectAttempt > 0 && countdown > 0) {
|
||||||
|
return `Connection lost. Retrying in ${countdown}s... (attempt ${reconnectAttempt})`;
|
||||||
|
}
|
||||||
|
return reconnectAttempt > 0 ? "Reconnecting..." : "Disconnected";
|
||||||
|
case ReadyState.UNINSTANTIATED:
|
||||||
|
return "Initializing...";
|
||||||
|
default:
|
||||||
|
return "Unknown connection state";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionColor = () => {
|
||||||
|
switch (readyState) {
|
||||||
|
case ReadyState.OPEN:
|
||||||
|
return "success.main";
|
||||||
|
case ReadyState.CONNECTING:
|
||||||
|
return "info.main";
|
||||||
|
case ReadyState.CLOSED:
|
||||||
|
return "error.main";
|
||||||
|
case ReadyState.CLOSING:
|
||||||
|
return "warning.main";
|
||||||
|
default:
|
||||||
|
return "text.secondary";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowProgress =
|
||||||
|
readyState === ReadyState.CONNECTING || (readyState === ReadyState.CLOSED && reconnectAttempt > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
m: 2,
|
||||||
|
width: "fit-content",
|
||||||
|
backgroundColor: readyState === ReadyState.CLOSED ? "#ffebee" : "background.paper",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
{shouldShowProgress && <CircularProgress size={20} />}
|
||||||
|
<Typography variant="h6" color={getConnectionColor()}>
|
||||||
|
{getConnectionStatusText()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{readyState === ReadyState.CLOSED && reconnectAttempt > 0 && countdown > 0 && (
|
||||||
|
<Box sx={{ mt: 2, width: "100%" }}>
|
||||||
|
<LinearProgress variant="determinate" value={((5 - countdown) / 5) * 100} sx={{ borderRadius: 1 }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ConnectionStatus };
|
@ -1,20 +1,141 @@
|
|||||||
import { createContext } from 'react';
|
import { createContext } from "react";
|
||||||
|
|
||||||
export type GlobalContextType = {
|
export type GlobalContextType = {
|
||||||
gameId?: string | undefined;
|
roomName?: string;
|
||||||
ws?: WebSocket | null | undefined;
|
|
||||||
name?: string;
|
name?: string;
|
||||||
sendJsonMessage?: (message: any) => void;
|
sendJsonMessage?: (message: any) => void;
|
||||||
chat?: Array<unknown>;
|
chat?: Array<unknown>;
|
||||||
|
socketUrl?: string;
|
||||||
|
session?: Session;
|
||||||
|
lastJsonMessage?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
const global: GlobalContextType = {
|
const global: GlobalContextType = {
|
||||||
gameId: undefined,
|
roomName: undefined,
|
||||||
ws: undefined,
|
|
||||||
name: "",
|
name: "",
|
||||||
chat: []
|
socketUrl: undefined,
|
||||||
|
chat: [],
|
||||||
|
session: undefined,
|
||||||
|
lastJsonMessage: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GlobalContext = createContext<GlobalContextType>(global);
|
const GlobalContext = createContext(global);
|
||||||
|
|
||||||
export { GlobalContext, global };
|
/**
|
||||||
|
* RoomModel
|
||||||
|
* @description Core room model used across components
|
||||||
|
*/
|
||||||
|
export interface RoomModel {
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Private
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
private?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RoomCreateData
|
||||||
|
* @description Data for room creation
|
||||||
|
*/
|
||||||
|
export interface RoomCreateData {
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Private
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
private?: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* RoomCreateRequest
|
||||||
|
* @description Request for creating a room
|
||||||
|
*/
|
||||||
|
export interface RoomCreateRequest {
|
||||||
|
/**
|
||||||
|
* Type
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
type: "room_create";
|
||||||
|
data: RoomCreateData;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* RoomCreateResponse
|
||||||
|
* @description Response for room creation
|
||||||
|
*/
|
||||||
|
export interface RoomCreateResponse {
|
||||||
|
/**
|
||||||
|
* Type
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
type: "room_created";
|
||||||
|
data: RoomModel;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* RoomListItem
|
||||||
|
* @description Room item for list responses
|
||||||
|
*/
|
||||||
|
export interface RoomListItem {
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* RoomModel
|
||||||
|
* @description Core room model used across components
|
||||||
|
*/
|
||||||
|
export interface RoomModel {
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Private
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
private?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionResponse
|
||||||
|
* @description Session response model
|
||||||
|
*/
|
||||||
|
export interface SessionResponse {
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/** Lobbies */
|
||||||
|
lobbies: RoomModel[];
|
||||||
|
/**
|
||||||
|
* Protected
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
protected?: boolean;
|
||||||
|
/**
|
||||||
|
* Has Media
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_media?: boolean;
|
||||||
|
/** Bot Run Id */
|
||||||
|
bot_run_id?: string | null;
|
||||||
|
/** Bot Provider Id */
|
||||||
|
bot_provider_id?: string | null;
|
||||||
|
/** Bot Instance Id */
|
||||||
|
bot_instance_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types from api-client for backwards compatibility
|
||||||
|
export type Room = RoomModel;
|
||||||
|
|
||||||
|
// Extended Session type that allows name to be null initially (before user sets it)
|
||||||
|
export type Session = Omit<SessionResponse, "name"> & {
|
||||||
|
name: string | null;
|
||||||
|
has_media?: boolean; // Whether this session provides audio/video streams
|
||||||
|
};
|
||||||
|
|
||||||
|
export { GlobalContext };
|
||||||
|
@ -9,6 +9,7 @@ import Videocam from "@mui/icons-material/Videocam";
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
|
import { Session } from "./GlobalContext";
|
||||||
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";
|
||||||
@ -17,15 +18,6 @@ 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.
|
||||||
const FILTER_HOST_CANDIDATES = false; // Temporarily disabled to test direct connections
|
const FILTER_HOST_CANDIDATES = false; // Temporarily disabled to test direct connections
|
||||||
|
|
||||||
export type Session = {
|
|
||||||
session_id: string;
|
|
||||||
peer_name: string;
|
|
||||||
has_media?: boolean; // Whether this user provides audio/video streams
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- Synthetic Tracks Helpers ---------- */
|
/* ---------- Synthetic Tracks Helpers ---------- */
|
||||||
|
|
||||||
// Helper to hash a string to a color
|
// Helper to hash a string to a color
|
||||||
@ -1089,8 +1081,6 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
|
|
||||||
const handleWebSocketMessage = useCallback(
|
const handleWebSocketMessage = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
console.log(`media-agent - WebSocket message received:`, data.type, data.data);
|
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "join_status":
|
case "join_status":
|
||||||
setJoinStatus({ status: data.status, message: data.message });
|
setJoinStatus({ status: data.status, message: data.message });
|
||||||
@ -1130,13 +1120,12 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
|
|
||||||
// Join lobby when media is ready
|
// Join lobby when media is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
|
||||||
if (media && joinStatus.status === "Not joined" && readyState === ReadyState.OPEN) {
|
if (media && joinStatus.status === "Not joined" && readyState === ReadyState.OPEN) {
|
||||||
console.log(`media-agent - Initiating media join for ${session.name}`);
|
console.log(`media-agent - Initiating media join for ${session.name}`);
|
||||||
setJoinStatus({ status: "Joining" });
|
setJoinStatus({ status: "Joining" });
|
||||||
sendJsonMessage({ type: "join", data: {} });
|
sendJsonMessage({ type: "join", data: {} });
|
||||||
}
|
}
|
||||||
}, [media, joinStatus.status, sendJsonMessage, readyState, session]);
|
}, [media, joinStatus.status, sendJsonMessage, readyState, session.name]);
|
||||||
|
|
||||||
// Update local peer in peers list
|
// Update local peer in peers list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1190,7 +1179,6 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
console.log(`media-agent - Requesting access to local audio/video`);
|
console.log(`media-agent - Requesting access to local audio/video`);
|
||||||
const attempt = { get_audio: true, get_video: true };
|
const attempt = { get_audio: true, get_video: true };
|
||||||
let media = null;
|
let media = null;
|
||||||
if (!session) { return new MediaStream(); }
|
|
||||||
|
|
||||||
// Try to get user media with fallback
|
// Try to get user media with fallback
|
||||||
while (attempt.get_audio || attempt.get_video) {
|
while (attempt.get_audio || attempt.get_video) {
|
||||||
@ -1271,7 +1259,7 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
hasRealVideo,
|
hasRealVideo,
|
||||||
});
|
});
|
||||||
return finalMedia;
|
return finalMedia;
|
||||||
}, [session]);
|
}, [session.name]);
|
||||||
|
|
||||||
// Initialize media once
|
// Initialize media once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
161
client/src/NameSetter.tsx
Normal file
161
client/src/NameSetter.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, KeyboardEvent, useRef, useContext } from "react";
|
||||||
|
import {
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Tooltip,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { GlobalContext, Session } from "./GlobalContext";
|
||||||
|
|
||||||
|
interface NameSetterProps {
|
||||||
|
onNameSet?: () => void;
|
||||||
|
initialName?: string;
|
||||||
|
initialPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NameSetter: React.FC<NameSetterProps> = ({ onNameSet, initialName = "", initialPassword = "" }) => {
|
||||||
|
const { session, sendJsonMessage } = useContext(GlobalContext);
|
||||||
|
const [editName, setEditName] = useState<string>(initialName);
|
||||||
|
const [editPassword, setEditPassword] = useState<string>(initialPassword);
|
||||||
|
const [showDialog, setShowDialog] = useState<boolean>(!session.name);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const setName = (name: string) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
sendJsonMessage({
|
||||||
|
type: "player-name",
|
||||||
|
data: { name, password: editPassword ? editPassword : undefined },
|
||||||
|
});
|
||||||
|
if (onNameSet) {
|
||||||
|
onNameSet();
|
||||||
|
}
|
||||||
|
setShowDialog(false);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setEditName("");
|
||||||
|
setEditPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (passwordInputRef.current) {
|
||||||
|
passwordInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const newName = editName.trim();
|
||||||
|
if (!newName || (session?.name && session.name === newName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setName(newName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDialog = () => {
|
||||||
|
setEditName(session.name || "");
|
||||||
|
setEditPassword("");
|
||||||
|
setShowDialog(true);
|
||||||
|
// Focus the name input when dialog opens
|
||||||
|
setTimeout(() => {
|
||||||
|
if (nameInputRef.current) {
|
||||||
|
nameInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
setEditName("");
|
||||||
|
setEditPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNameChanged = editName.trim() !== (session.name || "");
|
||||||
|
const canSubmit = editName.trim() && hasNameChanged && !isSubmitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||||
|
{session.name && !showDialog && (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Typography>You are logged in as: {session.name}</Typography>
|
||||||
|
<Button variant="outlined" size="small" onClick={handleOpenDialog}>
|
||||||
|
Change Name
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog for name change */}
|
||||||
|
<Dialog open={showDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{session.name ? "Change Your Name" : "Enter Your Name"}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{session.name ? "Enter a new name to change your current name." : "Enter your name to join the lobby."}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
You can optionally set a password to reserve this name; supply it again to takeover the name from another
|
||||||
|
client.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
inputRef={nameInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e): void => {
|
||||||
|
setEditName(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
placeholder="Your name"
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
inputRef={passwordInputRef}
|
||||||
|
type="password"
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(e): void => setEditPassword(e.target.value)}
|
||||||
|
onKeyDown={handlePasswordKeyDown}
|
||||||
|
placeholder="Optional password"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip title="Optional: choose a short password to reserve this name. Keep it secret.">
|
||||||
|
<span />
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
color={hasNameChanged ? "primary" : "inherit"}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Changing..." : session.name ? "Change Name" : "Join"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NameSetter;
|
@ -1,191 +1,210 @@
|
|||||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useContext } from "react";
|
||||||
import Paper from "@mui/material/Paper";
|
import Paper from "@mui/material/Paper";
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
|
|
||||||
import "./PlayerList.css";
|
import "./PlayerList.css";
|
||||||
import { PlayerColor } from "./PlayerColor";
|
import { MediaControl, MediaAgent, Peer } from "./MediaControl";
|
||||||
import { MediaAgent, MediaControl, Session } from "./MediaControl";
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
import { GlobalContext } from "./GlobalContext";
|
import { GlobalContext } from "./GlobalContext";
|
||||||
|
import useWebSocket from "react-use-websocket";
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
type Player = {
|
||||||
|
name: string;
|
||||||
interface PlayerListProps {
|
session_id: string;
|
||||||
socketUrl?: string;
|
live: boolean;
|
||||||
session?: Session;
|
local: boolean /* Client side variable */;
|
||||||
}
|
protected?: boolean;
|
||||||
|
has_media?: boolean; // Whether this Player provides audio/video streams
|
||||||
const PlayerList: React.FC<PlayerListProps> = ({ socketUrl, session }) => {
|
bot_run_id?: string;
|
||||||
const { ws, name, sendJsonMessage } = useContext(GlobalContext);
|
bot_provider_id?: string;
|
||||||
const [players, setPlayers] = useState<{ [key: string]: any }>({});
|
bot_instance_id?: string; // For bot instances
|
||||||
const [unselected, setUneslected] = useState<string[]>([]);
|
muted?: boolean;
|
||||||
const [state, setState] = useState<string>("lobby");
|
video_on?: boolean;
|
||||||
const [color, setColor] = useState<string | undefined>(undefined);
|
|
||||||
const [peers, setPeers] = useState<{ [key: string]: any }>({});
|
|
||||||
|
|
||||||
const onWsMessage = (event: MessageEvent) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
switch (data.type) {
|
|
||||||
case "game-update":
|
|
||||||
console.log(`player-list - game update`, data.update);
|
|
||||||
|
|
||||||
if ("unselected" in data.update) {
|
|
||||||
setUneslected(data.update.unselected);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("players" in data.update) {
|
|
||||||
let found = false;
|
|
||||||
for (const key in data.update.players) {
|
|
||||||
if (data.update.players[key].name === name) {
|
|
||||||
found = true;
|
|
||||||
setColor(key);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
setColor(undefined);
|
|
||||||
}
|
|
||||||
setPlayers(data.update.players);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("state" in data.update && data.update.state !== state) {
|
|
||||||
setState(data.update.state);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const refWsMessage = useRef(onWsMessage);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refWsMessage.current = onWsMessage;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ws) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
|
|
||||||
ws.addEventListener("message", cbMessage);
|
|
||||||
return () => {
|
|
||||||
ws.removeEventListener("message", cbMessage);
|
|
||||||
};
|
|
||||||
}, [ws, refWsMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sendJsonMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendJsonMessage({
|
|
||||||
type: "get",
|
|
||||||
fields: ["state", "players", "unselected"],
|
|
||||||
});
|
|
||||||
}, [ws]);
|
|
||||||
|
|
||||||
const toggleSelected = (key: string) => {
|
|
||||||
ws!.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "set",
|
|
||||||
field: "color",
|
|
||||||
value: color === key ? "" : key,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const playerElements: React.ReactElement[] = [];
|
const PlayerList: React.FC = () => {
|
||||||
|
const { session, socketUrl } = useContext(GlobalContext);
|
||||||
|
const [Players, setPlayers] = useState<Player[] | null>(null);
|
||||||
|
const [peers, setPeers] = useState<Record<string, Peer>>({});
|
||||||
|
|
||||||
const inLobby = state === "lobby";
|
const sortPlayers = useCallback(
|
||||||
const sortedPlayers: any[] = [];
|
(A: any, B: any) => {
|
||||||
|
if (!session) {
|
||||||
for (const key in players) {
|
return 0;
|
||||||
sortedPlayers.push(players[key]);
|
|
||||||
}
|
}
|
||||||
|
/* active Player first */
|
||||||
const sortPlayers = (A: any, B: any) => {
|
if (A.name === session.name) {
|
||||||
/* active player first */
|
|
||||||
if (A.name === name) {
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (B.name === name) {
|
if (B.name === session.name) {
|
||||||
return +1;
|
return +1;
|
||||||
}
|
}
|
||||||
|
/* Sort active Players first */
|
||||||
/* Sort active players first */
|
|
||||||
if (A.name && !B.name) {
|
if (A.name && !B.name) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (B.name && !A.name) {
|
if (B.name && !A.name) {
|
||||||
return +1;
|
return +1;
|
||||||
}
|
}
|
||||||
|
/* Otherwise, sort by color */
|
||||||
/* Ohterwise, sort by color */
|
if (A.color && B.color) {
|
||||||
return A.color.localeCompare(B.color);
|
return A.color.localeCompare(B.color);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
[session]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the WebSocket hook for room events with automatic reconnection
|
||||||
|
const { sendJsonMessage } = useWebSocket(socketUrl, {
|
||||||
|
share: true,
|
||||||
|
shouldReconnect: (closeEvent) => true, // Auto-reconnect on connection loss
|
||||||
|
reconnectInterval: 5000,
|
||||||
|
onMessage: (event: MessageEvent) => {
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
const data: any = message.data;
|
||||||
|
switch (message.type) {
|
||||||
|
case "room_state": {
|
||||||
|
type RoomStateData = {
|
||||||
|
participants: Player[];
|
||||||
|
};
|
||||||
|
const room_state = data as RoomStateData;
|
||||||
|
console.log(`Players - room_state`, room_state.participants);
|
||||||
|
room_state.participants.forEach((Player) => {
|
||||||
|
Player.local = Player.session_id === session.id;
|
||||||
|
});
|
||||||
|
room_state.participants.sort(sortPlayers);
|
||||||
|
setPlayers(room_state.participants);
|
||||||
|
// Initialize peers with remote mute/video state
|
||||||
|
setPeers((prevPeers) => {
|
||||||
|
const updated: Record<string, Peer> = { ...prevPeers };
|
||||||
|
room_state.participants.forEach((Player) => {
|
||||||
|
// Only update remote peers, never overwrite local peer object
|
||||||
|
if (!Player.local && updated[Player.session_id]) {
|
||||||
|
updated[Player.session_id] = {
|
||||||
|
...updated[Player.session_id],
|
||||||
|
muted: Player.muted ?? false,
|
||||||
|
video_on: Player.video_on ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
sortedPlayers.sort(sortPlayers);
|
|
||||||
|
|
||||||
/* Array of just names... */
|
|
||||||
unselected.sort((A, B) => {
|
|
||||||
/* active player first */
|
|
||||||
if (A === name) {
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
if (B === name) {
|
});
|
||||||
return +1;
|
return updated;
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
/* Then sort alphabetically */
|
case "update_name": {
|
||||||
return A.localeCompare(B);
|
// Update local session name immediately
|
||||||
|
if (data && typeof data.name === "string") {
|
||||||
|
session.name = data.name;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "peer_state_update": {
|
||||||
|
// Update peer state in peers, but do not override local mute
|
||||||
|
setPeers((prevPeers) => {
|
||||||
|
const updated = { ...prevPeers };
|
||||||
|
const peerId = data.peer_id;
|
||||||
|
if (peerId && updated[peerId]) {
|
||||||
|
updated[peerId] = {
|
||||||
|
...updated[peerId],
|
||||||
|
muted: data.muted,
|
||||||
|
video_on: data.video_on,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const videoClass = sortedPlayers.length <= 2 ? "Medium" : "Small";
|
useEffect(() => {
|
||||||
|
if (Players !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJsonMessage({
|
||||||
|
type: "list_Players",
|
||||||
|
});
|
||||||
|
}, [Players, sendJsonMessage]);
|
||||||
|
|
||||||
sortedPlayers.forEach((player) => {
|
return (
|
||||||
const playerName = player.name;
|
<Box sx={{ position: "relative", width: "100%" }}>
|
||||||
const selectable = inLobby && (player.status === "Not active" || color === player.color);
|
<Paper
|
||||||
playerElements.push(
|
className={`PlayerList Medium`}
|
||||||
<div
|
sx={{
|
||||||
data-selectable={selectable}
|
maxWidth: { xs: "100%", sm: 500 },
|
||||||
data-selected={player.color === color}
|
p: { xs: 1, sm: 2 },
|
||||||
className="PlayerEntry"
|
m: { xs: 0, sm: 2 },
|
||||||
onClick={() => {
|
|
||||||
inLobby && selectable && toggleSelected(player.color);
|
|
||||||
}}
|
}}
|
||||||
key={`player-${player.color}`}
|
|
||||||
>
|
>
|
||||||
<div>
|
<MediaAgent {...{ session, socketUrl, peers, setPeers }} />
|
||||||
<PlayerColor color={player.color} />
|
<List className="PlayerSelector">
|
||||||
<div className="Name">{playerName ? playerName : "Available"}</div>
|
{Players?.map((Player) => (
|
||||||
{playerName && !player.live && <div className="NoNetwork"></div>}
|
<Box
|
||||||
</div>
|
key={Player.session_id}
|
||||||
{playerName && player.live && (
|
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
|
||||||
<MediaControl className={videoClass} peer={peers[playerName]} isSelf={player.color === color} />
|
className={`PlayerEntry ${Player.local ? "PlayerSelf" : ""}`}
|
||||||
)}
|
>
|
||||||
{!playerName && <div></div>}
|
<Box>
|
||||||
</div>
|
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
);
|
<Box style={{ display: "flex-wrap", alignItems: "center" }}>
|
||||||
});
|
<div className="Name">{Player.name ? Player.name : Player.session_id}</div>
|
||||||
|
{Player.protected && (
|
||||||
const waiting = unselected.map((player) => {
|
<div
|
||||||
return (
|
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
|
||||||
<div className={player === name ? "Self" : ""} key={player}>
|
title="This name is protected with a password"
|
||||||
<div>{player}</div>
|
>
|
||||||
<MediaControl className={"Small"} peer={peers[player]} isSelf={name === player} />
|
🔒
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper className={`PlayerList ${videoClass}`}>
|
|
||||||
{socketUrl && session && <MediaAgent {...{ socketUrl, setPeers, peers, session }} />}
|
|
||||||
<List className="PlayerSelector">{playerElements}</List>
|
|
||||||
{unselected && unselected.length !== 0 && (
|
|
||||||
<div className="Unselected">
|
|
||||||
<div>In lobby</div>
|
|
||||||
<div>{waiting}</div>
|
|
||||||
</div>
|
</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 && peers[Player.session_id] && (Player.local || Player.has_media !== false) ? (
|
||||||
|
<MediaControl
|
||||||
|
className="Medium"
|
||||||
|
key={Player.session_id}
|
||||||
|
peer={peers[Player.session_id]}
|
||||||
|
isSelf={Player.local}
|
||||||
|
sendJsonMessage={Player.local ? sendJsonMessage : undefined}
|
||||||
|
remoteAudioMuted={peers[Player.session_id].muted}
|
||||||
|
remoteVideoOff={peers[Player.session_id].video_on === false}
|
||||||
|
/>
|
||||||
|
) : Player.name && Player.live && Player.has_media === false ? (
|
||||||
|
<div
|
||||||
|
className="Video fade-in"
|
||||||
|
style={{
|
||||||
|
background: "#333",
|
||||||
|
color: "#fff",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
💬 Chat Only
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video className="Video"></video>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
229
client/src/RoomView.css
Normal file
229
client/src/RoomView.css
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'Droid Sans', 'Arial Narrow', Arial, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100vw;
|
||||||
|
/* height: 100vh; breaks on mobile -- not needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
background-image: url("./assets/tabletop.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs {
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs .Dialog {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.25rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs .Dialog > div {
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs .Dialog > div > div:first-child {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs .TurnNoticeDialog {
|
||||||
|
background-color: #7a680060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs .ErrorDialog {
|
||||||
|
background-color: #40000060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs .WarningDialog {
|
||||||
|
background-color: #00000060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Game {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Board {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .PlayersStatus {
|
||||||
|
z-index: 500; /* Under Hand */
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .PlayersStatus.ActivePlayer {
|
||||||
|
z-index: 1500; /* On top of Hand */
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Hand {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
height: 11rem;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 25rem;
|
||||||
|
max-width: 25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Sidebar .Chat {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Trade {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
z-index: 25000;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 20000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Dialogs > * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .ViewCard {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .Winner {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.RoomView .HouseRules {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .ChooseCard {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView button {
|
||||||
|
margin: 0.25rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid black; /* why !important */
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .MuiButton-text {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
border: 1px solid #ccc; /* why !important */
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .ActivitiesBox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: absolute;
|
||||||
|
left: 1em;
|
||||||
|
top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .DiceRoll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
/*
|
||||||
|
left: 1rem;
|
||||||
|
top: 5rem;*/
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: left;
|
||||||
|
align-items: left;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .DiceRoll div:not(:last-child) {
|
||||||
|
border: 1px solid black;
|
||||||
|
background-color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.RoomView .DiceRoll div:last-child {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView .DiceRoll .Dice {
|
||||||
|
margin: 0.25rem;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
427
client/src/RoomView.tsx
Normal file
427
client/src/RoomView.tsx
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
import React, { useState, useEffect, useContext } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
|
|
||||||
|
import Paper from "@mui/material/Paper";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
|
||||||
|
import { GlobalContext, GlobalContextType } from "./GlobalContext";
|
||||||
|
import { PlayerList } from "./PlayerList";
|
||||||
|
import { Chat } from "./Chat";
|
||||||
|
import { Board } from "./Board";
|
||||||
|
import { Actions } from "./Actions";
|
||||||
|
import { ws_base, base } from "./Common";
|
||||||
|
import { GameOrder } from "./GameOrder";
|
||||||
|
import { Activities } from "./Activities";
|
||||||
|
import { SelectPlayer } from "./SelectPlayer";
|
||||||
|
import { PlayersStatus } from "./PlayersStatus";
|
||||||
|
import { ViewCard } from "./ViewCard";
|
||||||
|
import { ChooseCard } from "./ChooseCard";
|
||||||
|
import { Hand } from "./Hand";
|
||||||
|
import { Trade } from "./Trade";
|
||||||
|
import { Winner } from "./Winner";
|
||||||
|
import { HouseRules } from "./HouseRules";
|
||||||
|
import { Dice } from "./Dice";
|
||||||
|
import { assetsPath } from "./Common";
|
||||||
|
import { Session, Room } from "./GlobalContext";
|
||||||
|
// history replaced by react-router's useNavigate
|
||||||
|
import "./RoomView.css";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
|
|
||||||
|
import itsYourTurnAudio from "./assets/its-your-turn.mp3";
|
||||||
|
import robberAudio from "./assets/robber.mp3";
|
||||||
|
import knightsAudio from "./assets/the-knights-who-say-ni.mp3";
|
||||||
|
import volcanoAudio from "./assets/volcano-eruption.mp3";
|
||||||
|
import { ConnectionStatus } from "./ConnectionStatus";
|
||||||
|
import NameSetter from "./NameSetter";
|
||||||
|
|
||||||
|
const audioFiles: Record<string, string> = {
|
||||||
|
"its-your-turn.mp3": itsYourTurnAudio,
|
||||||
|
"robber.mp3": robberAudio,
|
||||||
|
"the-knights-who-say-ni.mp3": knightsAudio,
|
||||||
|
"volcano-eruption.mp3": volcanoAudio,
|
||||||
|
};
|
||||||
|
|
||||||
|
type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean };
|
||||||
|
const audioEffects: Record<string, AudioEffect | undefined> = {};
|
||||||
|
|
||||||
|
const loadAudio = (src: string) => {
|
||||||
|
const audio = document.createElement("audio") as AudioEffect;
|
||||||
|
audio.src = audioFiles[src];
|
||||||
|
console.log("Loading audio:", audio.src);
|
||||||
|
audio.setAttribute("preload", "auto");
|
||||||
|
audio.setAttribute("controls", "none");
|
||||||
|
audio.style.display = "none";
|
||||||
|
document.body.appendChild(audio);
|
||||||
|
audio.load();
|
||||||
|
audio.addEventListener("error", (e) => console.error("Audio load error:", e, audio.src));
|
||||||
|
audio.addEventListener("canplay", () => console.log("Audio can play:", audio.src));
|
||||||
|
return audio;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomProps = {
|
||||||
|
session: Session;
|
||||||
|
setSession: React.Dispatch<React.SetStateAction<Session | null>>;
|
||||||
|
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoomView = (props: RoomProps) => {
|
||||||
|
const { session, setSession, setError } = props;
|
||||||
|
const [socketUrl, setSocketUrl] = useState<string | null>(null);
|
||||||
|
const { roomName = "default" } = useParams<{ roomName: string }>();
|
||||||
|
const [reconnectAttempt, setReconnectAttempt] = useState<number>(0);
|
||||||
|
|
||||||
|
const [name, setName] = useState<string>("");
|
||||||
|
const [warning, setWarning] = useState<string | undefined>(undefined);
|
||||||
|
const [loaded, setLoaded] = useState<boolean>(false);
|
||||||
|
|
||||||
|
type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record<string, number> };
|
||||||
|
type PrivateType = { name?: string; color?: string; turnNotice?: string };
|
||||||
|
|
||||||
|
const [dice, setDice] = useState<number[] | undefined>(undefined);
|
||||||
|
const [state, setState] = useState<string | undefined>(undefined);
|
||||||
|
const [color, setColor] = useState<string | undefined>(undefined);
|
||||||
|
const [priv, setPriv] = useState<PrivateType | undefined>(undefined);
|
||||||
|
const [turn, setTurn] = useState<Turn | undefined>(undefined);
|
||||||
|
const [buildActive, setBuildActive] = useState<boolean>(false);
|
||||||
|
const [tradeActive, setTradeActive] = useState<boolean>(false);
|
||||||
|
const [cardActive, setCardActive] = useState<unknown>(undefined);
|
||||||
|
const [houseRulesActive, setHouseRulesActive] = useState<boolean>(false);
|
||||||
|
const [winnerDismissed, setWinnerDismissed] = useState<boolean>(false);
|
||||||
|
const [count, setCount] = useState<number>(0);
|
||||||
|
const [audio, setAudio] = useState<boolean>(
|
||||||
|
localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false
|
||||||
|
);
|
||||||
|
const [animations, setAnimations] = useState<boolean>(
|
||||||
|
localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false
|
||||||
|
);
|
||||||
|
const [volume, setVolume] = useState<number>(
|
||||||
|
localStorage.getItem("volume") ? parseFloat(localStorage.getItem("volume") as string) : 0.5
|
||||||
|
);
|
||||||
|
const fields = ["id", "state", "color", "name", "private", "dice", "turn"];
|
||||||
|
|
||||||
|
const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl, {
|
||||||
|
onOpen: () => {
|
||||||
|
console.log("app - WebSocket connection opened.");
|
||||||
|
if (reconnectAttempt > 0) {
|
||||||
|
console.log("app - WebSocket reconnected after connection loss, refreshing room state");
|
||||||
|
}
|
||||||
|
sendJsonMessage({ type: "get", fields });
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
console.log("app - WebSocket connection closed.");
|
||||||
|
setReconnectAttempt((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
onError: (event: Event) => {
|
||||||
|
console.error("app - WebSocket error observed:", event);
|
||||||
|
// If we get a WebSocket error, it might be due to invalid room ID
|
||||||
|
// Reset the room state to force recreation
|
||||||
|
console.log("app - WebSocket error, clearing room state to force refresh");
|
||||||
|
setSocketUrl(null);
|
||||||
|
},
|
||||||
|
shouldReconnect: (closeEvent) => {
|
||||||
|
// Don't reconnect if the room doesn't exist (4xx errors)
|
||||||
|
if (closeEvent.code >= 4000 && closeEvent.code < 5000) {
|
||||||
|
console.log("app - WebSocket closed with client error, not reconnecting");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
reconnectInterval: 5000, // Retry every 5 seconds
|
||||||
|
onReconnectStop: (numAttempts) => {
|
||||||
|
console.log(`Stopped reconnecting after ${numAttempts} attempts`);
|
||||||
|
},
|
||||||
|
share: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socketUrl = `${ws_base}/${roomName}`;
|
||||||
|
console.log("app - connecting to", socketUrl);
|
||||||
|
setSocketUrl(socketUrl);
|
||||||
|
}, [roomName, session.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastJsonMessage || !session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data: any = lastJsonMessage;
|
||||||
|
switch (data.type) {
|
||||||
|
case "error":
|
||||||
|
console.error(`App - error`, data.error);
|
||||||
|
setError(data.error);
|
||||||
|
break;
|
||||||
|
case "warning":
|
||||||
|
console.warn(`App - warning`, data.warning);
|
||||||
|
setWarning(data.warning);
|
||||||
|
setTimeout(() => {
|
||||||
|
setWarning("");
|
||||||
|
}, 3000);
|
||||||
|
break;
|
||||||
|
case "game-update":
|
||||||
|
if (!loaded) {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
console.log(`app - message - ${data.type}`, data.update);
|
||||||
|
|
||||||
|
if ("private" in data.update && !equal(priv, data.update.private)) {
|
||||||
|
const priv = data.update.private;
|
||||||
|
if (priv.name !== name) {
|
||||||
|
setName(priv.name);
|
||||||
|
}
|
||||||
|
if (priv.color !== color) {
|
||||||
|
setColor(priv.color);
|
||||||
|
}
|
||||||
|
setPriv(priv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("name" in data.update) {
|
||||||
|
if (data.update.name) {
|
||||||
|
setName(data.update.name);
|
||||||
|
} else {
|
||||||
|
setWarning("");
|
||||||
|
setError("");
|
||||||
|
setPriv(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("state" in data.update && data.update.state !== state) {
|
||||||
|
if (data.update.state !== "winner" && winnerDismissed) {
|
||||||
|
setWinnerDismissed(false);
|
||||||
|
}
|
||||||
|
setState(data.update.state);
|
||||||
|
}
|
||||||
|
if ("dice" in data.update && !equal(data.update.dice, dice)) {
|
||||||
|
setDice(data.update.dice);
|
||||||
|
}
|
||||||
|
if ("turn" in data.update && !equal(data.update.turn, turn)) {
|
||||||
|
setTurn(data.update.turn);
|
||||||
|
}
|
||||||
|
if ("color" in data.update && data.update.color !== color) {
|
||||||
|
setColor(data.update.color);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [lastJsonMessage, session, setError, setSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === "volcano") {
|
||||||
|
if (!audioEffects.volcano) {
|
||||||
|
audioEffects.volcano = loadAudio("volcano-eruption.mp3");
|
||||||
|
audioEffects.volcano.volume = volume * volume;
|
||||||
|
} else {
|
||||||
|
if (!audioEffects.volcano.hasPlayed && audioEffects.volcano.readyState >= 2) {
|
||||||
|
audioEffects.volcano.hasPlayed = true;
|
||||||
|
audioEffects.volcano.play().catch((e) => console.error("Audio play failed:", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (audioEffects.volcano) {
|
||||||
|
audioEffects.volcano.hasPlayed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state, volume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (turn && turn.color === color && state !== "room") {
|
||||||
|
if (!audioEffects.yourTurn) {
|
||||||
|
audioEffects.yourTurn = loadAudio("its-your-turn.mp3");
|
||||||
|
audioEffects.yourTurn.volume = volume * volume;
|
||||||
|
} else {
|
||||||
|
if (!audioEffects.yourTurn.hasPlayed && audioEffects.yourTurn.readyState >= 2) {
|
||||||
|
audioEffects.yourTurn.hasPlayed = true;
|
||||||
|
audioEffects.yourTurn.play().catch((e) => console.error("Audio play failed:", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (turn) {
|
||||||
|
if (audioEffects.yourTurn) {
|
||||||
|
audioEffects.yourTurn.hasPlayed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turn && turn.roll === 7) {
|
||||||
|
if (!audioEffects.robber) {
|
||||||
|
audioEffects.robber = loadAudio("robber.mp3");
|
||||||
|
audioEffects.robber.volume = volume * volume;
|
||||||
|
} else {
|
||||||
|
if (!audioEffects.robber.hasPlayed && audioEffects.robber.readyState >= 2) {
|
||||||
|
audioEffects.robber.hasPlayed = true;
|
||||||
|
audioEffects.robber.play().catch((e) => console.error("Audio play failed:", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (turn) {
|
||||||
|
if (audioEffects.robber) {
|
||||||
|
audioEffects.robber.hasPlayed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turn && turn.actions && turn.actions.indexOf("playing-knight") !== -1) {
|
||||||
|
if (!audioEffects.knights) {
|
||||||
|
audioEffects.knights = loadAudio("the-knights-who-say-ni.mp3");
|
||||||
|
audioEffects.knights.volume = volume * volume;
|
||||||
|
} else {
|
||||||
|
if (!audioEffects.knights.hasPlayed && audioEffects.knights.readyState >= 2) {
|
||||||
|
audioEffects.knights.hasPlayed = true;
|
||||||
|
audioEffects.knights.play().catch((e) => console.error("Audio play failed:", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (turn && turn.actions && turn.actions.indexOf("playing-knight") === -1) {
|
||||||
|
if (audioEffects.knights) {
|
||||||
|
audioEffects.knights.hasPlayed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state, turn, color, volume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const key in audioEffects) {
|
||||||
|
audioEffects[key].volume = volume * volume;
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
if (readyState !== ReadyState.OPEN || !session) {
|
||||||
|
return <ConnectionStatus readyState={readyState} reconnectAttempt={reconnectAttempt} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage }}>
|
||||||
|
<div className="RoomView">
|
||||||
|
{!name ? (
|
||||||
|
<Paper>
|
||||||
|
<NameSetter />
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="ActivitiesBox">
|
||||||
|
<Activities />
|
||||||
|
{dice && dice.length && (
|
||||||
|
<div className="DiceRoll">
|
||||||
|
{dice.length === 1 && <div>Volcano roll!</div>}
|
||||||
|
{dice.length === 2 && <div>Current roll</div>}
|
||||||
|
<div>
|
||||||
|
<Dice pips={dice[0]} />
|
||||||
|
{dice.length === 2 && <Dice pips={dice[1]} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="Game">
|
||||||
|
<div className="Dialogs">
|
||||||
|
{priv && priv.turnNotice && (
|
||||||
|
<div className="Dialog TurnNoticeDialog">
|
||||||
|
<Paper className="TurnNotice">
|
||||||
|
<div>{priv.turnNotice}</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
sendJsonMessage({ type: "turn-notice" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warning && (
|
||||||
|
<div className="Dialog WarningDialog">
|
||||||
|
<Paper className="Warning">
|
||||||
|
<div>{warning}</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setWarning("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state === "normal" && <SelectPlayer />}
|
||||||
|
{color && state === "game-order" && <GameOrder />}
|
||||||
|
|
||||||
|
{!winnerDismissed && <Winner {...{ winnerDismissed, setWinnerDismissed }} />}
|
||||||
|
{houseRulesActive && <HouseRules {...{ houseRulesActive, setHouseRulesActive }} />}
|
||||||
|
<ViewCard {...{ cardActive, setCardActive }} />
|
||||||
|
<ChooseCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Board animations={animations} />
|
||||||
|
<PlayersStatus active={false} />
|
||||||
|
<PlayersStatus active={true} />
|
||||||
|
<Hand {...{ buildActive, setBuildActive, setCardActive }} />
|
||||||
|
</div>
|
||||||
|
<div className="Sidebar">
|
||||||
|
{name !== "" && volume !== undefined && (
|
||||||
|
<Paper className="Volume">
|
||||||
|
<div>Audio effects</div>{" "}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="audio"
|
||||||
|
name="audio"
|
||||||
|
defaultChecked={audio ? true : false}
|
||||||
|
onInput={() => {
|
||||||
|
const value = !audio;
|
||||||
|
localStorage.setItem("audio", JSON.stringify(value));
|
||||||
|
setAudio(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>Sound effects volume</div>{" "}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="volume"
|
||||||
|
name="volume"
|
||||||
|
value={volume * 100}
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
onInput={(e) => {
|
||||||
|
const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100;
|
||||||
|
|
||||||
|
localStorage.setItem("volume", alpha.toString());
|
||||||
|
setVolume(alpha);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>Animations</div>{" "}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="animations"
|
||||||
|
name="animations"
|
||||||
|
defaultChecked={animations ? true : false}
|
||||||
|
onInput={() => {
|
||||||
|
const value = !animations;
|
||||||
|
localStorage.setItem("animations", JSON.stringify(value));
|
||||||
|
setAnimations(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
{name && <PlayerList />}
|
||||||
|
{/* Trade is an untyped JS component; assert its type to avoid `any` */}
|
||||||
|
{(() => {
|
||||||
|
const TradeComponent = Trade as unknown as React.ComponentType<{
|
||||||
|
tradeActive: boolean;
|
||||||
|
setTradeActive: (v: boolean) => void;
|
||||||
|
}>;
|
||||||
|
return <TradeComponent tradeActive={tradeActive} setTradeActive={setTradeActive} />;
|
||||||
|
})()}
|
||||||
|
{name !== "" && <Chat />}
|
||||||
|
{/* name !== "" && <VideoFeeds/> */}
|
||||||
|
{loaded && (
|
||||||
|
<Actions
|
||||||
|
{...{
|
||||||
|
buildActive,
|
||||||
|
setBuildActive,
|
||||||
|
tradeActive,
|
||||||
|
setTradeActive,
|
||||||
|
houseRulesActive,
|
||||||
|
setHouseRulesActive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlobalContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { RoomView };
|
45
client/src/api-client.ts
Normal file
45
client/src/api-client.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Session, Room } from "./GlobalContext";
|
||||||
|
import { base } from "./Common";
|
||||||
|
const sessionApi = {
|
||||||
|
getCurrent: async (): Promise<Session> => {
|
||||||
|
const response = await fetch(`${base}/api/v1/games/`, {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin", /* include cookies */
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to connect to Ketr Ketran game server! Try refreshing your browser in a few seconds.`);
|
||||||
|
}
|
||||||
|
const session : any = await response.json();
|
||||||
|
if (!session.id) {
|
||||||
|
session.id = session.player;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
createRoom: async (name?: string): Promise<Room> => {
|
||||||
|
const response = await fetch(`${base}/api/v1/games/${name || ''}`, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch session: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const room : Room = await response.json();
|
||||||
|
if (!room.name) {
|
||||||
|
room.name = room.id;
|
||||||
|
}
|
||||||
|
if (!room.id) {
|
||||||
|
room.id = room.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { sessionApi };
|
@ -17,16 +17,16 @@
|
|||||||
}*/
|
}*/
|
||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100dvh;
|
||||||
width: 100%;
|
width: 100dvw;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
position: relative;
|
display: flex;
|
||||||
height: 100%;
|
height: 100dvh;
|
||||||
width: 100%;
|
width: 100dvw;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
@ -35,8 +35,3 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
|
||||||
monospace;
|
|
||||||
}
|
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
const fetch = require('node-fetch');
|
// @ts-nocheck
|
||||||
const WebSocket = require('ws');
|
import fetch from 'node-fetch';
|
||||||
const fs = require('fs').promises;
|
import WebSocket from 'ws';
|
||||||
const calculateLongestRoad = require('./longest-road.js');
|
import fs from 'fs';
|
||||||
|
import calculateLongestRoad from './longest-road';
|
||||||
|
|
||||||
const { getValidRoads, getValidCorners } = require('../util/validLocations.js');
|
import { getValidRoads, getValidCorners } from '../util/validLocations';
|
||||||
const { layout, staticData } = require('../util/layout.js');
|
import { layout, staticData } from '../util/layout';
|
||||||
|
|
||||||
const version = '0.0.1';
|
const version = '0.0.1';
|
||||||
|
|
||||||
@ -24,14 +25,14 @@ const server = process.argv[2];
|
|||||||
const gameId = process.argv[3];
|
const gameId = process.argv[3];
|
||||||
const name = process.argv[4];
|
const name = process.argv[4];
|
||||||
|
|
||||||
const game = {};
|
const game: any = {};
|
||||||
const anyValue = undefined;
|
const anyValue = undefined;
|
||||||
|
|
||||||
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
|
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
|
||||||
|
|
||||||
/* Do not use arrow function as this is rebound to have
|
/* Do not use arrow function as this is rebound to have
|
||||||
* this as the WebSocket */
|
* this as the WebSocket */
|
||||||
let send = function (data) {
|
let send = function (this: WebSocket, data: any) {
|
||||||
if (data.type === 'get') {
|
if (data.type === 'get') {
|
||||||
console.log(`ws - send: get`, data.fields);
|
console.log(`ws - send: get`, data.fields);
|
||||||
} else {
|
} else {
|
||||||
@ -40,7 +41,7 @@ let send = function (data) {
|
|||||||
this.send(JSON.stringify(data));
|
this.send(JSON.stringify(data));
|
||||||
};
|
};
|
||||||
|
|
||||||
const error = (e) => {
|
const error = (e: any) => {
|
||||||
console.log(`ws - error`, e);
|
console.log(`ws - error`, e);
|
||||||
};
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
|||||||
const { layout } = require('../util/layout.js');
|
// @ts-nocheck
|
||||||
|
import { layout } from '../util/layout';
|
||||||
|
|
||||||
const processCorner = (game, color, cornerIndex, placedCorner) => {
|
const processCorner = (game: any, color: string, cornerIndex: number, placedCorner: any): number => {
|
||||||
/* If this corner is allocated and isn't assigned to the walking color, skip it */
|
/* If this corner is allocated and isn't assigned to the walking color, skip it */
|
||||||
if (placedCorner.color && placedCorner.color !== color) {
|
if (placedCorner.color && placedCorner.color !== color) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -13,7 +14,7 @@ const processCorner = (game, color, cornerIndex, placedCorner) => {
|
|||||||
placedCorner.walking = true;
|
placedCorner.walking = true;
|
||||||
/* Calculate the longest road branching from both corners */
|
/* Calculate the longest road branching from both corners */
|
||||||
let longest = 0;
|
let longest = 0;
|
||||||
layout.corners[cornerIndex].roads.forEach(roadIndex => {
|
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
|
||||||
const placedRoad = game.placements.roads[roadIndex];
|
const placedRoad = game.placements.roads[roadIndex];
|
||||||
if (placedRoad.walking) {
|
if (placedRoad.walking) {
|
||||||
return;
|
return;
|
||||||
@ -32,7 +33,7 @@ const processCorner = (game, color, cornerIndex, placedCorner) => {
|
|||||||
return longest;
|
return longest;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCornerGraph = (game, color, cornerIndex, placedCorner, set) => {
|
const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedCorner: any, set: any) => {
|
||||||
/* If this corner is allocated and isn't assigned to the walking color, skip it */
|
/* If this corner is allocated and isn't assigned to the walking color, skip it */
|
||||||
if (placedCorner.color && placedCorner.color !== color) {
|
if (placedCorner.color && placedCorner.color !== color) {
|
||||||
return;
|
return;
|
||||||
@ -44,13 +45,13 @@ const buildCornerGraph = (game, color, cornerIndex, placedCorner, set) => {
|
|||||||
|
|
||||||
placedCorner.walking = true;
|
placedCorner.walking = true;
|
||||||
/* Calculate the longest road branching from both corners */
|
/* Calculate the longest road branching from both corners */
|
||||||
layout.corners[cornerIndex].roads.forEach(roadIndex => {
|
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
|
||||||
const placedRoad = game.placements.roads[roadIndex];
|
const placedRoad = game.placements.roads[roadIndex];
|
||||||
buildRoadGraph(game, color, roadIndex, placedRoad, set);
|
buildRoadGraph(game, color, roadIndex, placedRoad, set);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const processRoad = (game, color, roadIndex, placedRoad) => {
|
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;
|
||||||
@ -75,7 +76,7 @@ const processRoad = (game, color, roadIndex, placedRoad) => {
|
|||||||
return roadLength;
|
return roadLength;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildRoadGraph = (game, color, roadIndex, placedRoad, set) => {
|
const buildRoadGraph = (game: any, color: string, roadIndex: number, placedRoad: any, set: any) => {
|
||||||
/* 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;
|
return;
|
||||||
@ -94,7 +95,7 @@ const buildRoadGraph = (game, color, roadIndex, placedRoad, set) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearRoadWalking = (game) => {
|
const clearRoadWalking = (game: any) => {
|
||||||
/* Clear out walk markers on roads */
|
/* Clear out walk markers on roads */
|
||||||
layout.roads.forEach((item, itemIndex) => {
|
layout.roads.forEach((item, itemIndex) => {
|
||||||
delete game.placements.roads[itemIndex].walking;
|
delete game.placements.roads[itemIndex].walking;
|
||||||
@ -106,7 +107,7 @@ const clearRoadWalking = (game) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateRoadLengths = (game) => {
|
const calculateRoadLengths = (game: any) => {
|
||||||
const color = game.color;
|
const color = game.color;
|
||||||
clearRoadWalking(game);
|
clearRoadWalking(game);
|
||||||
|
|
||||||
@ -158,4 +159,4 @@ const calculateRoadLengths = (game) => {
|
|||||||
return final;
|
return final;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = calculateRoadLengths;
|
export default calculateRoadLengths;
|
@ -1,19 +1,20 @@
|
|||||||
"use strict";
|
// @ts-nocheck
|
||||||
|
|
||||||
process.env.TZ = "Etc/GMT";
|
process.env.TZ = "Etc/GMT";
|
||||||
|
|
||||||
console.log("Loading ketr.ketran");
|
console.log("Loading ketr.ketran");
|
||||||
|
|
||||||
const express = require("express"),
|
import express from "express";
|
||||||
bodyParser = require("body-parser"),
|
import bodyParser from "body-parser";
|
||||||
config = require("config"),
|
import config from "config";
|
||||||
session = require('express-session'),
|
import session from 'express-session';
|
||||||
basePath = require("./basepath"),
|
import basePath from "./basepath";
|
||||||
cookieParser = require("cookie-parser"),
|
import cookieParser from "cookie-parser";
|
||||||
app = express(),
|
import fs from 'fs';
|
||||||
fs = require('fs');
|
import http from "http";
|
||||||
|
|
||||||
const server = require("http").createServer(app);
|
const app = express();
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ app.use(cookieParser());
|
|||||||
// and URL for requests under the configured basePath so we can trace which
|
// and URL for requests under the configured basePath so we can trace which
|
||||||
// service (server or dev proxy) is handling requests and their returned
|
// service (server or dev proxy) is handling requests and their returned
|
||||||
// status during debugging. Keep this lightweight.
|
// status during debugging. Keep this lightweight.
|
||||||
app.use((req, res, next) => {
|
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const bp = app.get("basePath") || '/';
|
const bp = app.get("basePath") || '/';
|
||||||
if (req.url && req.url.indexOf(bp) === 0) {
|
if (req.url && req.url.indexOf(bp) === 0) {
|
||||||
@ -37,16 +38,17 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ws = require('express-ws')(app, server);
|
import expressWs from 'express-ws';
|
||||||
|
expressWs(app, server);
|
||||||
|
|
||||||
require("./console-line.js"); /* Monkey-patch console.log with line numbers */
|
require("./console-line"); /* Monkey-patch console.log with line numbers */
|
||||||
|
|
||||||
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
|
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
|
||||||
serverConfig = config.get("server");
|
serverConfig = config.get("server") as any;
|
||||||
|
|
||||||
console.log("Hosting server from: " + basePath);
|
console.log("Hosting server from: " + basePath);
|
||||||
|
|
||||||
let userDB, gameDB;
|
let userDB: any, gameDB: any;
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
@ -86,7 +88,7 @@ const users = require("./routes/users");
|
|||||||
app.use(basePath + "api/v1/users", users.router);
|
app.use(basePath + "api/v1/users", users.router);
|
||||||
*/
|
*/
|
||||||
|
|
||||||
app.use(function(err, req, res, next) {
|
app.use(function(err: any, req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
message: err.message,
|
message: err.message,
|
||||||
@ -110,7 +112,9 @@ process.on('SIGINT', () => {
|
|||||||
server.close(() => process.exit(1));
|
server.close(() => process.exit(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
require("./db/games").then(function(db) {
|
import { initGameDB } from './routes/games/store';
|
||||||
|
|
||||||
|
initGameDB().then(function(db) {
|
||||||
gameDB = db;
|
gameDB = db;
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
return require("./db/users").then(function(db) {
|
return require("./db/users").then(function(db) {
|
||||||
@ -126,7 +130,7 @@ require("./db/games").then(function(db) {
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("error", function(error) {
|
server.on("error", function(error: any) {
|
||||||
if (error.syscall !== "listen") {
|
if (error.syscall !== "listen") {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -145,3 +149,5 @@ server.on("error", function(error) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { app, server };
|
@ -1,5 +1,6 @@
|
|||||||
const fs = require('fs');
|
import fs from 'fs';
|
||||||
let basePathRaw = process.env.VITE_BASEPATH || '';
|
|
||||||
|
let basePathRaw = process.env['VITE_BASEPATH'] || '';
|
||||||
|
|
||||||
// If env not provided, try to detect a <base href="..."> in the
|
// If env not provided, try to detect a <base href="..."> in the
|
||||||
// built client's index.html (if present). This helps when the
|
// built client's index.html (if present). This helps when the
|
||||||
@ -29,4 +30,4 @@ if (basePath === '//') basePath = '/';
|
|||||||
|
|
||||||
console.log(`Using basepath ${basePath}`);
|
console.log(`Using basepath ${basePath}`);
|
||||||
|
|
||||||
module.exports = basePath;
|
export default basePath;
|
@ -1,30 +0,0 @@
|
|||||||
/* monkey-patch console.log to prefix with file/line-number */
|
|
||||||
if (process.env.LOG_LINE) {
|
|
||||||
let cwd = process.cwd(),
|
|
||||||
cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "\/([^:]*:[0-9]*).*$");
|
|
||||||
[ "log", "warn", "error" ].forEach(function(method) {
|
|
||||||
console[method] = (function () {
|
|
||||||
let orig = console[method];
|
|
||||||
return function () {
|
|
||||||
function getErrorObject() {
|
|
||||||
try {
|
|
||||||
throw Error('');
|
|
||||||
} catch (err) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let err = getErrorObject(),
|
|
||||||
caller_line = err.stack.split("\n")[3],
|
|
||||||
args = [caller_line.replace(cwdRe, "$1 -")];
|
|
||||||
|
|
||||||
/* arguments.unshift() doesn't exist... */
|
|
||||||
for (var i = 0; i < arguments.length; i++) {
|
|
||||||
args.push(arguments[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
orig.apply(this, args);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
28
server/console-line.ts
Executable file
28
server/console-line.ts
Executable file
@ -0,0 +1,28 @@
|
|||||||
|
/* monkey-patch console.log to prefix with file/line-number */
|
||||||
|
if (process.env['LOG_LINE']) {
|
||||||
|
let cwd = process.cwd(),
|
||||||
|
cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "\/([^:]*:[0-9]*).*$");
|
||||||
|
[ "log", "warn", "error" ].forEach(function(method: string) {
|
||||||
|
(console as any)[method] = (function () {
|
||||||
|
let orig = (console as any)[method];
|
||||||
|
return function (this: any, ...args: any[]) {
|
||||||
|
function getErrorObject(): Error {
|
||||||
|
try {
|
||||||
|
throw Error('');
|
||||||
|
} catch (err) {
|
||||||
|
return err as Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = getErrorObject(),
|
||||||
|
caller_line = err.stack?.split("\n")[3] || '',
|
||||||
|
prefixedArgs = [caller_line.replace(cwdRe, "$1 -")];
|
||||||
|
|
||||||
|
/* arguments.unshift() doesn't exist... */
|
||||||
|
prefixedArgs.push(...args);
|
||||||
|
|
||||||
|
orig.apply(this, prefixedArgs);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
@ -1,44 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
const fs = require('fs'),
|
|
||||||
path = require('path'),
|
|
||||||
Sequelize = require('sequelize'),
|
|
||||||
config = require('config');
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
const db = {
|
|
||||||
sequelize: new Sequelize(config.get("db.games")),
|
|
||||||
Sequelize: Sequelize
|
|
||||||
};
|
|
||||||
|
|
||||||
return db.sequelize.authenticate().then(function () {
|
|
||||||
const Game = db.sequelize.define('game', {
|
|
||||||
id: {
|
|
||||||
type: Sequelize.INTEGER,
|
|
||||||
primaryKey: true,
|
|
||||||
autoIncrement: true
|
|
||||||
},
|
|
||||||
path: Sequelize.STRING,
|
|
||||||
name: Sequelize.STRING,
|
|
||||||
}, {
|
|
||||||
timestamps: false,
|
|
||||||
classMethods: {
|
|
||||||
associate: function() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return db.sequelize.sync({
|
|
||||||
force: false
|
|
||||||
}).then(function () {
|
|
||||||
return db;
|
|
||||||
});
|
|
||||||
}).catch(function (error) {
|
|
||||||
console.log("ERROR: Failed to authenticate with GAMES DB");
|
|
||||||
console.log("ERROR: " + JSON.stringify(config.get("db"), null, 2));
|
|
||||||
console.log(error);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = init();
|
|
@ -1,70 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
const Sequelize = require('sequelize'),
|
|
||||||
config = require('config');
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
const db = {
|
|
||||||
sequelize: new Sequelize(config.get("db.users")),
|
|
||||||
Sequelize: Sequelize
|
|
||||||
};
|
|
||||||
|
|
||||||
return db.sequelize.authenticate().then(function () {
|
|
||||||
const User = db.sequelize.define('users', {
|
|
||||||
id: {
|
|
||||||
type: Sequelize.INTEGER,
|
|
||||||
primaryKey: true,
|
|
||||||
autoIncrement: true
|
|
||||||
},
|
|
||||||
displayName: Sequelize.STRING,
|
|
||||||
notes: Sequelize.STRING,
|
|
||||||
uid: Sequelize.STRING,
|
|
||||||
authToken: Sequelize.STRING,
|
|
||||||
authDate: Sequelize.DATE,
|
|
||||||
authenticated: Sequelize.BOOLEAN,
|
|
||||||
mailVerified: Sequelize.BOOLEAN,
|
|
||||||
mail: Sequelize.STRING,
|
|
||||||
memberSince: Sequelize.DATE,
|
|
||||||
password: Sequelize.STRING, /* SHA hash of user supplied password */
|
|
||||||
passwordExpires: Sequelize.DATE
|
|
||||||
}, {
|
|
||||||
timestamps: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const Authentication = db.sequelize.define('authentication', {
|
|
||||||
key: {
|
|
||||||
type: Sequelize.STRING,
|
|
||||||
primaryKey: true,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
issued: Sequelize.DATE,
|
|
||||||
type: {
|
|
||||||
type: Sequelize.ENUM,
|
|
||||||
values: [ 'account-setup', 'password-reset' ]
|
|
||||||
},
|
|
||||||
userId: {
|
|
||||||
type: Sequelize.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: User,
|
|
||||||
key: 'id',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
timestamps: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return db.sequelize.sync({
|
|
||||||
force: false
|
|
||||||
}).then(function () {
|
|
||||||
return db;
|
|
||||||
});
|
|
||||||
}).catch(function (error) {
|
|
||||||
console.log("ERROR: Failed to authenticate with USER DB");
|
|
||||||
console.log("ERROR: " + JSON.stringify(config.get("db"), null, 2));
|
|
||||||
console.log(error);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = init();
|
|
@ -1,8 +1,8 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const config = require("config"),
|
import config from "config";
|
||||||
crypto = require("crypto"),
|
import crypto from "crypto";
|
||||||
hb = require("handlebars");
|
import hb from "handlebars";
|
||||||
|
|
||||||
const templates = {
|
const templates = {
|
||||||
"verify": {
|
"verify": {
|
||||||
@ -52,13 +52,13 @@ const templates = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendVerifyMail = function(userDB, req, user) {
|
const sendVerifyMail = function(userDB: any, req: any, user: any): any {
|
||||||
return userDB.sequelize.query("DELETE FROM authentications WHERE userId=:id AND type='account-setup'", {
|
return userDB.sequelize.query("DELETE FROM authentications WHERE userId=:id AND type='account-setup'", {
|
||||||
replacements: {
|
replacements: {
|
||||||
id: user.id
|
id: user.id
|
||||||
}
|
}
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise<string>(function(resolve, reject) {
|
||||||
crypto.randomBytes(16, function(error, buffer) {
|
crypto.randomBytes(16, function(error, buffer) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return reject(error);
|
return reject(error);
|
||||||
@ -66,7 +66,7 @@ const sendVerifyMail = function(userDB, req, user) {
|
|||||||
return resolve(buffer.toString('hex'));
|
return resolve(buffer.toString('hex'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}).then(function(secret) {
|
}).then(function(secret: string) {
|
||||||
return userDB.sequelize.query(
|
return userDB.sequelize.query(
|
||||||
"INSERT INTO authentications " +
|
"INSERT INTO authentications " +
|
||||||
"(userId,issued,key,type) " +
|
"(userId,issued,key,type) " +
|
||||||
@ -77,11 +77,11 @@ const sendVerifyMail = function(userDB, req, user) {
|
|||||||
}
|
}
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
return secret;
|
return secret;
|
||||||
}).catch(function(error) {
|
}).catch(function(error: any) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
}).then(function(secret) {
|
}).then(function(secret: string) {
|
||||||
const transporter = req.app.get("transporter");
|
const transporter = req.app.get("transporter");
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
console.log("Not sending VERIFY email; SMTP not configured.");
|
console.log("Not sending VERIFY email; SMTP not configured.");
|
||||||
@ -102,15 +102,16 @@ const sendVerifyMail = function(userDB, req, user) {
|
|||||||
text: hb.compile(templates.verify.text)(data),
|
text: hb.compile(templates.verify.text)(data),
|
||||||
html: hb.compile(templates.verify.html)(data)
|
html: hb.compile(templates.verify.html)(data)
|
||||||
};
|
};
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise<void>(function (resolve, reject) {
|
||||||
let attempts = 10;
|
let attempts = 10;
|
||||||
|
|
||||||
function send(envelope) {
|
function send(envelope: any) {
|
||||||
/* Rate limit to ten per second */
|
/* Rate limit to ten per second */
|
||||||
transporter.sendMail(envelope, function (error, info) {
|
transporter.sendMail(envelope, function (error: any, info: any) {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
console.log('Message sent: ' + info.response);
|
console.log('Message sent: ' + (info && info.response));
|
||||||
return resolve();
|
resolve();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempts == 0) {
|
if (attempts == 0) {
|
||||||
@ -126,12 +127,12 @@ const sendVerifyMail = function(userDB, req, user) {
|
|||||||
|
|
||||||
send(envelope);
|
send(envelope);
|
||||||
});
|
});
|
||||||
}).catch(function(error) {
|
}).catch(function(error: any) {
|
||||||
console.log("Error creating account: ", error);
|
console.log("Error creating account: ", error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendPasswordChangedMail = function(userDB, req, user) {
|
const sendPasswordChangedMail = function(_userDB: any, req: any, user: any): any {
|
||||||
const transporter = req.app.get("transporter");
|
const transporter = req.app.get("transporter");
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
console.log("Not sending VERIFY email; SMTP not configured.");
|
console.log("Not sending VERIFY email; SMTP not configured.");
|
||||||
@ -151,14 +152,14 @@ const sendPasswordChangedMail = function(userDB, req, user) {
|
|||||||
text: hb.compile(templates.password.text)(data),
|
text: hb.compile(templates.password.text)(data),
|
||||||
html: hb.compile(templates.password.html)(data)
|
html: hb.compile(templates.password.html)(data)
|
||||||
};
|
};
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise<void>(function (resolve, reject) {
|
||||||
let attempts = 10;
|
let attempts = 10;
|
||||||
|
|
||||||
function send(envelope) {
|
function send(envelope: any) {
|
||||||
/* Rate limit to ten per second */
|
/* Rate limit to ten per second */
|
||||||
transporter.sendMail(envelope, function (error, info) {
|
transporter.sendMail(envelope, function (error: any, info: any) {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
console.log('Message sent: ' + info.response);
|
console.log('Message sent: ' + (info && info.response));
|
||||||
return resolve();
|
return resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,7 +178,7 @@ const sendPasswordChangedMail = function(userDB, req, user) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
sendVerifyMail,
|
sendVerifyMail,
|
||||||
sendPasswordChangedMail
|
sendPasswordChangedMail
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const createTransport = require('nodemailer').createTransport,
|
import { createTransport } from 'nodemailer';
|
||||||
{ timestamp } = require("./timestamp");
|
import { timestamp } from "./timestamp";
|
||||||
|
|
||||||
const transporter = createTransport({
|
const transporter = createTransport({
|
||||||
host: 'email.ketrenos.com',
|
host: 'email.ketrenos.com',
|
||||||
@ -9,8 +9,8 @@ const transporter = createTransport({
|
|||||||
port: 25
|
port: 25
|
||||||
});
|
});
|
||||||
|
|
||||||
function sendMail(to, subject, message, cc) {
|
function sendMail(to: string, subject: string, message: string, cc?: string): Promise<boolean> {
|
||||||
let envelope = {
|
let envelope: any = {
|
||||||
subject: subject,
|
subject: subject,
|
||||||
from: 'Ketr.Ketran <james_ketran@ketrenos.com>',
|
from: 'Ketr.Ketran <james_ketran@ketrenos.com>',
|
||||||
to: to || '',
|
to: to || '',
|
||||||
@ -29,33 +29,29 @@ function sendMail(to, subject, message, cc) {
|
|||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
let attempts = 10;
|
let attempts = 10;
|
||||||
|
|
||||||
function attemptSend(envelope) {
|
function attemptSend(envelope: any) {
|
||||||
/* Rate limit to ten per second */
|
/* Rate limit to ten per second */
|
||||||
transporter.sendMail(envelope, function (error, info) {
|
transporter.sendMail(envelope, function (error, _info) {
|
||||||
if (error) {
|
if (error) {
|
||||||
if (attempts) {
|
if (attempts) {
|
||||||
attempts--;
|
attempts--;
|
||||||
console.warn(timestamp() + " Unable to send mail. Trying again in 100ms (" + attempts + " attempts remain): ", error);
|
console.warn(timestamp() + " Unable to send mail. Trying again in 100ms (" + attempts + " attempts remain): ", error);
|
||||||
setTimeout(send.bind(undefined, envelope), 100);
|
setTimeout(() => attemptSend(envelope), 100);
|
||||||
} else {
|
} else {
|
||||||
console.error(timestamp() + " Error sending email: ", error)
|
console.error(timestamp() + " Error sending email: ", error)
|
||||||
return reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
|
||||||
console.log(timestamp() + " Mail sent to: " + envelope.to);
|
console.log(timestamp() + " Mail sent to: " + envelope.to);
|
||||||
return resolve(true);
|
resolve(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptSend(envelope);
|
attemptSend(envelope);
|
||||||
}).then(function(success) {
|
|
||||||
if (!success) {
|
|
||||||
console.error(timestamp() + " Mail not sent to: " + envelope.to);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
sendMail: sendMail
|
sendMail
|
||||||
};
|
};
|
@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "peddlers-of-ketran-server",
|
"name": "peddlers-of-ketran-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "app.js",
|
"main": "dist/src/app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "export $(cat ../.env | xargs) && node dist/app.js",
|
"start": "export $(cat ../.env | xargs) && node dist/src/app.js",
|
||||||
"start:legacy": "export $(cat ../.env | xargs) && node app.js",
|
"start:legacy": "export $(cat ../.env | xargs) && node app.js",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start:dev": "ts-node-dev --respawn --transpile-only src/app.ts",
|
"start:dev": "ts-node-dev --respawn --transpile-only src/app.ts",
|
||||||
|
"list-games": "ts-node-dev --transpile-only tools/list-games.ts",
|
||||||
|
"import-games": "ts-node-dev --transpile-only tools/import-games-to-db.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
@ -36,9 +38,25 @@
|
|||||||
"ws": "^8.5.0"
|
"ws": "^8.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bluebird": "^3.5.38",
|
||||||
|
"@types/config": "^3.3.1",
|
||||||
|
"@types/connect-sqlite3": "^0.9.3",
|
||||||
|
"@types/cookie-parser": "^1.4.4",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/express-session": "^1.17.7",
|
||||||
|
"@types/express-ws": "^3.0.1",
|
||||||
|
"@types/handlebars": "^4.1.0",
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^29.5.0",
|
||||||
|
"@types/moment": "^2.13.0",
|
||||||
|
"@types/morgan": "^1.9.5",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/node-fetch": "^2.6.4",
|
||||||
|
"@types/node-gzip": "^1.1.0",
|
||||||
|
"@types/nodemailer": "^6.4.8",
|
||||||
|
"@types/random-words": "^1.1.0",
|
||||||
|
"@types/sequelize": "^4.28.15",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
|
"@types/ws": "^8.5.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^6.3.3",
|
"supertest": "^6.3.3",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
"use strict";
|
import express from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
const express = require("express"),
|
import url from 'url';
|
||||||
fs = require("fs"),
|
|
||||||
url = require("url");
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -10,9 +8,9 @@ const router = express.Router();
|
|||||||
* to replace BASEPATH */
|
* to replace BASEPATH */
|
||||||
router.get("/*", (req, res, next) => {
|
router.get("/*", (req, res, next) => {
|
||||||
const parts = url.parse(req.url),
|
const parts = url.parse(req.url),
|
||||||
basePath = req.app.get("basePath");
|
basePath = req.app.get("basePath") as string;
|
||||||
|
|
||||||
if (!/^\/[^/]+\.html$/.exec(parts.pathname)) {
|
if (!/^\/[^/]+\.html$/.exec(parts.pathname || '')) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,14 +18,13 @@ router.get("/*", (req, res, next) => {
|
|||||||
|
|
||||||
/* Replace <script>'<base href="/BASEPATH/">';</script> in index.html with
|
/* Replace <script>'<base href="/BASEPATH/">';</script> in index.html with
|
||||||
* the basePath */
|
* the basePath */
|
||||||
fs.readFile("frontend" + parts.pathname, "utf8", function(error, content) {
|
fs.readFile("frontend" + (parts.pathname || ''), "utf8", function(error, content) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
res.send(content.replace(
|
res.send((content as string).replace(
|
||||||
/<script>'<base href="BASEPATH">';<\/script>/,
|
/<script>'<base href="BASEPATH">';<\/script>/,
|
||||||
"<base href='" + basePath + "'>"));
|
"<base href='" + basePath + "'>"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
export default router;
|
||||||
module.exports = router;
|
|
@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const express = require("express");
|
import express from "express";
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -9,7 +9,7 @@ const router = express.Router();
|
|||||||
* by the server. It is mounted under the application's basePath so you can
|
* by the server. It is mounted under the application's basePath so you can
|
||||||
* hit: /<basePath>/__debug/request
|
* hit: /<basePath>/__debug/request
|
||||||
*/
|
*/
|
||||||
router.get('/__debug/request', (req, res) => {
|
router.get('/__debug/request', (req: express.Request, res: express.Response) => {
|
||||||
try {
|
try {
|
||||||
console.log('[debug] __debug/request hit:', req.method, req.originalUrl);
|
console.log('[debug] __debug/request hit:', req.method, req.originalUrl);
|
||||||
// Echo back a compact JSON summary so curl or browsers can inspect it.
|
// Echo back a compact JSON summary so curl or browsers can inspect it.
|
||||||
@ -21,10 +21,10 @@ router.get('/__debug/request', (req, res) => {
|
|||||||
hostname: req.hostname,
|
hostname: req.hostname,
|
||||||
basePath: req.app && req.app.get && req.app.get('basePath')
|
basePath: req.app && req.app.get && req.app.get('basePath')
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error('[debug] error in __debug/request', e && e.stack || e);
|
console.error('[debug] error in __debug/request', e && e.stack || e);
|
||||||
res.status(500).json({ error: 'debug endpoint error' });
|
res.status(500).json({ error: 'debug endpoint error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
export default router;
|
File diff suppressed because it is too large
Load Diff
5458
server/routes/games.ts
Executable file
5458
server/routes/games.ts
Executable file
File diff suppressed because it is too large
Load Diff
20
server/routes/games/constants.ts
Normal file
20
server/routes/games/constants.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export const MAX_SETTLEMENTS = 5;
|
||||||
|
export const MAX_CITIES = 4;
|
||||||
|
export const MAX_ROADS = 15;
|
||||||
|
|
||||||
|
export const types: string[] = [ 'wheat', 'stone', 'sheep', 'wood', 'brick' ];
|
||||||
|
|
||||||
|
export const debug = {
|
||||||
|
audio: false,
|
||||||
|
get: true,
|
||||||
|
set: true,
|
||||||
|
update: false,
|
||||||
|
road: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const all = `[ all ]`;
|
||||||
|
export const info = `[ info ]`;
|
||||||
|
export const todo = `[ todo ]`;
|
||||||
|
|
||||||
|
export const SEND_THROTTLE_MS = 50;
|
||||||
|
export const INCOMING_GET_BATCH_MS = 20;
|
19
server/routes/games/serialize.ts
Normal file
19
server/routes/games/serialize.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { GameState } from './state';
|
||||||
|
|
||||||
|
export function serializeGame(game: GameState): string {
|
||||||
|
// Use a deterministic JSON serializer for snapshots; currently use JSON.stringify
|
||||||
|
return JSON.stringify(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeGame(serialized: string): GameState {
|
||||||
|
try {
|
||||||
|
return JSON.parse(serialized) as GameState;
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, return a minimal empty game state to avoid crashes
|
||||||
|
return { players: [], placements: { corners: [], roads: [] } } as GameState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneGame(game: GameState): GameState {
|
||||||
|
return deserializeGame(serializeGame(game));
|
||||||
|
}
|
34
server/routes/games/state.ts
Normal file
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;
|
||||||
|
|
184
server/routes/games/store.ts
Normal file
184
server/routes/games/store.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import type { GameState } from './state.js';
|
||||||
|
import { promises as fsp } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export interface GameDB {
|
||||||
|
sequelize?: any;
|
||||||
|
Sequelize?: any;
|
||||||
|
getGameById(id: string | number): Promise<GameState | null>;
|
||||||
|
saveGameState(id: string | number, state: GameState): Promise<void>;
|
||||||
|
deleteGame?(id: string | number): Promise<void>;
|
||||||
|
[k: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin game DB initializer / accessor.
|
||||||
|
* This currently returns the underlying db module (for runtime compatibility)
|
||||||
|
* and is the single place to add typed helper methods for game persistence.
|
||||||
|
*/
|
||||||
|
export async function initGameDB(): Promise<GameDB> {
|
||||||
|
// dynamic import to preserve original runtime ordering
|
||||||
|
// path is relative to this file (routes/games)
|
||||||
|
// Prefer synchronous require at runtime when available to avoid TS module resolution
|
||||||
|
// issues during type-checking. Declare require to keep TypeScript happy.
|
||||||
|
let mod: any;
|
||||||
|
try {
|
||||||
|
// Use runtime require to load the DB module. This runs under Node (ts-node)
|
||||||
|
// so a direct require is appropriate and avoids relying on globalThis.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
mod = require('../../db/games');
|
||||||
|
} catch (e) {
|
||||||
|
// DB-only mode: fail fast so callers know persistence is required.
|
||||||
|
throw new Error('Game DB module could not be loaded: ' + String(e));
|
||||||
|
}
|
||||||
|
// If the module uses default export, prefer it
|
||||||
|
let db: any = (mod && (mod.default || mod));
|
||||||
|
// If the required module returned a Promise (the db initializer may), await it.
|
||||||
|
if (db && typeof db.then === 'function') {
|
||||||
|
try {
|
||||||
|
db = await db;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Game DB initializer promise rejected: ' + String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attach typed helper placeholders (will be implemented incrementally)
|
||||||
|
if (!db.getGameById) {
|
||||||
|
db.getGameById = async (id: string | number): Promise<GameState | null> => {
|
||||||
|
// fallback: try to query by id using raw SQL if sequelize is available
|
||||||
|
if (db && db.sequelize) {
|
||||||
|
try {
|
||||||
|
const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
|
||||||
|
replacements: { id },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
if (rows && rows.length) {
|
||||||
|
const r = rows[0] as any;
|
||||||
|
// state may be stored as text or JSON
|
||||||
|
if (typeof r.state === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(r.state) as GameState;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.saveGameState) {
|
||||||
|
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
|
||||||
|
// Always persist a JSON file so game state is inspectable/editable.
|
||||||
|
try {
|
||||||
|
const gamesDir = path.resolve(__dirname, '../../../db/games');
|
||||||
|
await fsp.mkdir(gamesDir, { recursive: true });
|
||||||
|
// Write extensionless filename to match existing files
|
||||||
|
const filePath = path.join(gamesDir, String(id));
|
||||||
|
const tmpPath = `${filePath}.tmp`;
|
||||||
|
await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
|
||||||
|
await fsp.rename(tmpPath, filePath);
|
||||||
|
} catch (err) {
|
||||||
|
// Log but continue to attempt DB persistence
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to write game JSON file for', id, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now attempt DB persistence if sequelize is present.
|
||||||
|
if (db && db.sequelize) {
|
||||||
|
const payload = JSON.stringify(state);
|
||||||
|
// Try an UPDATE; if it errors due to missing column, try to add the
|
||||||
|
// column and retry. If update affects no rows, try INSERT.
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
133
server/routes/games/types.ts
Normal file
133
server/routes/games/types.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
export type ResourceKey = "wood" | "brick" | "sheep" | "wheat" | "stone";
|
||||||
|
|
||||||
|
export type ResourceMap = Partial<Record<ResourceKey, number>> & { [k: string]: any };
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
order: number;
|
||||||
|
orderRoll?: number;
|
||||||
|
position?: string;
|
||||||
|
orderStatus?: string;
|
||||||
|
tied?: boolean;
|
||||||
|
roads?: number;
|
||||||
|
settlements?: number;
|
||||||
|
cities?: number;
|
||||||
|
longestRoad?: number;
|
||||||
|
mustDiscard?: number;
|
||||||
|
sheep?: number;
|
||||||
|
wheat?: number;
|
||||||
|
stone?: number;
|
||||||
|
brick?: number;
|
||||||
|
wood?: number;
|
||||||
|
points?: number;
|
||||||
|
resources?: number;
|
||||||
|
lastActive?: number;
|
||||||
|
live?: boolean;
|
||||||
|
status?: string;
|
||||||
|
developmentCards?: number;
|
||||||
|
development?: DevelopmentCard[];
|
||||||
|
[key: string]: any; // allow incremental fields until fully typed
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CornerPlacement {
|
||||||
|
color?: string;
|
||||||
|
type?: "settlement" | "city";
|
||||||
|
walking?: boolean;
|
||||||
|
longestRoad?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadPlacement {
|
||||||
|
color?: string;
|
||||||
|
walking?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Placements {
|
||||||
|
corners: CornerPlacement[];
|
||||||
|
roads: RoadPlacement[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Turn {
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
actions?: string[];
|
||||||
|
limits?: any;
|
||||||
|
roll?: number;
|
||||||
|
volcano?: number | null | undefined;
|
||||||
|
free?: boolean;
|
||||||
|
freeRoads?: number;
|
||||||
|
select?: Record<string, number>;
|
||||||
|
active?: string;
|
||||||
|
robberInAction?: boolean;
|
||||||
|
placedRobber?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevelopmentCard {
|
||||||
|
card?: number | string;
|
||||||
|
type?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
userId?: number;
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
ws?: any; // WebSocket instance; keep as any to avoid dependency on ws types
|
||||||
|
player?: Player;
|
||||||
|
live?: boolean;
|
||||||
|
lastActive?: number;
|
||||||
|
keepAlive?: any;
|
||||||
|
_initialSnapshotSent?: boolean;
|
||||||
|
_getBatch?: { fields: Set<string>; timer?: any };
|
||||||
|
_pendingMessage?: any;
|
||||||
|
_pendingTimeout?: any;
|
||||||
|
resources?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferItem {
|
||||||
|
type: string; // 'bank' or resource key or other
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Offer {
|
||||||
|
gets: OfferItem[];
|
||||||
|
gives: OfferItem[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Game {
|
||||||
|
id: string;
|
||||||
|
developmentCards: DevelopmentCard[];
|
||||||
|
players: Record<string, Player>;
|
||||||
|
sessions: Record<string, Session>;
|
||||||
|
unselected?: any[];
|
||||||
|
active?: number;
|
||||||
|
rules?: any;
|
||||||
|
step?: number;
|
||||||
|
placements: Placements;
|
||||||
|
turn: Turn;
|
||||||
|
pipOrder?: number[];
|
||||||
|
tileOrder?: number[];
|
||||||
|
borderOrder?: number[];
|
||||||
|
tiles?: any[];
|
||||||
|
pips?: any[];
|
||||||
|
dice?: number[];
|
||||||
|
chat?: any[];
|
||||||
|
activities?: any[];
|
||||||
|
playerOrder?: string[];
|
||||||
|
state?: string;
|
||||||
|
robber?: number;
|
||||||
|
robberName?: string;
|
||||||
|
turns?: number;
|
||||||
|
longestRoad?: string | false;
|
||||||
|
longestRoadLength?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomingMessage = { type: string | null; data: any };
|
30
server/routes/games/utils.ts
Normal file
30
server/routes/games/utils.ts
Normal file
@ -0,0 +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[] {
|
||||||
|
let currentIndex = array.length, temporaryValue: T | undefined, randomIndex: number;
|
||||||
|
while (0 !== currentIndex) {
|
||||||
|
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||||
|
currentIndex -= 1;
|
||||||
|
// use non-null assertions because we're swapping indices that exist
|
||||||
|
temporaryValue = array[currentIndex] as T;
|
||||||
|
array[currentIndex] = array[randomIndex] as T;
|
||||||
|
array[randomIndex] = temporaryValue as T;
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
@ -1,10 +1,10 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const express = require("express"),
|
import express from "express";
|
||||||
fs = require("fs"),
|
import fs from "fs";
|
||||||
url = require("url"),
|
import url from "url";
|
||||||
config = require("config"),
|
import config from "config";
|
||||||
basePath = require("../basepath");
|
import basePath from "../basepath";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -31,24 +31,24 @@ const extensionMatch = new RegExp("^.*?(" + extensions.join("|") + ")$", "i");
|
|||||||
* If so, 404 because the asset isn't there. otherwise assume it is a
|
* If so, 404 because the asset isn't there. otherwise assume it is a
|
||||||
* dynamic client side route and *then* return index.html.
|
* dynamic client side route and *then* return index.html.
|
||||||
*/
|
*/
|
||||||
router.get("/*", function(req, res, next) {
|
router.get("/*", function(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
const parts = url.parse(req.url);
|
const parts = url.parse(req.url);
|
||||||
|
|
||||||
/* If req.user isn't set yet (authentication hasn't happened) then
|
/* If req.user isn't set yet (authentication hasn't happened) then
|
||||||
* only allow / to be loaded--everything else chains to the next
|
* only allow / to be loaded--everything else chains to the next
|
||||||
* handler */
|
* handler */
|
||||||
if (!req.user &&
|
if (!(req as any).user &&
|
||||||
req.url != "/" &&
|
req.url != "/" &&
|
||||||
req.url.indexOf("/games") != 0) {
|
req.url.indexOf("/games") != 0) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.url == "/" || req.url.indexOf("/games") == 0 || !extensionMatch.exec(parts.pathname)) {
|
if (req.url == "/" || req.url.indexOf("/games") == 0 || !extensionMatch.exec(parts.pathname || '')) {
|
||||||
console.log("Returning index for " + req.url);
|
console.log("Returning index for " + req.url);
|
||||||
|
|
||||||
/* Replace <script>'<base href="BASEPATH">';</script> in index.html with
|
/* Replace <script>'<base href="BASEPATH">';</script> in index.html with
|
||||||
* the basePath */
|
* the basePath */
|
||||||
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
|
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
|
||||||
index = fs.readFileSync(frontendPath + "index.html", "utf8");
|
index = fs.readFileSync(frontendPath + "index.html", "utf8");
|
||||||
res.send(index.replace(
|
res.send(index.replace(
|
||||||
/<script>'<base href="BASEPATH">';<\/script>/,
|
/<script>'<base href="BASEPATH">';<\/script>/,
|
||||||
@ -63,4 +63,4 @@ router.get("/*", function(req, res, next) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
export default router;
|
@ -1,19 +1,19 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const express = require("express"),
|
import express from "express";
|
||||||
config = require("config"),
|
import config from "config";
|
||||||
{ sendVerifyMail, sendPasswordChangedMail } = require("../lib/mail"),
|
import { sendVerifyMail, sendPasswordChangedMail } from "../lib/mail";
|
||||||
crypto = require("crypto");
|
import crypto from "crypto";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
let userDB;
|
let userDB: any;
|
||||||
|
|
||||||
require("../db/users.js").then(function(db) {
|
import("../db/users.js").then(function(db: any) {
|
||||||
userDB = db;
|
userDB = db;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/", function(req, res/*, next*/) {
|
router.get("/", function(req: express.Request, res: express.Response/*, next*/) {
|
||||||
console.log("/users/");
|
console.log("/users/");
|
||||||
return getSessionUser(req).then((user) => {
|
return getSessionUser(req).then((user) => {
|
||||||
return res.status(200).send(user);
|
return res.status(200).send(user);
|
||||||
@ -23,12 +23,12 @@ router.get("/", function(req, res/*, next*/) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/password", function(req, res) {
|
router.put("/password", function(req: express.Request, res: express.Response) {
|
||||||
console.log("/users/password");
|
console.log("/users/password");
|
||||||
|
const q = req.query as any;
|
||||||
const changes = {
|
const changes = {
|
||||||
currentPassword: req.query.c || req.body.c,
|
currentPassword: q.c || req.body.c,
|
||||||
newPassword: req.query.n || req.body.n
|
newPassword: q.n || req.body.n
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!changes.currentPassword || !changes.newPassword) {
|
if (!changes.currentPassword || !changes.newPassword) {
|
||||||
@ -39,7 +39,7 @@ router.put("/password", function(req, res) {
|
|||||||
return res.status(400).send("Attempt to set new password to current password.");
|
return res.status(400).send("Attempt to set new password to current password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSessionUser(req).then(function(user) {
|
return getSessionUser(req).then(function(user: any) {
|
||||||
return userDB.sequelize.query("SELECT id FROM users " +
|
return userDB.sequelize.query("SELECT id FROM users " +
|
||||||
"WHERE uid=:username AND password=:password", {
|
"WHERE uid=:username AND password=:password", {
|
||||||
replacements: {
|
replacements: {
|
||||||
@ -48,13 +48,13 @@ router.put("/password", function(req, res) {
|
|||||||
},
|
},
|
||||||
type: userDB.Sequelize.QueryTypes.SELECT,
|
type: userDB.Sequelize.QueryTypes.SELECT,
|
||||||
raw: true
|
raw: true
|
||||||
}).then(function(users) {
|
}).then(function(users: any) {
|
||||||
if (users.length != 1) {
|
if (users.length != 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
}).then(function(user) {
|
}).then(function(user: any) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log("Invalid password");
|
console.log("Invalid password");
|
||||||
/* Invalid password */
|
/* Invalid password */
|
||||||
@ -77,15 +77,15 @@ router.put("/password", function(req, res) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/create", function(req, res) {
|
router.post("/create", function(req: express.Request, res: express.Response) {
|
||||||
console.log("/users/create");
|
console.log("/users/create");
|
||||||
|
const q = req.query as any;
|
||||||
const user = {
|
const user = {
|
||||||
uid: req.query.m || req.body.m,
|
uid: q.m || req.body.m,
|
||||||
displayName: req.query.n || req.body.n || "",
|
displayName: q.n || req.body.n || "",
|
||||||
password: req.query.p || req.body.p || "",
|
password: q.p || req.body.p || "",
|
||||||
mail: req.query.m || req.body.m,
|
mail: q.m || req.body.m,
|
||||||
notes: req.query.w || req.body.w || ""
|
notes: q.w || req.body.w || ""
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user.uid || !user.password || !user.displayName || !user.notes) {
|
if (!user.uid || !user.password || !user.displayName || !user.notes) {
|
||||||
@ -98,37 +98,38 @@ router.post("/create", function(req, res) {
|
|||||||
replacements: user,
|
replacements: user,
|
||||||
type: userDB.Sequelize.QueryTypes.SELECT,
|
type: userDB.Sequelize.QueryTypes.SELECT,
|
||||||
raw: true
|
raw: true
|
||||||
}).then(function(results) {
|
}).then(function(results: any) {
|
||||||
if (results.length != 0) {
|
if (results.length != 0) {
|
||||||
return res.status(400).send("Email address already used.");
|
return res.status(400).send("Email address already used.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
let re = /^(([^<>()\[\]\\.,;:\s@\"]+(\.[^<>()\[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||||
if (!re.exec(user.mail)) {
|
if (!re.exec(user.mail)) {
|
||||||
console.log("Invalid email address: " + user.mail);
|
console.log("Invalid email address: " + user.mail);
|
||||||
throw "Invalid email address.";
|
throw "Invalid email address.";
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
return userDB.sequelize.query("INSERT INTO users " +
|
return userDB.sequelize.query("INSERT INTO users " +
|
||||||
"(uid,displayName,password,mail,memberSince,authenticated,notes) " +
|
"(uid,displayName,password,mail,memberSince,authenticated,notes) " +
|
||||||
"VALUES(:uid,:displayName,:password,:mail,CURRENT_TIMESTAMP,0,:notes)", {
|
"VALUES(:uid,:displayName,:password,:mail,CURRENT_TIMESTAMP,0,:notes)", {
|
||||||
replacements: user
|
replacements: user
|
||||||
}).spread(function(results, metadata) {
|
}).spread(function(_results: any, metadata: any) {
|
||||||
req.session.userId = metadata.lastID;
|
req.session.userId = metadata.lastID;
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
return getSessionUser(req).then(function(user) {
|
return getSessionUser(req).then(function(user: any) {
|
||||||
res.status(200).send(user);
|
res.status(200).send(user);
|
||||||
user.id = req.session.userId;
|
user.id = req.session.userId;
|
||||||
return sendVerifyMail(userDB, req, user);
|
return sendVerifyMail(userDB, req, user);
|
||||||
});
|
});
|
||||||
}).catch(function(error) {
|
}).catch(function(error: any) {
|
||||||
console.log("Error creating account: ", error);
|
console.log("Error creating account: ", error);
|
||||||
return res.status(401).send(error);
|
return res.status(401).send(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSessionUser = function(req) {
|
const getSessionUser = function(req: express.Request): Promise<any> {
|
||||||
return Promise.resolve().then(function() {
|
return Promise.resolve().then(function() {
|
||||||
if (!req.session || !req.session.userId) {
|
if (!req.session || !req.session.userId) {
|
||||||
throw "Unauthorized. You must be logged in.";
|
throw "Unauthorized. You must be logged in.";
|
||||||
@ -143,7 +144,7 @@ const getSessionUser = function(req) {
|
|||||||
},
|
},
|
||||||
type: userDB.Sequelize.QueryTypes.SELECT,
|
type: userDB.Sequelize.QueryTypes.SELECT,
|
||||||
raw: true
|
raw: true
|
||||||
}).then(function(results) {
|
}).then(function(results: any) {
|
||||||
if (results.length != 1) {
|
if (results.length != 1) {
|
||||||
throw "Invalid account.";
|
throw "Invalid account.";
|
||||||
}
|
}
|
||||||
@ -162,7 +163,7 @@ const getSessionUser = function(req) {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
}).then(function(user) {
|
}).then(function(user: any) {
|
||||||
req.user = user;
|
req.user = user;
|
||||||
|
|
||||||
/* If the user already has a restriction, or there are no album user restrictions,
|
/* If the user already has a restriction, or there are no album user restrictions,
|
||||||
@ -171,7 +172,7 @@ const getSessionUser = function(req) {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowed = config.get("restrictions");
|
let allowed: any = config.get("restrictions");
|
||||||
if (!Array.isArray(allowed)) {
|
if (!Array.isArray(allowed)) {
|
||||||
allowed = [ allowed ];
|
allowed = [ allowed ];
|
||||||
}
|
}
|
||||||
@ -187,8 +188,8 @@ const getSessionUser = function(req) {
|
|||||||
}).then(function(user) {
|
}).then(function(user) {
|
||||||
/* If there are maintainers on this album, check if this user is a maintainer */
|
/* If there are maintainers on this album, check if this user is a maintainer */
|
||||||
if (config.has("maintainers")) {
|
if (config.has("maintainers")) {
|
||||||
let maintainers = config.get("maintainers");
|
let maintainers: any = config.get("maintainers");
|
||||||
if (maintainers.indexOf(user.username) != -1) {
|
if (Array.isArray(maintainers) && maintainers.indexOf(user.username) != -1) {
|
||||||
user.maintainer = true;
|
user.maintainer = true;
|
||||||
if (user.restriction) {
|
if (user.restriction) {
|
||||||
console.warn("User " + user.username + " is a maintainer AND has a restriction which will be ignored: " + user.restriction);
|
console.warn("User " + user.username + " is a maintainer AND has a restriction which will be ignored: " + user.restriction);
|
||||||
@ -212,11 +213,12 @@ const getSessionUser = function(req) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post("/login", function(req, res) {
|
router.post("/login", function(req: express.Request, res: express.Response) {
|
||||||
console.log("/users/login");
|
console.log("/users/login");
|
||||||
|
const q = req.query as any;
|
||||||
|
|
||||||
let username = req.query.u || req.body.u || "",
|
let username = q.u || req.body.u || "",
|
||||||
password = req.query.p || req.body.p || "";
|
password = q.p || req.body.p || "";
|
||||||
|
|
||||||
console.log("Login attempt");
|
console.log("Login attempt");
|
||||||
|
|
||||||
@ -224,7 +226,7 @@ router.post("/login", function(req, res) {
|
|||||||
return res.status(400).send("Missing username and/or password");
|
return res.status(400).send("Missing username and/or password");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((reject, resolve) => {
|
return new Promise((resolve, _reject) => {
|
||||||
console.log("Looking up user in DB.");
|
console.log("Looking up user in DB.");
|
||||||
let query = "SELECT " +
|
let query = "SELECT " +
|
||||||
"id,mailVerified,authenticated,uid AS username,displayName AS name,mail " +
|
"id,mailVerified,authenticated,uid AS username,displayName AS name,mail " +
|
||||||
@ -235,7 +237,7 @@ router.post("/login", function(req, res) {
|
|||||||
password: crypto.createHash('sha256').update(password).digest('base64')
|
password: crypto.createHash('sha256').update(password).digest('base64')
|
||||||
},
|
},
|
||||||
type: userDB.Sequelize.QueryTypes.SELECT
|
type: userDB.Sequelize.QueryTypes.SELECT
|
||||||
}).then(function(users) {
|
}).then(function(users: any) {
|
||||||
if (users.length != 1) {
|
if (users.length != 1) {
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
@ -243,7 +245,7 @@ router.post("/login", function(req, res) {
|
|||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
return resolve(user);
|
return resolve(user);
|
||||||
});
|
});
|
||||||
}).then(function(user) {
|
}).then(function(user: any) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log(username + " not found (or invalid password.)");
|
console.log(username + " not found (or invalid password.)");
|
||||||
req.session.userId = null;
|
req.session.userId = null;
|
||||||
@ -259,7 +261,7 @@ router.post("/login", function(req, res) {
|
|||||||
console.log(message);
|
console.log(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSessionUser(req).then(function(user) {
|
return getSessionUser(req).then(function(user: any) {
|
||||||
return res.status(200).send(user);
|
return res.status(200).send(user);
|
||||||
});
|
});
|
||||||
}).catch(function(error) {
|
}).catch(function(error) {
|
||||||
@ -277,7 +279,7 @@ router.get("/logout", function(req, res) {
|
|||||||
res.status(200).send({});
|
res.status(200).send({});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
router,
|
router,
|
||||||
getSessionUser
|
getSessionUser
|
||||||
};
|
};
|
@ -1,42 +1,45 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import express from 'express';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import config from 'config';
|
||||||
|
import basePath from '../basepath';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import http from 'http';
|
||||||
|
import expressWs from 'express-ws';
|
||||||
|
|
||||||
process.env.TZ = "Etc/GMT";
|
process.env.TZ = "Etc/GMT";
|
||||||
|
|
||||||
console.log("Loading ketr.ketran");
|
console.log("Loading ketr.ketran");
|
||||||
|
|
||||||
const express = require("express");
|
|
||||||
const bodyParser = require("body-parser");
|
|
||||||
const config = require("config");
|
|
||||||
const session = require('express-session');
|
|
||||||
const basePath = require("../basepath");
|
|
||||||
const cookieParser = require("cookie-parser");
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = require("http").createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
const ws = require('express-ws')(app, server);
|
expressWs(app, server);
|
||||||
|
|
||||||
require("../console-line.js"); /* Monkey-patch console.log with line numbers */
|
require("../console-line"); /* Monkey-patch console.log with line numbers */
|
||||||
|
|
||||||
// Temporary debug routes (dev-only). Mount before static so we can
|
// Temporary debug routes (dev-only). Mount before static so we can
|
||||||
// inspect what the server receives for base-prefixed requests.
|
// inspect what the server receives for base-prefixed requests.
|
||||||
try {
|
try {
|
||||||
app.use(basePath, require("../routes/debug.js"));
|
// import the debug router using ESM style; fallback to require at runtime if needed
|
||||||
} catch (e) {
|
// (some dev environments may still emit JS commonjs files)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const debugRouter = require("../routes/debug").default || require("../routes/debug");
|
||||||
|
app.use(basePath, debugRouter);
|
||||||
|
} catch (e: any) {
|
||||||
console.error('Failed to mount debug routes (src):', e && e.stack || e);
|
console.error('Failed to mount debug routes (src):', e && e.stack || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
|
const frontendPath = (config.get("frontendPath") as string).replace(/\/$/, "") + "/",
|
||||||
serverConfig = config.get("server");
|
serverConfig = config.get("server") as { port: number };
|
||||||
|
|
||||||
console.log("Hosting server from: " + basePath);
|
console.log("Hosting server from: " + basePath);
|
||||||
|
|
||||||
let userDB: any, gameDB: any;
|
// DB handles are initialized by the modules below; we don't need file-scoped vars here.
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req: Request, _res: Response, next: NextFunction) => {
|
||||||
console.log(`${req.method} ${req.url}`);
|
console.log(`${req.method} ${req.url}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@ -48,19 +51,27 @@ app.use(bodyParser.json());
|
|||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
app.set("basePath", basePath);
|
app.set("basePath", basePath);
|
||||||
app.use(basePath, require("../routes/basepath.js"));
|
// basepath is a simple exported string
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const basepathRouter = require("../routes/basepath").default || require("../routes/basepath");
|
||||||
|
app.use(basePath, basepathRouter);
|
||||||
|
|
||||||
/* Handle static files first so excessive logging doesn't occur */
|
/* Handle static files first so excessive logging doesn't occur */
|
||||||
app.use(basePath, express.static(frontendPath, { index: false }));
|
app.use(basePath, express.static(frontendPath, { index: false }));
|
||||||
|
|
||||||
const index = require("../routes/index");
|
// index route (may be ESM default export)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const index = require("../routes/index").default || require("../routes/index");
|
||||||
|
|
||||||
if (config.has("admin")) {
|
if (config.has("admin")) {
|
||||||
const admin = config.get("admin");
|
const admin = config.get("admin");
|
||||||
app.set("admin", admin);
|
app.set("admin", admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(`${basePath}api/v1/games`, require("../routes/games"));
|
// games router
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const gamesRouter = require("../routes/games").default || require("../routes/games");
|
||||||
|
app.use(`${basePath}api/v1/games`, gamesRouter);
|
||||||
|
|
||||||
/* Allow loading of the app w/out being logged in */
|
/* Allow loading of the app w/out being logged in */
|
||||||
app.use(basePath, index);
|
app.use(basePath, index);
|
||||||
@ -82,11 +93,16 @@ process.on('SIGINT', () => {
|
|||||||
server.close(() => process.exit(1));
|
server.close(() => process.exit(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
require("../db/games").then(function(db: any) {
|
// database initializers
|
||||||
gameDB = db;
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
import { initGameDB } from '../routes/games/store';
|
||||||
|
|
||||||
|
initGameDB().then(function(_db: any) {
|
||||||
|
// games DB initialized via store facade
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
return require("../db/users").then(function(db: any) {
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
userDB = db;
|
return Promise.resolve((require("../db/users") as any).default || require("../db/users")).then(function(_db: any) {
|
||||||
|
// users DB initialized
|
||||||
});
|
});
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
console.log("DB connected. Opening server.");
|
console.log("DB connected. Opening server.");
|
||||||
@ -118,4 +134,4 @@ server.on("error", function(error: any) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { app, server };
|
export { app, server };
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
"use strict";
|
function twoDigit(number: number): string {
|
||||||
|
|
||||||
function twoDigit(number) {
|
|
||||||
return ("0" + number).slice(-2);
|
return ("0" + number).slice(-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function timestamp(date) {
|
function timestamp(date?: Date): string {
|
||||||
date = date || new Date();
|
date = date || new Date();
|
||||||
return [ date.getFullYear(), twoDigit(date.getMonth() + 1), twoDigit(date.getDate()) ].join("-") +
|
return [ date.getFullYear(), twoDigit(date.getMonth() + 1), twoDigit(date.getDate()) ].join("-") +
|
||||||
" " +
|
" " +
|
||||||
[ twoDigit(date.getHours()), twoDigit(date.getMinutes()), twoDigit(date.getSeconds()) ].join(":");
|
[ twoDigit(date.getHours()), twoDigit(date.getMinutes()), twoDigit(date.getSeconds()) ].join(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
timestamp
|
timestamp
|
||||||
};
|
};
|
109
server/tools/import-games-to-db.ts
Normal file
109
server/tools/import-games-to-db.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env ts-node
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { initGameDB } from '../routes/games/store';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const gamesDir = path.resolve(__dirname, '../../db/games');
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(gamesDir);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read games dir', gamesDir, e);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: any;
|
||||||
|
try {
|
||||||
|
db = await initGameDB();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize DB', e);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db || !db.sequelize) {
|
||||||
|
console.error('DB did not expose sequelize; cannot proceed.');
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
// ignore dotfiles and .bk backup files (we don't want to import backups)
|
||||||
|
if (f.startsWith('.') || f.endsWith('.bk')) continue;
|
||||||
|
const full = path.join(gamesDir, f);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(full);
|
||||||
|
if (!stat.isFile()) continue;
|
||||||
|
const raw = await fs.readFile(full, 'utf8');
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
// Derive id from filename (strip .json if present)
|
||||||
|
const idStr = f.endsWith('.json') ? f.slice(0, -5) : f;
|
||||||
|
const id = isNaN(Number(idStr)) ? idStr : Number(idStr);
|
||||||
|
|
||||||
|
// derive a friendly name from the saved state when present
|
||||||
|
const nameCandidate = (state && (state.name || state.id)) ? String(state.name || state.id) : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
// numeric filename: use the typed helper
|
||||||
|
await db.saveGameState(id, state);
|
||||||
|
console.log(`Saved game id=${id}`);
|
||||||
|
if (nameCandidate) {
|
||||||
|
try {
|
||||||
|
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id, name: nameCandidate } });
|
||||||
|
} catch (_) {
|
||||||
|
// ignore name update failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// string filename: try to find an existing row by path and save via id;
|
||||||
|
// otherwise insert a new row with path and the JSON state.
|
||||||
|
let found: any[] = [];
|
||||||
|
try {
|
||||||
|
found = await db.sequelize.query('SELECT id FROM games WHERE path=:path', {
|
||||||
|
replacements: { path: idStr },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
} catch (qe) {
|
||||||
|
found = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found && found.length) {
|
||||||
|
const foundId = found[0].id;
|
||||||
|
await db.saveGameState(foundId, state);
|
||||||
|
console.log(`Saved game path=${idStr} -> id=${foundId}`);
|
||||||
|
if (nameCandidate) {
|
||||||
|
try {
|
||||||
|
await db.sequelize.query('UPDATE games SET name=:name WHERE id=:id', { replacements: { id: foundId, name: nameCandidate } });
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ensure state column exists before inserting a new row
|
||||||
|
try {
|
||||||
|
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const payload = JSON.stringify(state);
|
||||||
|
if (nameCandidate) {
|
||||||
|
await db.sequelize.query('INSERT INTO games (path, state, name) VALUES(:path, :state, :name)', { replacements: { path: idStr, state: payload, name: nameCandidate } });
|
||||||
|
} else {
|
||||||
|
await db.sequelize.query('INSERT INTO games (path, state) VALUES(:path, :state)', { replacements: { path: idStr, state: payload } });
|
||||||
|
}
|
||||||
|
console.log(`Inserted game path=${idStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save game', idStr, e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read/parse', full, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Import complete');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
102
server/tools/list-games.ts
Normal file
102
server/tools/list-games.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env ts-node
|
||||||
|
import { initGameDB } from '../routes/games/store';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
gameId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(): Args {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const res: Args = {};
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if ((a === '-g' || a === '--game') && args[i+1]) {
|
||||||
|
res.gameId = String(args[i+1]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { gameId } = parseArgs();
|
||||||
|
|
||||||
|
let db: any;
|
||||||
|
try {
|
||||||
|
db = await initGameDB();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize game DB:', e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db || !db.sequelize) {
|
||||||
|
console.error('DB does not expose sequelize; cannot run queries.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gameId) {
|
||||||
|
// List all game ids
|
||||||
|
try {
|
||||||
|
const rows: any[] = await db.sequelize.query('SELECT id, name FROM games', { type: db.Sequelize.QueryTypes.SELECT });
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
console.log('No games found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Games:');
|
||||||
|
rows.forEach(r => console.log(`${r.id} - ${r.name}`));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to list games:', e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For a given game ID, try to print the turns history from the state
|
||||||
|
try {
|
||||||
|
const rows: any[] = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
|
||||||
|
replacements: { id: gameId },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
console.error('Game not found:', gameId);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const r = rows[0] as any;
|
||||||
|
let state = r.state;
|
||||||
|
if (typeof state === 'string') {
|
||||||
|
try {
|
||||||
|
state = JSON.parse(state);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse stored state JSON:', e);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
console.error('Empty state for game', gameId);
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Game ${gameId} summary:`);
|
||||||
|
console.log(` - turns: ${state.turns || 0}`);
|
||||||
|
if (state.turnHistory || state.turnsData || state.turns_list) {
|
||||||
|
const turns = state.turnHistory || state.turnsData || state.turns_list;
|
||||||
|
console.log('Turns:');
|
||||||
|
turns.forEach((t: any, idx: number) => {
|
||||||
|
console.log(`${idx}: ${JSON.stringify(t)}`);
|
||||||
|
});
|
||||||
|
} else if (state.turns && state.turns > 0) {
|
||||||
|
console.log('No explicit turn history found inside state; showing snapshot metadata.');
|
||||||
|
// Print limited snapshot details per turn if available
|
||||||
|
if (state.turnsData) {
|
||||||
|
state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No turn history recorded in state.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load game state for', gameId, e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
@ -1,19 +1,32 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2022",
|
||||||
"module": "CommonJS",
|
"module": "CommonJS",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": ".",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"sourceMap": true
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["**/*.ts", "**/*.js"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "test-output"]
|
||||||
}
|
}
|
||||||
|
21
server/types/db-modules.d.ts
vendored
Normal file
21
server/types/db-modules.d.ts
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
declare module '../db/games' {
|
||||||
|
const value: any;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '../db/games.js' {
|
||||||
|
const value: any;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '../db/users' {
|
||||||
|
const value: any;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '../db/users.js' {
|
||||||
|
const value: any;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
19
server/types/express-session.d.ts
vendored
Normal file
19
server/types/express-session.d.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import 'express';
|
||||||
|
import 'express-session';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
// populated by getSessionUser
|
||||||
|
user?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
userId?: number | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
@ -62,7 +62,7 @@
|
|||||||
* |
|
* |
|
||||||
* 12 | 11 10
|
* 12 | 11 10
|
||||||
*/
|
*/
|
||||||
const Tile = (corners, roads) => {
|
const Tile = (corners: number[], roads: number[]) => {
|
||||||
return {
|
return {
|
||||||
corners: corners, /* 6 */
|
corners: corners, /* 6 */
|
||||||
pip: -1,
|
pip: -1,
|
||||||
@ -74,7 +74,7 @@ const Tile = (corners, roads) => {
|
|||||||
/* Borders have three sections each, so they are numbered
|
/* Borders have three sections each, so they are numbered
|
||||||
* 0-17 clockwise. Some corners share two borders. */
|
* 0-17 clockwise. Some corners share two borders. */
|
||||||
|
|
||||||
const Corner = (roads, banks) => {
|
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 */
|
||||||
@ -82,7 +82,7 @@ const Corner = (roads, banks) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const Road = (corners) => {
|
const Road = (corners: number[]) => {
|
||||||
return {
|
return {
|
||||||
corners: corners, /* 2 */
|
corners: corners, /* 2 */
|
||||||
data: undefined
|
data: undefined
|
||||||
@ -314,7 +314,7 @@ const staticData = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
layout,
|
layout,
|
||||||
staticData
|
staticData
|
||||||
};
|
};
|
@ -1,34 +1,41 @@
|
|||||||
const { layout } = require('./layout.js');
|
import { layout } from './layout';
|
||||||
|
|
||||||
const isRuleEnabled = (game, rule) => {
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getValidRoads = (game, color) => {
|
const getValidRoads = (game: any, color: string): number[] => {
|
||||||
const limits = [];
|
const limits: number[] = [];
|
||||||
|
|
||||||
/* For each road, if the road is set, skip it.
|
/* For each road, if the road is set, skip it.
|
||||||
* If no color is set, check the two corners. If the corner
|
* If no color is set, check the two corners. If the corner
|
||||||
* has a matching color, add this to the set. Otherwise skip.
|
* has a matching color, add this to the set. Otherwise skip.
|
||||||
*/
|
*/
|
||||||
layout.roads.forEach((road, roadIndex) => {
|
layout.roads.forEach((road, roadIndex) => {
|
||||||
if (game.placements.roads[roadIndex].color) {
|
if (!game.placements || !game.placements.roads || game.placements.roads[roadIndex]?.color) {
|
||||||
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 corner = layout.corners[road.corners[c]],
|
const cornerIndex = road.corners[c] as number;
|
||||||
cornerColor = game.placements.corners[road.corners[c]].color;
|
if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const corner = (layout as any).corners[cornerIndex];
|
||||||
|
const cornerColor = (game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cornerIndex] && (game as any).placements.corners[cornerIndex].color;
|
||||||
/* Roads do not pass through other player's settlements */
|
/* Roads do not pass through other player's settlements */
|
||||||
if (cornerColor && cornerColor !== color) {
|
if (cornerColor && cornerColor !== color) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (let r = 0; !valid && r < corner.roads.length; r++) {
|
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
|
||||||
/* This side of the corner is pointing to the road being validated. Skip it. */
|
/* This side of the corner is pointing to the road being validated. Skip it. */
|
||||||
if (corner.roads[r] === roadIndex) {
|
if (!corner.roads || corner.roads[r] === roadIndex) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (game.placements.roads[corner.roads[r]].color === color) {
|
const rr = corner.roads[r];
|
||||||
|
if (rr == null) { continue; }
|
||||||
|
const placementsRoads = (game as any).placements && (game as any).placements.roads;
|
||||||
|
if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) {
|
||||||
valid = true;
|
valid = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -41,8 +48,8 @@ const getValidRoads = (game, color) => {
|
|||||||
return limits;
|
return limits;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getValidCorners = (game, color, type) => {
|
const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
||||||
const limits = [];
|
const limits: number[] = [];
|
||||||
|
|
||||||
/* For each corner, if the corner already has a color set, skip it if type
|
/* For each corner, if the corner already has a color set, skip it if type
|
||||||
* isn't set. If type is set, if it is a match, and the color is a match,
|
* isn't set. If type is set, if it is a match, and the color is a match,
|
||||||
@ -77,14 +84,20 @@ const getValidCorners = (game, color, type) => {
|
|||||||
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++) {
|
||||||
valid = game.placements.roads[corner.roads[r]].color === color;
|
const rr = corner.roads[r];
|
||||||
|
if (rr == null) { continue; }
|
||||||
|
const placementsRoads = (game as any).placements && (game as any).placements.roads;
|
||||||
|
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++) {
|
||||||
const road = layout.roads[corner.roads[r]];
|
if (!corner.roads) { break; }
|
||||||
for (let c = 0; valid && c < road.corners.length; c++) {
|
const ridx = corner.roads[r] as number;
|
||||||
|
if (ridx == null || (layout as any).roads[ridx] == null) { continue; }
|
||||||
|
const road = (layout as any).roads[ridx];
|
||||||
|
for (let c = 0; valid && c < (road.corners || []).length; c++) {
|
||||||
/* This side of the road is pointing to the corner being validated.
|
/* This side of the road is pointing to the corner being validated.
|
||||||
* Skip it. */
|
* Skip it. */
|
||||||
if (road.corners[c] === cornerIndex) {
|
if (road.corners[c] === cornerIndex) {
|
||||||
@ -92,7 +105,8 @@ const getValidCorners = (game, color, type) => {
|
|||||||
}
|
}
|
||||||
/* There is a settlement within one segment from this
|
/* There is a settlement within one segment from this
|
||||||
* corner, so it is invalid for settlement placement */
|
* corner, so it is invalid for settlement placement */
|
||||||
if (game.placements.corners[road.corners[c]].color) {
|
const cc = road.corners[c] as number;
|
||||||
|
if ((game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cc] && (game as any).placements.corners[cc].color) {
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,7 +117,7 @@ const getValidCorners = (game, color, type) => {
|
|||||||
* on the volcano) */
|
* on the volcano) */
|
||||||
if (!(game.state === 'initial-placement'
|
if (!(game.state === 'initial-placement'
|
||||||
&& isRuleEnabled(game, 'volcano')
|
&& isRuleEnabled(game, 'volcano')
|
||||||
&& layout.tiles[game.robber].corners.indexOf(cornerIndex) !== -1
|
&& (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
|
||||||
)) {
|
)) {
|
||||||
limits.push(cornerIndex);
|
limits.push(cornerIndex);
|
||||||
}
|
}
|
||||||
@ -113,7 +127,7 @@ const getValidCorners = (game, color, type) => {
|
|||||||
return limits;
|
return limits;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
getValidCorners,
|
getValidCorners,
|
||||||
getValidRoads,
|
getValidRoads,
|
||||||
isRuleEnabled
|
isRuleEnabled
|
Loading…
x
Reference in New Issue
Block a user