diff --git a/client/src/App.tsx b/client/src/App.tsx index 2165773..21c21a9 100755 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,593 +1,127 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { BrowserRouter as Router, Route, Routes, useParams, useNavigate } from "react-router-dom"; +import React, { useState, useEffect, useCallback } from "react"; +import { Paper, Typography } from "@mui/material"; -import Paper from "@mui/material/Paper"; -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 { Session } from "./GlobalContext"; 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'; -import robberAudio from './assets/robber.mp3'; -import knightsAudio from './assets/the-knights-who-say-ni.mp3'; -import volcanoAudio from './assets/volcano-eruption.mp3'; +console.log(`Peddlers of Ketran Build: ${import.meta.env.VITE_APP_POK_BUILD}`); -const audioFiles: Record = { - 'its-your-turn.mp3': itsYourTurnAudio, - 'robber.mp3': robberAudio, - 'the-knights-who-say-ni.mp3': knightsAudio, - 'volcano-eruption.mp3': volcanoAudio, -}; +interface LoadingProps { + setError: (error: string | null) => void; +} -type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean }; -const audioEffects: Record = {}; - -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 Loading = (props: LoadingProps) => { const navigate = useNavigate(); - const [gameId, setGameId] = useState(params.gameId ? (params.gameId as string) : undefined); - const [ws, setWs] = useState(undefined); /* tracks full websocket lifetime */ - const [connection, setConnection] = useState(undefined); /* set after ws is in OPEN */ - const [retryConnection, setRetryConnection] = - useState(true); /* set when connection should be re-established */ - const [name, setName] = useState(""); - const [error, setError] = useState(undefined); - const [warning, setWarning] = useState(undefined); - const [loaded, setLoaded] = useState(false); + const { setError } = props; - type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record }; - type PrivateType = { name?: string; color?: string; turnNotice?: string }; - - const [dice, setDice] = useState(undefined); - const [state, setState] = useState(undefined); - const [color, setColor] = useState(undefined); - const [priv, setPriv] = useState(undefined); - const [turn, setTurn] = useState(undefined); - const [buildActive, setBuildActive] = useState(false); - const [tradeActive, setTradeActive] = useState(false); - const [cardActive, setCardActive] = useState(undefined); - const [houseRulesActive, setHouseRulesActive] = useState(false); - const [winnerDismissed, setWinnerDismissed] = useState(false); - const [global, setGlobal] = useState>({}); - const [count, setCount] = useState(0); - const [audio, setAudio] = useState( - localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false - ); - const [animations, setAnimations] = useState( - localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false - ); - const [volume, setVolume] = useState( - 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 data = JSON.parse(event.data as string); - 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 ("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); + useEffect(() => { + 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); } - 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(() => { - if (gameId) { - return; - } - - window - .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]); + createRoom(); + }, [setError]); return ( - - {/* */} -
-
- - {dice && dice.length && ( -
- {dice.length === 1 &&
Volcano roll!
} - {dice.length === 2 &&
Current roll
} -
- - {dice.length === 2 && } -
-
- )} -
-
-
- {error && ( -
- -
{error}
- -
-
- )} - - {priv && priv.turnNotice && ( -
- -
{priv.turnNotice}
- -
-
- )} - {warning && ( -
- -
{warning}
- -
-
- )} - {state === "normal" && } - {color && state === "game-order" && } - - {!winnerDismissed && } - {houseRulesActive && } - - -
- - - - - -
-
- {name !== "" && volume !== undefined && ( - -
Audio effects
{" "} - { - const value = !audio; - localStorage.setItem("audio", JSON.stringify(value)); - setAudio(value); - }} - /> -
Sound effects volume
{" "} - { - const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100; - - localStorage.setItem("volume", alpha.toString()); - setVolume(alpha); - }} - /> -
Animations
{" "} - { - const value = !animations; - localStorage.setItem("animations", JSON.stringify(value)); - setAnimations(value); - }} - /> -
- )} - {name !== "" && } - {/* 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 ; - })()} - {name !== "" && } - {/* name !== "" && */} - {loaded && ( - - )} -
-
-
+ + Loading... + ); }; -const App: React.FC = () => { - const [playerId, setPlayerId] = useState(undefined); - const [error, setError] = useState(undefined); +const App = () => { + const [session, setSession] = useState(null); + const [error, setError] = useState(null); + const [sessionRetryAttempt, setSessionRetryAttempt] = useState(0); useEffect(() => { - if (playerId) { + if (error) { + setTimeout(() => setError(null), 5000); + } + }, [error]); + + useEffect(() => { + if (!session) { 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]); + console.log(`App - sessionId`, session.id); + }, [session]); - if (!playerId) { - return <>{error}; - } + 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(() => { + getSession(); // Retry + }, 5000); + } + }, []); + + useEffect(() => { + if (session) { + return; + } + getSession(); + }, [session, getSession]); return ( - - - } path="/:gameId" /> - } path="/" /> - - + + {!session && ( + 0 ? ReadyState.CLOSED : ReadyState.CONNECTING} + reconnectAttempt={sessionRetryAttempt} + /> + )} + {session && ( + + + } path={`${base}/:roomName`} /> + } path={`${base}`} /> + + + )} + {error && ( + + {error} + + )} + ); }; diff --git a/client/src/Common.ts b/client/src/Common.ts index afa8130..620e6f2 100644 --- a/client/src/Common.ts +++ b/client/src/Common.ts @@ -1,86 +1,57 @@ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -function debounce void>(fn: T, ms: number): T { - let timer: any = null; - return function(...args: Parameters) { - 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 // accidentally include literal quotes when setting env vars (for example, // `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 // an accidental quoted-empty value becomes an empty string. -const candidateEnvVars = [ - import.meta.env.VITE_API_BASE, - // 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 = ''; +const candidateEnvVars = [import.meta.env.VITE_API_BASE, import.meta.env.VITE_BASEPATH, import.meta.env.PUBLIC_URL]; +let rawEnvApiBase = ""; for (const candidate of candidateEnvVars) { - if (typeof candidate === 'string' && candidate.trim() !== '') { + if (typeof candidate === "string" && candidate.trim() !== "") { rawEnvApiBase = candidate; 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 (envApiBase === '""' || envApiBase === "''") { - envApiBase = ''; + envApiBase = ""; } // Remove surrounding single or double quotes if present. -if ((envApiBase.startsWith('"') && envApiBase.endsWith('"')) || - (envApiBase.startsWith("'") && envApiBase.endsWith("'"))) { +if ( + (envApiBase.startsWith('"') && envApiBase.endsWith('"')) || + (envApiBase.startsWith("'") && envApiBase.endsWith("'")) +) { 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 // constructing `${base}/api/...` never produces a protocol-relative // URL like `//api/...` which the browser resolves to `https://api/...`. -let baseCandidate = envApiBase || publicBase || ''; -if (baseCandidate === '/') { - baseCandidate = ''; +let baseCandidate = envApiBase || publicBase || ""; +if (baseCandidate === "/") { + baseCandidate = ""; } // Remove trailing slash if present (but keep leading slash for path bases). -if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) { - baseCandidate = baseCandidate.replace(/\/+$/, ''); +if (baseCandidate.length > 1 && baseCandidate.endsWith("/")) { + baseCandidate = baseCandidate.replace(/\/+$/, ""); } // 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 // VITE_BASEPATH is `/ketr.ketran`), React Router's // 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 -// the client still renders correctly in local/dev setups. +// basename. In that situation throw an error! try { - if (typeof window !== 'undefined' && baseCandidate) { - 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 + '/'))) { - // Mismatch: fallback to empty base so router can match the URL. - // 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 = ''; + if (typeof window !== "undefined" && baseCandidate) { + const pathname = window.location && window.location.pathname ? window.location.pathname : ""; + if (!(pathname === baseCandidate || pathname.startsWith(baseCandidate + "/"))) { + // Mismatch: FAIL! + throw Error(`Configured base '${baseCandidate}' does not match current pathname '${pathname}'`); } } } catch (e) { @@ -97,4 +68,6 @@ const base = baseCandidate; const assetsPath = base; const gamesPath = `${base}`; -export { base, debounce, assetsPath, gamesPath }; \ No newline at end of file +const ws_base: string = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${base}/ws`; + +export { base, ws_base, assetsPath, gamesPath }; diff --git a/client/src/ConnectionStatus.tsx b/client/src/ConnectionStatus.tsx new file mode 100644 index 0000000..be25083 --- /dev/null +++ b/client/src/ConnectionStatus.tsx @@ -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 = ({ 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 ( + + + {shouldShowProgress && } + + {getConnectionStatusText()} + + + + {readyState === ReadyState.CLOSED && reconnectAttempt > 0 && countdown > 0 && ( + + + + )} + + ); +}; + +export { ConnectionStatus }; diff --git a/client/src/GlobalContext.ts b/client/src/GlobalContext.ts index 29ecc4c..79c27cb 100644 --- a/client/src/GlobalContext.ts +++ b/client/src/GlobalContext.ts @@ -1,20 +1,135 @@ -import { createContext } from 'react'; +import { createContext } from "react"; export type GlobalContextType = { - gameId?: string | undefined; - ws?: WebSocket | null | undefined; + roomName?: string; name?: string; sendJsonMessage?: (message: any) => void; chat?: Array; }; const global: GlobalContextType = { - gameId: undefined, - ws: undefined, + roomName: undefined, name: "", - chat: [] + chat: [], }; const GlobalContext = createContext(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 & { + name: string | null; + has_media?: boolean; // Whether this session provides audio/video streams +}; + export { GlobalContext, global }; diff --git a/client/src/NameSetter.tsx b/client/src/NameSetter.tsx new file mode 100644 index 0000000..9ff0bb3 --- /dev/null +++ b/client/src/NameSetter.tsx @@ -0,0 +1,162 @@ +import React, { useState, KeyboardEvent, useRef } from "react"; +import { Input, Button, Box, Typography, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions } from "@mui/material"; +import { Session } from "./GlobalContext"; + +interface NameSetterProps { + session: Session; + sendJsonMessage: (message: any) => void; + onNameSet?: () => void; + initialName?: string; + initialPassword?: string; +} + +const NameSetter: React.FC = ({ + session, + sendJsonMessage, + onNameSet, + initialName = "", + initialPassword = "", +}) => { + const [editName, setEditName] = useState(initialName); + const [editPassword, setEditPassword] = useState(initialPassword); + const [showDialog, setShowDialog] = useState(!session.name); + const [isSubmitting, setIsSubmitting] = useState(false); + + const nameInputRef = useRef(null); + const passwordInputRef = useRef(null); + + const setName = (name: string) => { + setIsSubmitting(true); + sendJsonMessage({ + type: "set_name", + data: { name, password: editPassword ? editPassword : undefined }, + }); + if (onNameSet) { + onNameSet(); + } + setShowDialog(false); + setIsSubmitting(false); + setEditName(""); + setEditPassword(""); + }; + + const handleNameKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Enter") { + event.preventDefault(); + if (passwordInputRef.current) { + passwordInputRef.current.focus(); + } + } + }; + + const handlePasswordKeyDown = (event: KeyboardEvent): 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 ( + + {session.name && !showDialog && ( + + You are logged in as: {session.name} + + + )} + + {/* Dialog for name change */} + + + {session.name ? "Change Your Name" : "Enter Your Name"} + + + + + {session.name + ? "Enter a new name to change your current name." + : "Enter your name to join the lobby." + } + + + You can optionally set a password to reserve this name; supply it again to takeover the name from another client. + + + { + setEditName(e.target.value); + }} + onKeyDown={handleNameKeyDown} + placeholder="Your name" + fullWidth + autoFocus + /> + + setEditPassword(e.target.value)} + onKeyDown={handlePasswordKeyDown} + placeholder="Optional password" + fullWidth + /> + + + + + + + + + + + + + ); +}; + +export default NameSetter; \ No newline at end of file diff --git a/client/src/PlayerList.tsx b/client/src/PlayerList.tsx index 012f8ec..4a7ca3b 100644 --- a/client/src/PlayerList.tsx +++ b/client/src/PlayerList.tsx @@ -1,191 +1,216 @@ -import React, { useState, useEffect, useContext, useRef } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import Paper from "@mui/material/Paper"; import List from "@mui/material/List"; - import "./PlayerList.css"; -import { PlayerColor } from "./PlayerColor"; -import { MediaAgent, MediaControl, Session } from "./MediaControl"; +import { MediaControl, MediaAgent, Peer } from "./MediaControl"; +import Box from "@mui/material/Box"; +import { Session, Room } from "./GlobalContext"; +import useWebSocket from "react-use-websocket"; -import { GlobalContext } from "./GlobalContext"; +type Player = { + name: string; + session_id: string; + live: boolean; + local: boolean /* Client side variable */; + protected?: boolean; + has_media?: boolean; // Whether this Player provides audio/video streams + bot_run_id?: string; + bot_provider_id?: string; + bot_instance_id?: string; // For bot instances + muted?: boolean; + video_on?: boolean; +}; -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ +type PlayerListProps = { + socketUrl: string; + session: Session; + roomId: string; +}; -interface PlayerListProps { - socketUrl?: string; - session?: Session; -} +const PlayerList: React.FC = (props: PlayerListProps) => { + const { socketUrl, session, roomId } = props; + const [Players, setPlayers] = useState(null); + const [peers, setPeers] = useState>({}); -const PlayerList: React.FC = ({ socketUrl, session }) => { - const { ws, name, sendJsonMessage } = useContext(GlobalContext); - const [players, setPlayers] = useState<{ [key: string]: any }>({}); - const [unselected, setUneslected] = useState([]); - const [state, setState] = useState("lobby"); - const [color, setColor] = useState(undefined); - const [peers, setPeers] = useState<{ [key: string]: any }>({}); + const sortPlayers = useCallback( + (A: any, B: any) => { + if (!session) { + return 0; + } + /* active Player first */ + if (A.name === session.name) { + return -1; + } + if (B.name === session.name) { + return +1; + } + /* Sort active Players first */ + if (A.name && !B.name) { + return -1; + } + if (B.name && !A.name) { + return +1; + } + /* Otherwise, sort by color */ + if (A.color && B.color) { + return A.color.localeCompare(B.color); + } + return 0; + }, + [session] + ); - 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); + // 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 = { ...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, + }; + } + }); + return updated; + }); + break; } - - 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; + case "update_name": { + // 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, + }; } - } - if (!found) { - setColor(undefined); - } - setPlayers(data.update.players); + return updated; + }); + break; } - - if ("state" in data.update && data.update.state !== state) { - setState(data.update.state); - } - break; - default: - break; - } - }; - const refWsMessage = useRef(onWsMessage); - - useEffect(() => { - refWsMessage.current = onWsMessage; + default: + break; + } + }, }); 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) { + if (Players !== null) { return; } sendJsonMessage({ - type: "get", - fields: ["state", "players", "unselected"], + type: "list_Players", }); - }, [ws]); - - const toggleSelected = (key: string) => { - ws!.send( - JSON.stringify({ - type: "set", - field: "color", - value: color === key ? "" : key, - }) - ); - }; - - const playerElements: React.ReactElement[] = []; - - const inLobby = state === "lobby"; - const sortedPlayers: any[] = []; - - for (const key in players) { - sortedPlayers.push(players[key]); - } - - const sortPlayers = (A: any, B: any) => { - /* active player first */ - if (A.name === name) { - return -1; - } - if (B.name === name) { - return +1; - } - - /* Sort active players first */ - if (A.name && !B.name) { - return -1; - } - if (B.name && !A.name) { - return +1; - } - - /* Ohterwise, sort by color */ - return A.color.localeCompare(B.color); - }; - - sortedPlayers.sort(sortPlayers); - - /* Array of just names... */ - unselected.sort((A, B) => { - /* active player first */ - if (A === name) { - return -1; - } - if (B === name) { - return +1; - } - /* Then sort alphabetically */ - return A.localeCompare(B); - }); - - const videoClass = sortedPlayers.length <= 2 ? "Medium" : "Small"; - - sortedPlayers.forEach((player) => { - const playerName = player.name; - const selectable = inLobby && (player.status === "Not active" || color === player.color); - playerElements.push( -
{ - inLobby && selectable && toggleSelected(player.color); - }} - key={`player-${player.color}`} - > -
- -
{playerName ? playerName : "Available"}
- {playerName && !player.live &&
} -
- {playerName && player.live && ( - - )} - {!playerName &&
} -
- ); - }); - - const waiting = unselected.map((player) => { - return ( -
-
{player}
- -
- ); - }); + }, [Players, sendJsonMessage]); return ( - - {socketUrl && session && } - {playerElements} - {unselected && unselected.length !== 0 && ( -
-
In lobby
-
{waiting}
-
- )} -
+ + + + + {Players?.map((Player) => ( + + + + +
{Player.name ? Player.name : Player.session_id}
+ {Player.protected && ( +
+ 🔒 +
+ )} + {Player.bot_instance_id && ( +
+ 🤖 +
+ )} +
+
+ {Player.name && !Player.live &&
} +
+ {Player.name && Player.live && peers[Player.session_id] && (Player.local || Player.has_media !== false) ? ( + + ) : Player.name && Player.live && Player.has_media === false ? ( +
+ 💬 Chat Only +
+ ) : ( + + )} +
+ ))} +
+
+
); }; diff --git a/client/src/RoomView.tsx b/client/src/RoomView.tsx new file mode 100644 index 0000000..b376af1 --- /dev/null +++ b/client/src/RoomView.tsx @@ -0,0 +1,434 @@ +import React, { useState, useEffect } 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 } 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 "./App.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"; + +const audioFiles: Record = { + "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 = {}; + +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>; + setError: React.Dispatch>; +}; + +const RoomView: React.FC = (props: RoomProps) => { + const { session, setSession, setError } = props; + const [socketUrl, setSocketUrl] = useState(null); + const { roomName = "default" } = useParams<{ roomName: string }>(); + const [creatingRoom, setCreatingRoom] = useState(false); + const [reconnectAttempt, setReconnectAttempt] = useState(0); + + const [name, setName] = useState(""); + const [warning, setWarning] = useState(undefined); + const [loaded, setLoaded] = useState(false); + + type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record }; + type PrivateType = { name?: string; color?: string; turnNotice?: string }; + + const [dice, setDice] = useState(undefined); + const [state, setState] = useState(undefined); + const [color, setColor] = useState(undefined); + const [priv, setPriv] = useState(undefined); + const [turn, setTurn] = useState(undefined); + const [buildActive, setBuildActive] = useState(false); + const [tradeActive, setTradeActive] = useState(false); + const [cardActive, setCardActive] = useState(undefined); + const [houseRulesActive, setHouseRulesActive] = useState(false); + const [winnerDismissed, setWinnerDismissed] = useState(false); + const [global, setGlobal] = useState>({}); + const [count, setCount] = useState(0); + const [audio, setAudio] = useState( + localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false + ); + const [animations, setAnimations] = useState( + localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false + ); + const [volume, setVolume] = useState( + 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(() => { + console.log("app - WebSocket connection status: ", readyState); + }, [readyState]); + + if (global.name !== name || global.roomName !== roomName) { + setGlobal({ + name, + roomName, + sendJsonMessage, + }); + } + + 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]); + + return ( + +
+ {readyState !== ReadyState.OPEN || !session ? ( + + ) : ( + <> +
+ + {dice && dice.length && ( +
+ {dice.length === 1 &&
Volcano roll!
} + {dice.length === 2 &&
Current roll
} +
+ + {dice.length === 2 && } +
+
+ )} +
+
+
+ {priv && priv.turnNotice && ( +
+ +
{priv.turnNotice}
+ +
+
+ )} + {warning && ( +
+ +
{warning}
+ +
+
+ )} + {state === "normal" && } + {color && state === "game-order" && } + + {!winnerDismissed && } + {houseRulesActive && } + + +
+ + + + + +
+
+ {name !== "" && volume !== undefined && ( + +
Audio effects
{" "} + { + const value = !audio; + localStorage.setItem("audio", JSON.stringify(value)); + setAudio(value); + }} + /> +
Sound effects volume
{" "} + { + const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100; + + localStorage.setItem("volume", alpha.toString()); + setVolume(alpha); + }} + /> +
Animations
{" "} + { + const value = !animations; + localStorage.setItem("animations", JSON.stringify(value)); + setAnimations(value); + }} + /> +
+ )} + + {/* 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 ; + })()} + {name !== "" && } + {/* name !== "" && */} + {loaded && ( + + )} +
+ + )} +
+
+ ); +}; + +export { RoomView }; diff --git a/client/src/api-client.ts b/client/src/api-client.ts new file mode 100644 index 0000000..f0ab382 --- /dev/null +++ b/client/src/api-client.ts @@ -0,0 +1,45 @@ +import { Session, Room } from "./GlobalContext"; +import { base } from "./Common"; +const sessionApi = { + getCurrent: async (): Promise => { + 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 => { + 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 }; \ No newline at end of file diff --git a/server/routes/games.ts b/server/routes/games.ts index 4e92f8a..7dbab08 100755 --- a/server/routes/games.ts +++ b/server/routes/games.ts @@ -1,59 +1,53 @@ -// @ts-nocheck -import express from 'express'; -import crypto from 'crypto'; -import { readFile, writeFile, mkdir } from 'fs/promises'; -import fs from 'fs'; -import randomWords from 'random-words'; -import equal from 'fast-deep-equal'; -import { layout, staticData } from '../util/layout'; -import basePath from '../basepath'; +import express from "express"; +import crypto from "crypto"; +import randomWords from "random-words"; +import equal from "fast-deep-equal"; +import { layout, staticData } from "../util/layout"; +import basePath from "../basepath"; +import { + MAX_SETTLEMENTS, + MAX_CITIES, + MAX_ROADS, + types, + debug, + all, + info, + SEND_THROTTLE_MS, + INCOMING_GET_BATCH_MS, +} from "./games/constants"; -import { getValidRoads, getValidCorners, isRuleEnabled } from '../util/validLocations'; -import { Player } from './games/types'; -import { normalizeIncoming, shuffleArray } from './games/utils'; -import type { GameState } from './games/state'; -import { serializeGame, deserializeGame, cloneGame } from './games/serialize'; +import { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLocations"; +import { Player, Game, Session, CornerPlacement, RoadPlacement } from "./games/types"; +import { normalizeIncoming, shuffleArray } from "./games/utils"; +// import type { GameState } from './games/state'; // unused import removed during typing pass const router = express.Router(); -const MAX_SETTLEMENTS = 5; -const MAX_CITIES = 4; -const MAX_ROADS = 15; +// normalizeIncoming imported from './games/utils' -const types: string[] = [ 'wheat', 'stone', 'sheep', 'wood', 'brick' ]; - -const debug = { - audio: false, - get: true, - set: true, - update: false -}; - -// normalizeIncoming imported from './games/utils.ts' - -import { initGameDB } from './games/store'; -import type { GameDB } from './games/store'; +import { initGameDB } from "./games/store"; +import type { GameDB } from "./games/store"; let gameDB: GameDB | undefined; -initGameDB().then((db) => { - gameDB = db; -}).catch((e) => { - console.error('Failed to initialize game DB', e); -}); +initGameDB() + .then((db) => { + gameDB = db; + }) + .catch((e) => { + console.error("Failed to initialize game DB", e); + }); // shuffleArray imported from './games/utils.ts' +const games: Record = {}; +const audio: Record = {}; -const games = {}; -const audio = {}; - -const processTies = (players: Player[]) => { - - /* Sort the players into buckets based on their +const processTies = (players: Player[]): boolean => { + /* Sort the players into buckets based on their * order, and their current roll. If a resulting * roll array has more than one element, then there * is a tie that must be resolved */ - let slots: Player[][] = []; + let slots: any[] = []; players.forEach((player: Player) => { if (!slots[player.order]) { slots[player.order] = []; @@ -61,40 +55,49 @@ const processTies = (players: Player[]) => { slots[player.order].push(player); }); - let ties = false, position = 1; + let ties = false, + position = 1; const irstify = (position: number): string => { switch (position) { - case 1: return `1st`; - case 2: return `2nd`; - case 3: return `3rd`; - case 4: return `4th`; - default: return position.toString(); + case 1: + return `1st`; + case 2: + return `2nd`; + case 3: + return `3rd`; + case 4: + return `4th`; + default: + return position.toString(); } - } + }; /* Reverse from high to low */ - slots.reverse().forEach((slot) => { - if (slot.length !== 1) { + const rev = slots.slice().reverse(); + for (const slot of rev) { + const s = slot || []; + if (s.length !== 1) { ties = true; - slot.forEach((player: Player) => { + s.forEach((player: Player) => { player.orderRoll = 0; /* Ties have to be re-rolled */ player.position = irstify(position); player.orderStatus = `Tied for ${irstify(position)}`; player.tied = true; }); - } else { - slot[0].tied = false; - slot[0].position = irstify(position); - slot[0].orderStatus = `Placed in ${irstify(position)}.`; + } else if (s[0]) { + s[0].tied = false; + s[0].position = irstify(position); + s[0].orderStatus = `Placed in ${irstify(position)}.`; } - position += slot.length - }); + position += s.length; + } return ties; -} +}; -const processGameOrder = (game, player, dice) => { + +const processGameOrder = (game: any, player: any, dice: number): any => { if (player.orderRoll) { return `You have already rolled for game order and are not in a tie.`; } @@ -116,8 +119,8 @@ const processGameOrder = (game, player, dice) => { if (!doneRolling) { sendUpdateToPlayers(game, { players: getFilteredPlayers(game), - chat: game.chat - }) + chat: game.chat, + }); return; } @@ -127,28 +130,30 @@ const processGameOrder = (game, player, dice) => { }); console.log(`Pre process ties: `, players); - + if (processTies(players)) { console.log(`${info}: There are ties in player rolls:`, players); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), - chat: game.chat + chat: game.chat, }); return; } - addChatMessage(game, null, `Player order set to ` + - players.map((player) => `${player.position}: ${player.name}`) - .join(', ') + `.`); + addChatMessage( + game, + null, + `Player order set to ` + players.map((player) => `${player.position}: ${player.name}`).join(", ") + `.` + ); - game.playerOrder = players.map(player => player.color); - game.state = 'initial-placement'; - game.direction = 'forward'; + game.playerOrder = players.map((player) => player.color); + game.state = "initial-placement"; + game.direction = "forward"; game.turn = { name: players[0].name, - color: players[0].color + color: players[0].color, }; - setForSettlementPlacement(game, getValidCorners(game)); + setForSettlementPlacement(game, getValidCorners(game), undefined); addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); addChatMessage(game, null, `Initial settlement placement has started!`); addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`); @@ -159,118 +164,136 @@ const processGameOrder = (game, player, dice) => { direction: game.direction, turn: game.turn, chat: game.chat, - activities: game.activities + activities: game.activities, }); -} +}; -const processVolcano = (game, session, dice) => { +const processVolcano = (game: Game, session: Session, dice: number[]): any => { const player = session.player, name = session.name ? session.name : "Unnamed"; - const volcano = layout.tiles.findIndex((tile, index) => - staticData.tiles[game.tileOrder[index]].type === 'desert'); + const volcano = layout.tiles.findIndex((_tile, index) => { + const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined; + return typeof tileIndex === "number" && !!staticData.tiles && staticData.tiles[tileIndex]?.type === "desert"; + }); /* Find the volcano tile */ console.log(`${info}: Processing volcano roll!`, { dice }); addChatMessage(game, session, `${name} rolled ${dice[0]} for the Volcano!`); - + game.dice = dice; - game.state = 'normal'; - - game.turn.volcano = layout.tiles[volcano].corners[dice[0] % 6]; - const corner = game.placements.corners[game.turn.volcano]; - if (corner.color) { - const player = game.players[corner.color]; - if (corner.type === 'city') { - if (player.settlements) { - addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); - player.cities++; - player.settlements--; - corner.type = 'settlement'; - } else { - addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`); - delete corner.type; - delete corner.color; - player.cities++; - } - } else { - addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); - delete corner.type; - delete corner.color; - player.settlements++; + game.state = "normal"; + + if (volcano !== -1 && layout.tiles?.[volcano] && dice && dice[0] !== undefined) { + const corners = layout.tiles[volcano].corners; + if (corners && corners[dice[0] % 6] !== undefined) { + game.turn.volcano = corners[dice[0] % 6]; } } - + const volcanoIdx = typeof game.turn.volcano === "number" ? game.turn.volcano : undefined; + const corner = volcanoIdx !== undefined ? game.placements.corners[volcanoIdx] : undefined; + if (corner && corner.color) { + const player = game.players[corner.color]; + if (player) { + if (corner.type === "city") { + if (player.settlements && player.settlements > 0) { + addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); + player.cities = (player.cities || 0) + 1; + player.settlements = (player.settlements || 0) - 1; + corner.type = "settlement"; + } else { + addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`); + delete corner.type; + delete corner.color; + player.cities = (player.cities || 0) + 1; + } + } else { + addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); + delete corner.type; + delete corner.color; + player.settlements = (player.settlements || 0) + 1; + } + } + } + sendUpdateToPlayers(game, { turn: game.turn, state: game.state, chat: game.chat, dice: game.dice, placements: game.placements, - players: getFilteredPlayers(game) + players: getFilteredPlayers(game), }); -} +}; -const roll = (game, session, dice) => { +const roll = (game: any, session: any, dice?: number[] | undefined): any => { const player = session.player, name = session.name ? session.name : "Unnamed"; if (!dice) { - dice = [ Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6) ]; + dice = [Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6)]; } switch (game.state) { - case "lobby": /* currently not available as roll is only after color is - * set for players */ - addChatMessage(game, session, `${name} rolled ${dice[0]}.`); - sendUpdateToPlayers(game, { chat: game.chat }); - return; + case "lobby" + /* currently not available as roll is only after color is + * set for players */: + addChatMessage(game, session, `${name} rolled ${dice[0]}.`); + sendUpdateToPlayers(game, { chat: game.chat }); + return; - case "game-order": - game.startTime = Date.now(); - addChatMessage(game, session, `${name} rolled ${dice[0]}.`); - return processGameOrder(game, player, dice[0]); + case "game-order": + game.startTime = Date.now(); + addChatMessage(game, session, `${name} rolled ${dice[0]}.`); + if (typeof dice[0] !== "number") { + return `Invalid roll value.`; + } + return processGameOrder(game, player, dice[0]); - case "normal": - if (game.turn.color !== session.color) { - return `It is not your turn.`; - } - if (game.turn.roll) { - return `You already rolled this turn.`; - } - processRoll(game, session, dice); - return; + case "normal": + if (game.turn.color !== session.color) { + return `It is not your turn.`; + } + if (game.turn.roll) { + return `You already rolled this turn.`; + } + processRoll(game, session, dice); + return; - case 'volcano': - if (game.turn.color !== session.color) { - return `It is not your turn.`; - } - if (game.turn.select) { - return `You can not roll for the Volcano until all players have mined their resources.`; - } - /* Only use the first die for the Volcano roll */ - processVolcano(game, session, [ dice[0] ]); - return; - - default: - return `Invalid game state (${game.state}) in roll.`; + case "volcano": + if (game.turn.color !== session.color) { + return `It is not your turn.`; + } + if (game.turn.select) { + return `You can not roll for the Volcano until all players have mined their resources.`; + } + /* Only use the first die for the Volcano roll */ + if (typeof dice[0] !== "number") { + return `Invalid roll value.`; + } + processVolcano(game, session, [dice[0]]); + return; + + default: + return `Invalid game state (${game.state}) in roll.`; } -} +}; -const sessionFromColor = (game, color) => { +const sessionFromColor = (game: any, color: string): any | undefined => { for (let key in game.sessions) { if (game.sessions[key].color === color) { return game.sessions[key]; } } -} + return undefined; +}; -const distributeResources = (game, roll) => { +const distributeResources = (game: any, roll: number): void => { console.log(`Roll: ${roll}`); /* Find which tiles have this roll */ let tiles = []; for (let i = 0; i < game.pipOrder.length; i++) { let index = game.pipOrder[i]; - if (staticData.pips[index].roll === roll) { + if (staticData.pips?.[index] && staticData.pips[index].roll === roll) { if (game.robber === i) { tiles.push({ robber: true, index: i }); } else { @@ -279,40 +302,42 @@ const distributeResources = (game, roll) => { } } - const receives = { - "O": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, - "R": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, - "W": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, - "B": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, - "robber": { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 } + const receives: Record = { + O: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, + R: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, + W: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, + B: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, + robber: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 }, }; /* Find which corners are on each tile */ - tiles.forEach(tile => { + tiles.forEach((tile) => { let shuffle = game.tileOrder[tile.index]; const resource = game.tiles[shuffle]; - layout.tiles[tile.index].corners.forEach(cornerIndex => { - const active = game.placements.corners[cornerIndex]; + const tileLayout = layout.tiles?.[tile.index]; + tileLayout?.corners.forEach((cornerIndex: number) => { + const active = game.placements.corners?.[cornerIndex]; if (active && active.color) { - const count = active.type === 'settlement' ? 1 : 2; + const count = active.type === "settlement" ? 1 : 2; if (!tile.robber) { receives[active.color][resource.type] += count; } else { - if (isRuleEnabled(game, `robin-hood-robber`) - && game.players[active.color].points <= 2) { - addChatMessage(game, null, `Robber does not steal ${count} - ${resource.type} from ${game.players[active.color].name} ` + - `due to Robin Hood Robber house rule.`); - console.log(`robin-hood-robber`, game.players[active.color], - active.color); + if (isRuleEnabled(game, `robin-hood-robber`) && game.players[active.color].points <= 2) { + addChatMessage( + game, + null, + `Robber does not steal ${count} + ${resource.type} from ${game.players[active.color].name} ` + `due to Robin Hood Robber house rule.` + ); + console.log(`robin-hood-robber`, game.players[active.color], active.color); receives[active.color][resource.type] += count; } else { - trackTheft(game, active.color, 'robber', resource.type, count); - receives.robber[resource.type] += count; + trackTheft(game, active.color, "robber", resource.type, count); + receives["robber"][resource.type] += count; } } } - }) + }); }); const robber = []; @@ -321,145 +346,186 @@ const distributeResources = (game, roll) => { if (!entry.wood && !entry.brick && !entry.sheep && !entry.wheat && !entry.stone) { continue; } - let message = [], session; + let messageParts: string[] = [], + session; for (let type in entry) { if (entry[type] === 0) { continue; } - if (color !== 'robber') { + if (color !== "robber") { session = sessionFromColor(game, color); session.player[type] += entry[type]; session.player.resources += entry[type]; - message.push(`${entry[type]} ${type}`); + messageParts.push(`${entry[type]} ${type}`); } else { - robber.push(`${entry[type]} ${type}`); } } - + if (session) { - addChatMessage(game, session, `${session.name} receives ${message.join(', ')} for pip ${roll}.`); + addChatMessage(game, session, `${session.name} receives ${messageParts.join(", ")} for pip ${roll}.`); } } if (robber.length) { - addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robber.join(', ')}!`); + addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robber.join(", ")}!`); } -} +}; -const pickRobber = (game) => { +const pickRobber = (game: any): void => { const selection = Math.floor(Math.random() * 3); switch (selection) { - case 0: - game.robberName = 'Robert'; - break; - case 1: - game.robberName = 'Roberta'; - break; - case 2: - game.robberName = 'Velocirobber'; - break; + case 0: + game.robberName = "Robert"; + break; + case 1: + game.robberName = "Roberta"; + break; + case 2: + game.robberName = "Velocirobber"; + break; } -} +}; - -const processRoll = (game, session, dice) => { +const processRoll = (game: Game, session: Session, dice: number[]): any => { if (!dice[1]) { console.error(`Invalid roll sequence!`); return; - } - - addChatMessage(game, session, `${session.name} rolled ` + - `${dice[0]}, ${dice[1]}.`); + } - const sum = dice[0] + dice[1]; + addChatMessage(game, session, `${session.name} rolled ` + `${dice[0]}, ${dice[1]}.`); + + const sum = dice && dice[0] !== undefined && dice[1] !== undefined ? dice[0] + dice[1] : 0; game.dice = dice; game.turn.roll = sum; if (game.turn.roll !== 7) { - let synonym = isRuleEnabled(game, 'twelve-and-two-are-synonyms') - && (sum === 2 || sum === 12); - + let synonym = isRuleEnabled(game, "twelve-and-two-are-synonyms") && (sum === 2 || sum === 12); + distributeResources(game, game.turn.roll); - if (isRuleEnabled(game, 'twelve-and-two-are-synonyms')) { - if (dice[0] + dice[1] === 12) { - addChatMessage(game, session, `House rule 'Twelve and Two are - Synonyms' activated. Twelve was rolled, so two is triggered too!`); + if (isRuleEnabled(game, "twelve-and-two-are-synonyms")) { + if (sum === 12) { + addChatMessage( + game, + session, + `House rule 'Twelve and Two are + Synonyms' activated. Twelve was rolled, so two is triggered too!` + ); distributeResources(game, 2); } - if (dice[0] + dice[1] === 2) { - addChatMessage(game, session, `House rule 'Twelve and Two are - Synonyms' activated. Two was rolled, so twelve is triggered too!`); + if (sum === 2) { + addChatMessage( + game, + session, + `House rule 'Twelve and Two are + Synonyms' activated. Two was rolled, so twelve is triggered too!` + ); distributeResources(game, 12); } } - if (isRuleEnabled(game, 'roll-double-roll-again')) { + if (isRuleEnabled(game, "roll-double-roll-again")) { if (dice[0] === dice[1]) { - addChatMessage(game, session, `House rule 'Roll Double, Roll - Again' activated.`); + addChatMessage( + game, + session, + `House rule 'Roll Double, Roll + Again' activated.` + ); game.turn.roll = 0; } } - if (isRuleEnabled(game, 'volcano')) { - if (sum === parseInt(game.rules['volcano'].number) - || (synonym - && (game.rules['volcano'].number === 2 - || game.rules['volcano'].number === 12))) { - addChatMessage(game, session, `House rule 'Volcano' activated. The - Volcano is erupting!`); - - game.state = 'volcano'; + if (isRuleEnabled(game, "volcano")) { + if ( + sum === parseInt(game.rules["volcano"].number) || + (synonym && (game.rules["volcano"].number === 2 || game.rules["volcano"].number === 12)) + ) { + addChatMessage( + game, + session, + `House rule 'Volcano' activated. The + Volcano is erupting!` + ); + + game.state = "volcano"; let count = 0; - - if (game.rules['volcano'].gold) { + + if (game.rules["volcano"].gold) { game.turn.select = {}; - const volcano = layout.tiles.find((tile, index) => - staticData.tiles[game.tileOrder[index]].type === 'desert'); - volcano.corners.forEach(index => { - const corner = game.placements.corners[index]; - if (corner.color) { - if (!(corner.color in game.turn.select)) { - game.turn.select[corner.color] = 0; - } - game.turn.select[corner.color] += - corner.type === 'settlement' ? 1 : 2; - count += corner.type === 'settlement' ? 1 : 2; - } + const volcanoIdx = layout.tiles.findIndex((_tile, index) => { + const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined; + return typeof tileIndex === "number" && !!staticData.tiles && staticData.tiles[tileIndex]?.type === "desert"; }); - console.log(`Volcano! - `, { - mode: 'gold', - selected: game.turn.select + if (volcanoIdx !== -1 && layout.tiles[volcanoIdx]) { + const vCorners = layout.tiles[volcanoIdx].corners || []; + vCorners.forEach((index: number) => { + const corner = game.placements.corners[index]; + if (corner && corner.color) { + if (!game.turn.select) { + game.turn.select = {} as Record; + } + if (!game.turn.select) { + game.turn.select = {} as Record; + } + if (!(corner.color in game.turn.select)) { + game.turn.select[corner.color] = 0; + } + game.turn.select[corner.color] = + (game.turn.select[corner.color] || 0) + (corner.type === "settlement" ? 1 : 2); + count += corner.type === "settlement" ? 1 : 2; + } + }); + } + console.log(`Volcano! - `, { + mode: "gold", + selected: game.turn.select, }); if (count) { /* To gain volcano resources, you need at least 3 settlements, * so Robin Hood Robber does not apply */ - if (volcano === layout.tiles[game.robber]) { - addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson blocked ${count} volcanic mineral resources!`); - addChatMessage(game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!`); - delete game.turn.select; + if (volcanoIdx === game.robber) { + addChatMessage( + game, + null, + `That pesky ${game.robberName} Robber Roberson blocked ${count} volcanic mineral resources!` + ); + addChatMessage( + game, + null, + `${game.turn.name} must roll the die to determine which direction the lava will flow!` + ); + if (game.turn.select) delete game.turn.select; } else { - addChatMessage(game, null, `House rule 'Volcanoes have minerals' activated. Players must select which resources to receive from the Volcano!`); - game.turn.actions = ['select-resources']; - game.turn.active = 'volcano'; + addChatMessage( + game, + null, + `House rule 'Volcanoes have minerals' activated. Players must select which resources to receive from the Volcano!` + ); + game.turn.actions = ["select-resources"]; + game.turn.active = "volcano"; } } else { - addChatMessage(game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!`); + addChatMessage( + game, + null, + `${game.turn.name} must roll the die to determine which direction the lava will flow!` + ); delete game.turn.select; } } } } - + for (let id in game.sessions) { - if (game.sessions[id].player) { - sendUpdateToPlayer(game, game.sessions[id], { - private: game.sessions[id].player + const _sess = game.sessions[id]; + if (_sess && _sess.player) { + sendUpdateToPlayer(game, _sess, { + private: _sess.player, }); } } @@ -469,7 +535,7 @@ const processRoll = (game, session, dice) => { players: getFilteredPlayers(game), chat: game.chat, dice: game.dice, - state: game.state + state: game.state, }); return; } @@ -481,9 +547,10 @@ const processRoll = (game, session, dice) => { const mustDiscard = []; for (let id in game.sessions) { - const player = game.sessions[id].player; + const player = game.sessions[id]?.player; if (player) { - let discard = player.stone + player.wheat + player.brick + player.wood + player.sheep; + let discard = + (player.stone || 0) + (player.wheat || 0) + (player.brick || 0) + (player.wood || 0) + (player.sheep || 0); if (discard > 7) { discard = Math.floor(discard / 2); player.mustDiscard = discard; @@ -493,12 +560,12 @@ const processRoll = (game, session, dice) => { } } } - + if (mustDiscard.length === 0) { addChatMessage(game, null, `ROBBER! ${game.robberName} Robber Roberson has fled, and no one had to discard!`); addChatMessage(game, null, `A new robber has arrived and must be placed by ${game.turn.name}.`); - game.turn.actions = [ 'place-robber' ]; - game.turn.limits = { pips: [] }; + game.turn.actions = ["place-robber"]; + game.turn.limits = { pips: [] }; for (let i = 0; i < 19; i++) { if (i === game.robber) { continue; @@ -506,12 +573,17 @@ const processRoll = (game, session, dice) => { game.turn.limits.pips.push(i); } } else { - mustDiscard.forEach(player => { - addChatMessage(game, null, `The robber was rolled and ${player.name} must discard ${player.mustDiscard} resource cards!`); + mustDiscard.forEach((player) => { + addChatMessage( + game, + null, + `The robber was rolled and ${player.name} must discard ${player.mustDiscard} resource cards!` + ); for (let key in game.sessions) { - if (game.sessions[key].player === player) { - sendUpdateToPlayer(game, game.sessions[key], { - private: player + const _sess = game.sessions[key]; + if (_sess && _sess.player === player) { + sendUpdateToPlayer(game, _sess, { + private: player, }); break; } @@ -523,17 +595,17 @@ const processRoll = (game, session, dice) => { turn: game.turn, players: getFilteredPlayers(game), chat: game.chat, - dice: game.dice + dice: game.dice, }); -} +}; -const newPlayer = (color) => { +const newPlayer = (color: string) => { return { - roads: MAX_ROADS, - cities: MAX_CITIES, - settlements: MAX_SETTLEMENTS, - points: 0, - status: "Not active", + roads: MAX_ROADS, + cities: MAX_CITIES, + settlements: MAX_SETTLEMENTS, + points: 0, + status: "Not active", lastActive: 0, resources: 0, order: 0, @@ -549,11 +621,11 @@ const newPlayer = (color) => { totalTime: 0, turnStart: 0, ports: 0, - developmentCards: 0 + developmentCards: 0, }; -} +}; -const getSession = (game, id) => { +const getSession = (game: Game, id: string) => { if (!game.sessions) { game.sessions = {}; } @@ -562,15 +634,14 @@ const getSession = (game, id) => { if (!(id in game.sessions)) { game.sessions[id] = { id: `[${id.substring(0, 8)}]`, - name: '', - color: '', - player: undefined, + name: "", + color: "", lastActive: Date.now(), - live: true - }; + live: true, + } as unknown as Session; } - const session = game.sessions[id]; + const session = game.sessions[id]!; session.lastActive = Date.now(); session.live = true; if (session.player) { @@ -581,6 +652,9 @@ const getSession = (game, id) => { /* Expire old unused sessions */ for (let _id in game.sessions) { const _session = game.sessions[_id]; + if (!_session) { + continue; + } if (_session.color || _session.name || _session.player) { continue; } @@ -588,12 +662,12 @@ const getSession = (game, id) => { continue; } /* 60 minutes */ - const age = Date.now() - _session.lastActive; + const age = Date.now() - (_session.lastActive || 0); if (age > 60 * 60 * 1000) { - console.log(`${_session.id}: Expiring old session ${_id}: ${age/(60 * 1000)} minutes`); + console.log(`${_session.id}: Expiring old session ${_id}: ${age / (60 * 1000)} minutes`); delete game.sessions[_id]; if (_id in game.sessions) { - console.log('delete DID NOT WORK!'); + console.log("delete DID NOT WORK!"); } } } @@ -601,7 +675,7 @@ const getSession = (game, id) => { return game.sessions[id]; }; -const loadGame = async (id) => { +const loadGame = async (id: string) => { if (/^\.|\//.exec(id)) { return undefined; } @@ -630,12 +704,12 @@ const loadGame = async (id) => { try { gameDB = await initGameDB(); } catch (e) { - throw new Error('Game DB is not available; persistence is required in DB-only mode'); + throw new Error("Game DB is not available; persistence is required in DB-only mode"); } } if (!gameDB.getGameById) { - throw new Error('Game DB does not expose getGameById; persistence is required'); + throw new Error("Game DB does not expose getGameById; persistence is required"); } let game: any = null; @@ -660,7 +734,7 @@ const loadGame = async (id) => { * from the information in the saved game sessions */ for (let color in game.players) { delete game.players[color].name; - game.players[color].status = 'Not active'; + game.players[color].status = "Not active"; } /* Reconnect session player colors to the player objects */ @@ -670,20 +744,20 @@ const loadGame = async (id) => { if (session.name && session.color && session.color in game.players) { session.player = game.players[session.color]; session.player.name = session.name; - session.player.status = 'Active'; + session.player.status = "Active"; session.player.live = false; } else { - session.color = ''; + session.color = ""; session.player = undefined; } - + session.live = false; // Ensure we treat initial snapshot as unsent on (re)load so new socket // attachments will get a fresh 'initial-game' message. if (session._initialSnapshotSent) { delete session._initialSnapshotSent; } - + /* Populate the 'unselected' list from the session table */ if (!game.sessions[id].color && game.sessions[id].name) { game.unselected.push(game.sessions[id]); @@ -694,300 +768,330 @@ const loadGame = async (id) => { return game; }; -const clearPlayer = (player) => { +const clearPlayer = (player: any) => { const color = player.color; for (let key in player) { delete player[key]; } Object.assign(player, newPlayer(color)); -} +}; -const canGiveBuilding = (game) => { +const canGiveBuilding = (game: any): string | void => { if (!game.turn.roll) { return `Admin cannot give a building until the dice have been rolled.`; - } - if (game.turn.actions && game.turn.actions.length !== 0) { - return `Admin cannot give a building while other actions in play: ${game.turn.actions.join(', ')}.` } -} - -const adminCommands = (game, action, value, query) => { - let color, player, parts, session, corners, error; - - switch (action) { - case 'rules': - const rule = value.replace(/=.*$/, ''); - if (rule === 'list') { - const rules = {}; - for (let key in supportedRules) { - if (game.rules[key]) { - rules[key] = game.rules[key]; - } else { - rules[key] = { enabled: false }; - } - } - return JSON.stringify(rules, null, 2); - } - let values = value.replace(/^.*=/, '').split(','); - const rules = {}; - rules[rule] = {}; - values.forEach(keypair => { - let [ key, value ] = keypair.split(':'); - if (value === 'true') { - value = true; - } else if (value === 'false') { - value = false; - } else if (parseInt(value) === value) { - value = parseInt(value); - } - rules[rule][key] = value; - }); - console.log(`admin - setRules -`, rules); - setRules(game, undefined, rules); - break; - - case "debug": - if (parseInt(value) === 0 || value === 'false') { - delete game.debug; - } else { - game.debug = true; - } - break; - - case "give": - parts = value.match(/^([^-]+)(-(.*))?$/); - if (!parts) { - return `Unable to parse give request.`; - } - const type = parts[1], card = parts[3] || 1; - - for (let id in game.sessions) { - if (game.sessions[id].name === game.turn.name) { - session = game.sessions[id]; - } - } - - if (!session) { - return `Unable to determine current player turn to give resources.`; - } - - let done = true; - switch (type) { - case 'road': - error = canGiveBuilding(game); - if (error) { - return error; - } - - if (session.player.roads === 0) { - return `Player ${game.turn.name} does not have any more roads to give.`; - } - let roads = getValidRoads(game, session.color); - if (roads.length === 0) { - return `There are no valid locations for ${game.turn.name} to place a road.`; - } - game.turn.free = true; - setForRoadPlacement(game, roads); - addChatMessage(game, null, `Admin gave a road to ${game.turn.name}.` + - `They must now place the road.`); - break; - case 'city': - error = canGiveBuilding(game); - if (error) { - return error; - } - - if (session.player.cities === 0) { - return `Player ${game.turn.name} does not have any more cities to give.`; - } - corners = getValidCorners(game, session.color, 'settlement'); - if (corners.length === 0) { - return `There are no valid locations for ${game.turn.name} to place a settlement.`; - } - game.turn.free = true; - setForCityPlacement(game, corners); - addChatMessage(game, null, `Admin gave a city to ${game.turn.name}. ` + - `They must now place the city.`); - break; - case 'settlement': - error = canGiveBuilding(game); - if (error) { - return error; - } - - if (session.player.settlements === 0) { - return `Player ${game.turn.name} does not have any more settlements to give.`; - } - corners = getValidCorners(game, session.color); - if (corners.length === 0) { - return `There are no valid locations for ${game.turn.name} to place a settlement.`; - } - game.turn.free = true; - setForSettlementPlacement(game, corners); - addChatMessage(game, null, `Admin gave a settlment to ${game.turn.name}. ` + - `They must now place the settlement.`); - break; - case 'wheat': - case 'sheep': - case 'wood': - case 'stone': - case 'brick': - const count = parseInt(card); - session.player[type] += count; - session.resources += count; - addChatMessage(game, null, `Admin gave ${count} ${type} to ${game.turn.name}.`); - break; - default: - done = false; - break; - } - if (done) { - break; - } - - const index = game.developmentCards.findIndex(item => - item.card.toString() === card && item.type === type); - - if (index === -1) { - console.log({ card, type}, game.developmentCards); - return `Unable to find ${type}-${card} in the current deck of development cards.`; - } - - let tmp = game.developmentCards.splice(index, 1)[0]; - tmp.turn = game.turns ? game.turns - 1 : 0; - session.player.development.push(tmp); - addChatMessage(game, null, `Admin gave a ${card}-${type} to ${game.turn.name}.`); - break; - - case "cards": - let results = game.developmentCards.map(card => `${card.type}-${card.card}`) - .join(', '); - return results; - - case "roll": - let dice = (query.dice || Math.ceil(Math.random() * 6)).split(','); - dice = dice.map(die => parseInt(die)); - - console.log({ dice }); - if (!value) { - return `Unable to parse roll request.`; - } - - switch (value) { - case 'orange': color = 'O'; break; - case 'red': color = 'R'; break; - case 'blue': color = 'B'; break; - case 'white': color = 'W'; break; - } - if (!color) { - return `Unable to find player ${value}` - } - const rollingPlayer = (color) => { - for (let id in game.sessions) { - if ((color - && game.sessions[id].player - && game.sessions[id].player.color === color) - || (game.sessions[id].name === game.turn.name)) { - return game.sessions[id]; - } - } - return undefined; - }; - - addChatMessage(game, null, - `Admin rolling ${dice.join(', ')} for ${value}.`); - if (game.state === 'game-order') { - session = rollingPlayer(color); - } else { - session = rollingPlayer(); - } - if (!session) { - return `Unable to determine current player turn for admin roll.`; - } - let warning = roll(game, session, dice); - if (warning) { - sendWarning(session, warning); - } - break; - - case "pass": - let name = game.turn.name; - const next = getNextPlayerSession(game, name); - game.turn = { - name: next.player, - color: next.color - }; - game.turns++; - startTurnTimer(game, next); - addChatMessage(game, null, `The admin skipped ${name}'s turn.`); - addChatMessage(game, null, `It is ${next.name}'s turn.`); - break; - - case "kick": - switch (value) { - case 'orange': color = 'O'; break; - case 'red': color = 'R'; break; - case 'blue': color = 'B'; break; - case 'white': color = 'W'; break; - } - if (!color) { - return `Unable to find player ${value}` - } - - player = game.players[color]; - for (let id in game.sessions) { - const session = game.sessions[id]; - if (session.player !== player) { - continue; - } - console.log(`Kicking ${value} from ${game.id}.`); - const preamble = session.name ? `${session.name}, playing as ${colorToWord(color)},` : colorToWord(color); - addChatMessage(game, null, `${preamble} was kicked from game by the Admin.`); - if (player) { - clearPlayer(player); - session.player = undefined; - } - session.color = ""; - return; - } - return `Unable to find active session for ${colorToWord(color)} (${value})`; - case "state": - if (game.state !== 'lobby') { - return `Game already started.`; - } - if (game.active < 2) { - return `Not enough players in game to start.`; - } - game.state = 'game-order'; - /* Delete any non-played colors from the player map; reduces all - * code that would otherwise have to filter out players by checking - * the 'Not active' state of player.status */ - for (let key in game.players) { - if (game.players[key].status !== 'Active') { - delete game.players[key]; - } - } - addChatMessage(game, null, `Admin requested to start the game.`); - break; - - default: - return `Invalid admin action ${action}.`; + if (game.turn.actions && game.turn.actions.length !== 0) { + return `Admin cannot give a building while other actions in play: ${game.turn.actions.join(", ")}.`; } }; -const setPlayerName = (game, session, name) => { +const adminCommands = (game: any, action: string, value: string, query: any): any => { + let color: string | undefined, parts: RegExpMatchArray | null, session: any, corners: any, corner: any, error: any; + + switch (action) { + case "rules": + const rule = value.replace(/=.*$/, ""); + if (rule === "list") { + const rules: any = {}; + for (let key in supportedRules) { + if (game.rules[key]) { + rules[key] = game.rules[key]; + } else { + rules[key] = { enabled: false }; + } + } + return JSON.stringify(rules, null, 2); + } + let values = value.replace(/^.*=/, "").split(","); + const rulesObj: Record = {}; + rulesObj[rule] = {}; + values.forEach((keypair) => { + let [key, val] = keypair.split(":"); + let parsed: any = val; + if (val === "true") { + parsed = true; + } else if (val === "false") { + parsed = false; + } else if (typeof val === "string" && !isNaN(parseInt(val))) { + parsed = parseInt(val); + } + if (rule && key) rulesObj[rule][key] = parsed; + }); + console.log(`admin - setRules -`, rulesObj); + setRules(game, undefined, rulesObj); + break; + + case "debug": + if (parseInt(value) === 0 || value === "false") { + delete game.debug; + } else { + game.debug = true; + } + break; + + case "give": + parts = value.match(/^([^-]+)(-(.*))?$/); + if (!parts) { + return `Unable to parse give request.`; + } + const type = parts[1], + card = parts[3] || 1; + + for (let id in game.sessions) { + if (game.sessions[id].name === game.turn.name) { + session = game.sessions[id]; + } + } + + if (!session) { + return `Unable to determine current player turn to give resources.`; + } + + let done = true; + switch (type) { + case "road": + error = canGiveBuilding(game); + if (error) { + return error; + } + + if (session.player.roads === 0) { + return `Player ${game.turn.name} does not have any more roads to give.`; + } + let roads = getValidRoads(game, session.color); + if (roads.length === 0) { + return `There are no valid locations for ${game.turn.name} to place a road.`; + } + game.turn.free = true; + setForRoadPlacement(game, roads); + addChatMessage(game, null, `Admin gave a road to ${game.turn.name}.` + `They must now place the road.`); + break; + case "city": + error = canGiveBuilding(game); + if (error) { + return error; + } + + if (session.player.cities === 0) { + return `Player ${game.turn.name} does not have any more cities to give.`; + } + corners = getValidCorners(game, session.color, "settlement"); + if (corners.length === 0) { + return `There are no valid locations for ${game.turn.name} to place a settlement.`; + } + game.turn.free = true; + setForCityPlacement(game, corners); + addChatMessage(game, null, `Admin gave a city to ${game.turn.name}. ` + `They must now place the city.`); + break; + case "settlement": + error = canGiveBuilding(game); + if (error) { + return error; + } + + if (session.player.settlements === 0) { + return `Player ${game.turn.name} does not have any more settlements to give.`; + } + corners = getValidCorners(game, session.color); + if (corners.length === 0) { + return `There are no valid locations for ${game.turn.name} to place a settlement.`; + } + game.turn.free = true; + setForSettlementPlacement(game, corners, undefined); + addChatMessage( + game, + null, + `Admin gave a settlment to ${game.turn.name}. ` + `They must now place the settlement.` + ); + break; + case "wheat": + case "sheep": + case "wood": + case "stone": + case "brick": + const count = parseInt(String(card)); + session.player[type] += count; + session.resources += count; + addChatMessage(game, null, `Admin gave ${count} ${type} to ${game.turn.name}.`); + break; + default: + done = false; + break; + } + if (done) { + break; + } + + const index = game.developmentCards.findIndex((item: any) => item.card.toString() === card && item.type === type); + + if (index === -1) { + console.log({ card, type }, game.developmentCards); + return `Unable to find ${type}-${card} in the current deck of development cards.`; + } + + let tmp = game.developmentCards.splice(index, 1)[0]; + tmp.turn = game.turns ? game.turns - 1 : 0; + session.player.development.push(tmp); + addChatMessage(game, null, `Admin gave a ${card}-${type} to ${game.turn.name}.`); + break; + + case "cards": + let results = game.developmentCards.map((card: any) => `${card.type}-${card.card}`).join(", "); + return results; + + case "roll": + let diceRaw = (query.dice || Math.ceil(Math.random() * 6)).toString(); + let dice = diceRaw.split(",").map((die: string) => parseInt(die)); + + console.log({ dice }); + if (!value) { + return `Unable to parse roll request.`; + } + + switch (value) { + case "orange": + color = "O"; + break; + case "red": + color = "R"; + break; + case "blue": + color = "B"; + break; + case "white": + color = "W"; + break; + } + if (corner && corner.color) { + const player = game.players ? game.players[corner.color] : undefined; + if (player) { + if (corner.type === "city") { + if (player.settlements) { + addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); + player.cities = (player.cities || 0) + 1; + player.settlements = (player.settlements || 1) - 1; + corner.type = "settlement"; + } else { + addChatMessage( + game, + null, + `${player.name}'s city was wiped out, and they have no settlements to replace it!` + ); + delete corner.type; + delete corner.color; + player.cities = (player.cities || 0) + 1; + } + } else { + addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); + delete corner.type; + delete corner.color; + player.settlements = (player.settlements || 0) + 1; + } + } + } + if (!session) { + return `Unable to determine current player turn for admin roll.`; + } + let warning = roll(game, session, dice); + if (warning) { + sendWarning(session, warning); + } + break; + + case "pass": + let name = game.turn.name; + const next = getNextPlayerSession(game, name); + game.turn = { + name: next.player, + color: next.color, + }; + game.turns++; + startTurnTimer(game, next); + addChatMessage(game, null, `The admin skipped ${name}'s turn.`); + addChatMessage(game, null, `It is ${next.name}'s turn.`); + break; + + case "kick": + switch (value) { + case "orange": + color = "O"; + break; + case "red": + color = "R"; + break; + case "blue": + color = "B"; + break; + case "white": + color = "W"; + break; + } + if (corner && corner.color) { + const player = game.players[corner.color]; + if (player) { + if (corner.type === "city") { + if (player.settlements && player.settlements > 0) { + addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`); + player.cities = (player.cities || 0) + 1; + player.settlements = (player.settlements || 0) - 1; + corner.type = "settlement"; + } else { + addChatMessage( + game, + null, + `${player.name}'s city was wiped out, and they have no settlements to replace it!` + ); + delete corner.type; + delete corner.color; + player.cities = (player.cities || 0) + 1; + } + } else { + addChatMessage(game, null, `${player.name}'s settlement was wiped out!`); + delete corner.type; + delete corner.color; + player.settlements = (player.settlements || 0) + 1; + } + } + } + break; + + case "state": + if (game.state !== "lobby") { + return `Game already started.`; + } + if (game.active < 2) { + return `Not enough players in game to start.`; + } + game.state = "game-order"; + /* Delete any non-played colors from the player map; reduces all + * code that would otherwise have to filter out players by checking + * the 'Not active' state of player.status */ + for (let key in game.players) { + if (game.players[key].status !== "Active") { + delete game.players[key]; + } + } + addChatMessage(game, null, `Admin requested to start the game.`); + break; + + default: + return `Invalid admin action ${action}.`; + } +}; + +const setPlayerName = (game: any, session: any, name: string): string | undefined => { if (session.name === name) { return; /* no-op */ } if (session.color) { return `You cannot change your name while you have a color selected.`; } - + if (!name) { return `You can not set your name to nothing!`; } - if (name.toLowerCase() === 'the bank') { + if (name.toLowerCase() === "the bank") { return `You cannot play as the bank!`; } @@ -999,7 +1103,7 @@ const setPlayerName = (game, session, name) => { continue; } if (tmp.name.toLowerCase() === name.toLowerCase()) { - if (!tmp.player || (Date.now() - tmp.player.lastActive) > 60000) { + if (!tmp.player || Date.now() - tmp.player.lastActive > 60000) { rejoin = true; /* Update the session object from tmp, but retain websocket * from active session */ @@ -1011,7 +1115,7 @@ const setPlayerName = (game, session, name) => { } } } - + let message; if (!session.name) { @@ -1024,8 +1128,7 @@ const setPlayerName = (game, session, name) => { message = `${name} has rejoined the lobby.`; } session.name = name; - if (session.ws && (game.id in audio) - && session.name in audio[game.id]) { + if (session.ws && game.id in audio && session.name in audio[game.id]) { part(audio[game.id], session); } } else { @@ -1046,17 +1149,17 @@ const setPlayerName = (game, session, name) => { session.player.name = name; session.player.live = true; } - + if (session.ws && session.hasAudio) { join(audio[game.id], session, { hasVideo: session.video ? true : false, - hasAudio: session.audio ? true : false + hasAudio: session.audio ? true : false, }); } console.log(`${info}: ${message}`); addChatMessage(game, null, message); - /* Rebuild the unselected list */ + /* Rebuild the unselected list */ if (!session.color) { console.log(`${info}: Adding ${session.name} to the unselected`); } @@ -1071,29 +1174,33 @@ const setPlayerName = (game, session, name) => { name: session.name, color: session.color, live: session.live, - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), unselected: getFilteredUnselected(game), - chat: game.chat + chat: game.chat, }); /* Now that a name is set, send the full game to the player */ sendGameToPlayer(game, session); -} +}; -const colorToWord = (color) => { +const colorToWord = (color: string): string => { switch (color) { - case 'O': return 'orange'; - case 'W': return 'white'; - case 'B': return 'blue'; - case 'R': return 'red'; + case "O": + return "orange"; + case "W": + return "white"; + case "B": + return "blue"; + case "R": + return "red"; default: - return ''; + return ""; } -} +}; -const getActiveCount = (game) => { +const getActiveCount = (game: any): number => { let active = 0; for (let color in game.players) { if (!game.players[color].name) { @@ -1102,9 +1209,9 @@ const getActiveCount = (game) => { active++; } return active; -} +}; -const setPlayerColor = (game, session, color) => { +const setPlayerColor = (game: any, session: any, color: string): string | void => { /* Selecting the same color is a NO-OP */ if (session.color === color) { return; @@ -1115,7 +1222,7 @@ const setPlayerColor = (game, session, color) => { return `You may only select a player when you have set your name.`; } - if (game.state !== 'lobby') { + if (game.state !== "lobby") { return `You may only select a player when the game is in the lobby.`; } @@ -1125,41 +1232,39 @@ const setPlayerColor = (game, session, color) => { } /* Verify selection is not already taken */ - if (color && game.players[color].status !== 'Not active') { + if (color && game.players[color].status !== "Not active") { return `${game.players[color].name} already has ${colorToWord(color)}`; } let active = getActiveCount(game); - + if (session.player) { /* Deselect currently active player for this session */ clearPlayer(session.player); session.player = undefined; const old_color = session.color; - session.color = ''; + session.color = ""; active--; - + /* If the player is not selecting a color, then return */ if (!color) { - addChatMessage(game, null, - `${session.name} is no longer ${colorToWord(old_color)}.`); + addChatMessage(game, null, `${session.name} is no longer ${colorToWord(old_color)}.`); game.unselected.push(session); game.active = active; if (active === 1) { - addChatMessage(game, null, - `There are no longer enough players to start a game.`); + addChatMessage(game, null, `There are no longer enough players to start a game.`); } sendUpdateToPlayer(game, session, { name: session.name, - color: '', + color: "", live: session.live, - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { active: game.active, unselected: getFilteredUnselected(game), players: getFilteredPlayers(game), - chat: game.chat + chat: game.chat, }); return; } @@ -1176,9 +1281,9 @@ const setPlayerColor = (game, session, color) => { session.player.live = true; addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`); - const update = { + const update: any = { players: getFilteredPlayers(game), - chat: game.chat + chat: game.chat, }; /* Rebuild the unselected list */ @@ -1195,8 +1300,7 @@ const setPlayerColor = (game, session, color) => { if (game.active !== active) { if (game.active < 2 && active >= 2) { - addChatMessage(game, null, - `There are now enough players to start the game.`); + addChatMessage(game, null, `There are now enough players to start the game.`); } game.active = active; update.active = game.active; @@ -1211,18 +1315,18 @@ const setPlayerColor = (game, session, color) => { sendUpdateToPlayers(game, update); }; -const addActivity = (game, session, message) => { +const addActivity = (game: any, session: any, message: string): void => { let date = Date.now(); if (game.activities.length && game.activities[game.activities.length - 1].date === date) { date++; } - game.activities.push({ color: session ? session.color : '', message, date }); + game.activities.push({ color: session ? session.color : "", message, date }); if (game.activities.length > 30) { game.activities.splice(0, game.activities.length - 30); } -} +}; -const addChatMessage = (game, session, message, isNormalChat) => { +const addChatMessage = (game: any, session: any, message: string, isNormalChat?: boolean) => { let now = Date.now(); let lastTime = 0; if (game.chat.length) { @@ -1232,9 +1336,9 @@ const addChatMessage = (game, session, message, isNormalChat) => { now = lastTime + 1; } - const entry = { + const entry: any = { date: now, - message: message + message: message, }; if (isNormalChat) { entry.normalChat = true; @@ -1251,36 +1355,36 @@ const addChatMessage = (game, session, message, isNormalChat) => { } }; -const getColorFromName = (game, name) => { +const getColorFromName = (game: any, name: string): string => { for (let id in game.sessions) { if (game.sessions[id].name === name) { return game.sessions[id].color; } } - return ''; + return ""; }; - -const getLastPlayerName = (game) => { + +const getLastPlayerName = (game: any): string => { let index = game.playerOrder.length - 1; for (let id in game.sessions) { if (game.sessions[id].color === game.playerOrder[index]) { return game.sessions[id].name; } } - return ''; -} + return ""; +}; -const getFirstPlayerName = (game) => { +const getFirstPlayerName = (game: any): string => { let index = 0; for (let id in game.sessions) { if (game.sessions[id].color === game.playerOrder[index]) { return game.sessions[id].name; } } - return ''; -} + return ""; +}; -const getNextPlayerSession = (game, name) => { +const getNextPlayerSession = (game: any, name: string): any => { let color; for (let id in game.sessions) { if (game.sessions[id].name === name) { @@ -1299,9 +1403,9 @@ const getNextPlayerSession = (game, name) => { } console.error(`getNextPlayerSession -- no player found!`); console.log(game.players); -} +}; -const getPrevPlayerSession = (game, name) => { +const getPrevPlayerSession = (game: any, name: string): any => { let color; for (let id in game.sessions) { if (game.sessions[id].name === name) { @@ -1318,9 +1422,9 @@ const getPrevPlayerSession = (game, name) => { } console.error(`getNextPlayerSession -- no player found!`); console.log(game.players); -} +}; -const processCorner = (game, color, cornerIndex, placedCorner) => { +const processCorner = (game: Game, color: string, cornerIndex: number, placedCorner: CornerPlacement): number => { /* If this corner is allocated and isn't assigned to the walking color, skip it */ if (placedCorner.color && placedCorner.color !== color) { return 0; @@ -1333,8 +1437,9 @@ const processCorner = (game, color, cornerIndex, placedCorner) => { placedCorner.walking = true; /* Calculate the longest road branching from both corners */ let longest = 0; - layout.corners[cornerIndex].roads.forEach(roadIndex => { + layout.corners?.[cornerIndex]?.roads.forEach((roadIndex: number) => { const placedRoad = game.placements.roads[roadIndex]; + if (!placedRoad) return; if (placedRoad.walking) { return; } @@ -1352,7 +1457,13 @@ const processCorner = (game, color, cornerIndex, placedCorner) => { return longest; }; -const buildCornerGraph = (game, color, cornerIndex, placedCorner, set) => { +const buildCornerGraph = ( + game: Game, + color: string, + cornerIndex: number, + placedCorner: CornerPlacement, + set: any +): void => { /* If this corner is allocated and isn't assigned to the walking color, skip it */ if (placedCorner.color && placedCorner.color !== color) { return; @@ -1361,19 +1472,20 @@ const buildCornerGraph = (game, color, cornerIndex, placedCorner, set) => { if (placedCorner.walking) { return; } - + placedCorner.walking = true; /* 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]; + if (!placedRoad) return; buildRoadGraph(game, color, roadIndex, placedRoad, set); }); }; -const processRoad = (game, color, roadIndex, placedRoad) => { +const processRoad = (game: Game, color: string, roadIndex: number, placedRoad: RoadPlacement): number => { /* If this road isn't assigned to the walking color, skip it */ if (placedRoad.color !== color) { - return 0; + return 0; } /* If this road is already being walked, skip it */ @@ -1384,8 +1496,9 @@ const processRoad = (game, color, roadIndex, placedRoad) => { placedRoad.walking = true; /* Calculate the longest road branching from both corners */ let roadLength = 1; - layout.roads[roadIndex].corners.forEach(cornerIndex => { - const placedCorner = game.placements.corners[cornerIndex]; + layout.roads?.[roadIndex]?.corners.forEach((cornerIndex: number) => { + const placedCorner = game.placements.corners?.[cornerIndex]; + if (!placedCorner) return; if (placedCorner.walking) { return; } @@ -1395,7 +1508,7 @@ const processRoad = (game, color, roadIndex, placedRoad) => { return roadLength; }; -const buildRoadGraph = (game, color, roadIndex, placedRoad, set) => { +const buildRoadGraph = (game: Game, color: string, roadIndex: number, placedRoad: RoadPlacement, set: any) => { /* If this road isn't assigned to the walking color, skip it */ if (placedRoad.color !== color) { return; @@ -1408,38 +1521,46 @@ const buildRoadGraph = (game, color, roadIndex, placedRoad, set) => { placedRoad.walking = true; set.push(roadIndex); /* Calculate the longest road branching from both corners */ - layout.roads[roadIndex].corners.forEach(cornerIndex => { - const placedCorner = game.placements.corners[cornerIndex]; - buildCornerGraph(game, color, cornerIndex, placedCorner, set) + layout.roads?.[roadIndex]?.corners.forEach((cornerIndex: any) => { + const placedCorner = game.placements?.corners?.[cornerIndex]; + if (!placedCorner) return; + buildCornerGraph(game, color, cornerIndex, placedCorner, set); }); }; -const clearRoadWalking = (game) => { +const clearRoadWalking = (game: Game): void => { /* Clear out walk markers on roads */ layout.roads.forEach((item, itemIndex) => { - delete game.placements.roads[itemIndex].walking; + if (game.placements?.roads?.[itemIndex]) { + delete game.placements.roads[itemIndex].walking; + } }); /* Clear out walk markers on corners */ layout.corners.forEach((item, itemIndex) => { - delete game.placements.corners[itemIndex].walking; + if (game.placements?.corners?.[itemIndex]) { + delete game.placements.corners[itemIndex].walking; + } }); -} +}; -const calculateRoadLengths = (game, session) => { +const calculateRoadLengths = (game: Game, session: Session): void => { clearRoadWalking(game); let currentLongest = game.longestRoad, - currentLength = currentLongest - ? game.players[currentLongest].longestRoad - : -1; - + currentLength = + currentLongest && typeof currentLongest === "string" && game.players[currentLongest] + ? game.players[currentLongest].longestRoad || -1 + : -1; + /* Clear out player longest road counts */ for (let key in game.players) { - game.players[key].longestRoad = 0; + if (game.players[key]) { + game.players[key].longestRoad = 0; + } } - /* Build a set of connected road graphs. Once all graphs are + /* Build a set of connected road graphs. Once all graphs are * constructed, walk through each graph, starting from each * location in the graph. If the length ever equals the * number of items in the graph, short circuit--longest path. @@ -1447,11 +1568,11 @@ const calculateRoadLengths = (game, session) => { * needed to catch loops where starting from an outside end * point may result in not counting the length of the loop */ - let graphs = []; - layout.roads.forEach((road, roadIndex) => { - const placedRoad = game.placements.roads[roadIndex]; - if (placedRoad.color) { - let set = []; + let graphs: any[] = []; + layout.roads.forEach((_: any, roadIndex: number) => { + const placedRoad = game.placements?.roads?.[roadIndex]; + if (placedRoad && placedRoad.color && typeof placedRoad.color === "string") { + let set: any[] = []; buildRoadGraph(game, placedRoad.color, roadIndex, placedRoad, set); if (set.length) { graphs.push({ color: placedRoad.color, set }); @@ -1459,15 +1580,16 @@ const calculateRoadLengths = (game, session) => { } }); - if (debug.road) console.log('Graphs A:', graphs); - + if (debug.road) console.log("Graphs A:", graphs); + clearRoadWalking(game); - graphs.forEach(graph => { + graphs.forEach((graph: any) => { graph.longestRoad = 0; - graph.set.forEach(roadIndex => { + graph.set.forEach((roadIndex: number) => { const placedRoad = game.placements.roads[roadIndex]; + if (!placedRoad) return; clearRoadWalking(game); - const length = processRoad(game, placedRoad.color, roadIndex, placedRoad); + const length = processRoad(game, placedRoad.color as string, roadIndex, placedRoad); if (length >= graph.longestRoad) { graph.longestStartSegment = roadIndex; graph.longestRoad = length; @@ -1475,77 +1597,97 @@ const calculateRoadLengths = (game, session) => { }); }); - if (debug.road) console.log('Graphs B:', graphs); - - if (debug.road) console.log('Pre update:', - game.placements.roads.filter(road => road.color)); + if (debug.road) console.log("Graphs B:", graphs); + + if (debug.road) + console.log( + "Pre update:", + game.placements.roads.filter((road) => road.color) + ); for (let color in game.players) { - if (game.players[color] === 'Not active') { + if (game.players[color]?.status === "Not active") { continue; } - game.players[color].longestRoad = 0; + if (game.players[color]) { + game.players[color].longestRoad = 0; + } } - graphs.forEach(graph => { - graph.set.forEach(roadIndex => { + graphs.forEach((graph: any) => { + graph.set.forEach((roadIndex: number) => { const placedRoad = game.placements.roads[roadIndex]; + if (!placedRoad) return; clearRoadWalking(game); - const longestRoad = processRoad(game, placedRoad.color, roadIndex, placedRoad); - placedRoad.longestRoad = longestRoad; - game.players[placedRoad.color].longestRoad = - Math.max(game.players[placedRoad.color].longestRoad, longestRoad); + const longestRoad = processRoad(game, placedRoad.color as string, roadIndex, placedRoad); + placedRoad["longestRoad"] = longestRoad; + if (placedRoad.color && typeof placedRoad.color === "string") { + const player = game.players[placedRoad.color]; + if (player) { + const prevVal = player["longestRoad"] || 0; + player["longestRoad"] = Math.max(prevVal, longestRoad); + } + } }); }); - game.placements.roads.forEach(road => delete road.walking); + game.placements.roads.forEach((road: any) => delete road.walking); - if (debug.road) console.log('Post update:', - game.placements.roads.filter(road => road.color)); + if (debug.road) + console.log( + "Post update:", + game.placements.roads.filter((road: any) => road.color) + ); let checkForTies = false; - + if (debug.road) console.log(currentLongest, currentLength); - if (currentLongest && game.players[currentLongest].longestRoad < currentLength) { - const _session = sessionFromColor(game, currentLongest); - addChatMessage(game, session, `${session.name} had their longest road split!`); - checkForTies = true; + if (currentLongest && typeof currentLongest === "string" && game.players[currentLongest]) { + const playerLongest = game.players[currentLongest]["longestRoad"] || 0; + if (playerLongest < currentLength) { + const prevSession = sessionFromColor(game, currentLongest as string); + if (prevSession) { + addChatMessage(game, prevSession, `${prevSession.name} had their longest road split!`); + } + checkForTies = true; + } } - let longestRoad = 4, longestPlayers = []; + let longestRoad = 4; + let longestPlayers: Player[] = []; for (let key in game.players) { const player = game.players[key]; - if (player.status === 'Not active') { + if (!player || player.status === "Not active") { continue; } - if (player.longestRoad > longestRoad) { - longestPlayers = [ player ]; - longestRoad = player.longestRoad; - } else if (game.players[key].longestRoad === longestRoad) { - if (longestRoad >= 5) { + const pLen = player.longestRoad || 0; + if (pLen > longestRoad) { + longestPlayers = [player]; + longestRoad = pLen; + } else if (pLen === longestRoad) { + if (longestRoad >= 5) { longestPlayers.push(player); } } } console.log({ longestPlayers }); - + if (longestPlayers.length > 0) { if (longestPlayers.length === 1) { game.longestRoadLength = longestRoad; - if (game.longestRoad !== longestPlayers[0].color) { - game.longestRoad = longestPlayers[0].color; - addChatMessage(game, session, - `${longestPlayers[0].name} now has the longest ` + - `road (${longestRoad})!`); + if (longestPlayers[0] && longestPlayers[0].color) { + if (game.longestRoad !== longestPlayers[0].color) { + game.longestRoad = longestPlayers[0].color; + addChatMessage(game, session, `${longestPlayers[0].name} now has the longest ` + `road (${longestRoad})!`); + } } } else { if (checkForTies) { game.longestRoadLength = longestRoad; - const names = longestPlayers.map(player => player.name); - addChatMessage(game, session, `${names.join(', ')} are tied for longest ` + - `road (${longestRoad})!`); + const names = longestPlayers.map((player) => player.name); + addChatMessage(game, session, `${names.join(", ")} are tied for longest ` + `road (${longestRoad})!`); } /* Do not reset the longest road! Current Longest is still longest! */ } @@ -1555,97 +1697,101 @@ const calculateRoadLengths = (game, session) => { } }; -const isCompatibleOffer = (player, offer) => { - const isBank = offer.name === 'The bank'; - let valid = player.gets.length === offer.gives.length && - player.gives.length === offer.gets.length; +const isCompatibleOffer = (player: any, offer: any): boolean => { + const isBank = offer.name === "The bank"; + let valid = player.gets.length === offer.gives.length && player.gives.length === offer.gets.length; if (!valid) { console.log(`Gives and gets lengths do not match!`); return false; } - - console.log({ - player: 'Submitting player', - gets: player.gets, - gives: player.gives - }, { - name: offer.name, - gets: offer.gets, - gives: offer.gives - }); - player.gets.forEach(get => { + console.log( + { + player: "Submitting player", + gets: player.gets, + gives: player.gives, + }, + { + name: offer.name, + gets: offer.gets, + gives: offer.gives, + } + ); + + player.gets.forEach((get: any) => { if (!valid) { return; } - valid = offer.gives.find(item => - (item.type === get.type || isBank) && - item.count === get.count) !== undefined; + valid = + offer.gives.find((item: any) => (item.type === get.type || isBank) && item.count === get.count) !== undefined; }); - if (valid) player.gives.forEach(give => { - if (!valid) { - return; - } - valid = offer.gets.find(item => - (item.type === give.type || isBank) && - item.count === give.count) !== undefined; - }); + if (valid) + player.gives.forEach((give: any) => { + if (!valid) { + return; + } + valid = + offer.gets.find((item: any) => (item.type === give.type || isBank) && item.count === give.count) !== undefined; + }); return valid; }; -const isSameOffer = (player, offer) => { - const isBank = offer.name === 'The bank'; +const isSameOffer = (player: any, offer: any): boolean => { + const isBank = offer.name === "The bank"; if (isBank) { return false; } - let same = player.gets && player.gives && + let same = + player.gets && + player.gives && player.gets.length === offer.gets.length && player.gives.length === offer.gives.length; if (!same) { return false; } - - player.gets.forEach(get => { + + player.gets.forEach((get: any) => { if (!same) { return; } - same = offer.gets.find(item => - item.type === get.type && item.count === get.count) !== undefined; + same = offer.gets.find((item: any) => item.type === get.type && item.count === get.count) !== undefined; }); - if (same) player.gives.forEach(give => { - if (!same) { - return; - } - same = offer.gives.find(item => - item.type === give.type && item.count === give.count) !== undefined; - }); + if (same) + player.gives.forEach((give: any) => { + if (!same) { + return; + } + same = offer.gives.find((item: any) => item.type === give.type && item.count === give.count) !== undefined; + }); return same; }; /* Verifies player can meet the offer */ -const checkPlayerOffer = (game, player, offer) => { - let error = undefined; +const checkPlayerOffer = (_game: any, player: any, offer: any): string | undefined => { + let error: string | undefined = undefined; const name = player.name; - console.log({ checkPlayerOffer: { - name: name, - player: player, - gets: offer.gets, - gives: offer.gives, - sheep: player.sheep, - wheat: player.wheat, - brick: player.brick, - stone: player.stone, - wood: player.wood, - description: offerToString(offer) - } }); + console.log({ + checkPlayerOffer: { + name: name, + player: player, + gets: offer.gets, + gives: offer.gives, + sheep: player.sheep, + wheat: player.wheat, + brick: player.brick, + stone: player.stone, + wood: player.wood, + description: offerToString(offer), + }, + }); - offer.gives.forEach(give => { - if (!error) { + offer.gives.forEach((give: any) => { + if (error) { return; } @@ -1655,7 +1801,7 @@ const checkPlayerOffer = (game, player, offer) => { } if (give.count <= 0) { - error = `${give.count} must be more than 0!` + error = `${give.count} must be more than 0!`; return; } @@ -1664,32 +1810,33 @@ const checkPlayerOffer = (game, player, offer) => { return; } - if (offer.gets.find(get => give.type === get.type)) { + if (offer.gets.find((get: any) => give.type === get.type)) { error = `${name} can not give and get the same resource type!`; return; } }); - if (!error) offer.gets.forEach(get => { - if (error) { - return; - } - if (get.count <= 0) { - error = `${get.count} must be more than 0!`; - return; - } - if (offer.gives.find(give => get.type === give.type)) { - error = `${name} can not give and get the same resource type!`; - }; - }) + if (!error) + offer.gets.forEach((get: any) => { + if (error) { + return; + } + if (get.count <= 0) { + error = `${get.count} must be more than 0!`; + return; + } + if (offer.gives.find((give: any) => get.type === give.type)) { + error = `${name} can not give and get the same resource type!`; + } + }); return error; }; -const canMeetOffer = (player, offer) => { +const canMeetOffer = (player: any, offer: any): boolean => { for (let i = 0; i < offer.gets.length; i++) { const get = offer.gets[i]; - if (get.type === 'bank') { + if (get.type === "bank") { if (player[player.gives[0].type] < get.count || get.count <= 0) { return false; } @@ -1700,36 +1847,44 @@ const canMeetOffer = (player, offer) => { return true; }; -const gameSignature = (game) => { +const gameSignature = (game: any): string => { if (!game) { return ""; } const salt = 251; const signature = - game.borderOrder.map(border => `00${(Number(border)^salt).toString(16)}`.slice(-2)).join('') + '-' + - game.pipOrder.map((pip, index) => `00${(Number(pip)^salt^(salt*index)).toString(16)}`.slice(-2)).join('') + '-' + - game.tileOrder.map((tile, index) => `00${(Number(tile)^salt^(salt*index)).toString(16)}`.slice(-2)).join(''); - + game.borderOrder.map((border: any) => `00${(Number(border) ^ salt).toString(16)}`.slice(-2)).join("") + + "-" + + game.pipOrder + .map((pip: any, index: number) => `00${(Number(pip) ^ salt ^ (salt * index)).toString(16)}`.slice(-2)) + .join("") + + "-" + + game.tileOrder + .map((tile: any, index: number) => `00${(Number(tile) ^ salt ^ (salt * index)).toString(16)}`.slice(-2)) + .join(""); + return signature; }; -const setGameFromSignature = (game, border, pip, tile) => { +const setGameFromSignature = (game: any, border: string, pip: string, tile: string): boolean => { const salt = 251; - const borders = [], pips = [], tiles = []; + const borders = [], + pips = [], + tiles = []; for (let i = 0; i < 6; i++) { - borders[i] = parseInt(border.slice(i * 2, (i * 2) + 2), 16)^salt; + borders[i] = parseInt(border.slice(i * 2, i * 2 + 2), 16) ^ salt; if (borders[i] > 6) { return false; } } for (let i = 0; i < 19; i++) { - pips[i] = parseInt(pip.slice(i * 2, (i * 2) + 2), 16)^salt^(salt*i) % 256; + pips[i] = parseInt(pip.slice(i * 2, i * 2 + 2), 16) ^ salt ^ (salt * i) % 256; if (pips[i] > 18) { return false; } } for (let i = 0; i < 19; i++) { - tiles[i] = parseInt(tile.slice(i * 2, (i * 2) + 2), 16)^salt^(salt*i) % 256; + tiles[i] = parseInt(tile.slice(i * 2, i * 2 + 2), 16) ^ salt ^ (salt * i) % 256; if (tiles[i] > 18) { return false; } @@ -1738,28 +1893,31 @@ const setGameFromSignature = (game, border, pip, tile) => { game.pipOrder = pips; game.tileOrder = tiles; return true; -} +}; -const offerToString = (offer) => { - return offer.gives.map(item => `${item.count} ${item.type}`).join(', ') + - ' in exchange for ' + - offer.gets.map(item => `${item.count} ${item.type}`).join(', '); -} +const offerToString = (offer: any): string => { + return ( + (offer.gives || []).map((item: any) => `${item.count} ${item.type}`).join(", ") + + " in exchange for " + + (offer.gets || []).map((item: any) => `${item.count} ${item.type}`).join(", ") + ); +}; -const setForRoadPlacement = (game, limits) => { - game.turn.actions = [ 'place-road' ]; +const setForRoadPlacement = (game: Game, limits: any): void => { + game.turn.actions = ["place-road"]; game.turn.limits = { roads: limits }; -} +}; -const setForCityPlacement = (game, limits) => { - game.turn.actions = [ 'place-city' ]; +const setForCityPlacement = (game: Game, limits: any): void => { + game.turn.actions = ["place-city"]; game.turn.limits = { corners: limits }; -} +}; -const setForSettlementPlacement = (game, limits) => { - game.turn.actions = [ 'place-settlement' ]; +const setForSettlementPlacement = (game: Game, limits?: number[] | undefined, _extra?: any): void => { + // limits: array of valid corner indices + game.turn.actions = ["place-settlement"]; game.turn.limits = { corners: limits }; -} +}; router.put("/:id/:action/:value?", async (req, res) => { const { action, id } = req.params, @@ -1772,10 +1930,10 @@ router.put("/:id/:action/:value?", async (req, res) => { return res.status(404).send(error); } - let error = 'Invalid request'; + let error = "Invalid request"; - if ('private-token' in req.headers) { - if (req.headers['private-token'] !== req.app.get('admin')) { + if ("private-token" in req.headers) { + if (req.headers["private-token"] !== req.app.get("admin")) { error = `Invalid admin credentials.`; } else { error = adminCommands(game, action, value, req.query); @@ -1790,27 +1948,26 @@ router.put("/:id/:action/:value?", async (req, res) => { return res.status(400).send(error); }); -const startTrade = (game, session) => { +const startTrade = (game: any, session: any): string | void => { /* Only the active player can begin trading */ if (game.turn.name !== session.name) { - return `You cannot start trading negotiations when it is not your turn.` + return `You cannot start trading negotiations when it is not your turn.`; } /* Clear any free gives if the player begins trading */ if (game.turn.free) { delete game.turn.free; } - game.turn.actions = ['trade']; + game.turn.actions = ["trade"]; game.turn.limits = {}; for (let key in game.players) { game.players[key].gives = []; game.players[key].gets = []; delete game.players[key].offerRejected; } - addActivity(game, session, - `${session.name} requested to begin trading negotiations.`); + addActivity(game, session, `${session.name} requested to begin trading negotiations.`); }; -const cancelTrade = (game, session) => { +const cancelTrade = (game: any, session: any): string | void => { /* TODO: Perhaps 'cancel' is how a player can remove an offer... */ if (game.turn.name !== session.name) { return `Only the active player can cancel trading negotiations.`; @@ -1820,7 +1977,7 @@ const cancelTrade = (game, session) => { addActivity(game, session, `${session.name} has cancelled trading negotiations.`); }; -const processOffer = (game, session, offer) => { +const processOffer = (game: any, session: any, offer: any): string | void => { let warning = checkPlayerOffer(game, session.player, offer); if (warning) { return warning; @@ -1840,13 +1997,13 @@ const processOffer = (game, session, offer) => { } /* If this offer matches what another player wants, clear rejection - * on of that other player's offer */ + * on of that other player's offer */ for (let color in game.players) { if (color === session.color) { continue; } const other = game.players[color]; - if (other.status !== 'Active') { + if (other.status !== "Active") { continue; } /* Comparison reverses give/get order */ @@ -1860,7 +2017,7 @@ const processOffer = (game, session, offer) => { addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`); }; -const rejectOffer = (game, session, offer) => { +const rejectOffer = (game: any, session: any, offer: any): void => { /* If the active player rejected an offer, they rejected another player */ const other = game.players[offer.color]; if (!other.offerRejected) { @@ -1874,8 +2031,9 @@ const rejectOffer = (game, session, offer) => { addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`); }; -const acceptOffer = (game, session, offer) => { - const name = session.name, player = session.player; +const acceptOffer = (game: any, session: any, offer: any): string | void => { + const name = session.name, + player = session.player; if (game.turn.name !== name) { return `Only the active player can accept an offer.`; @@ -1890,30 +2048,30 @@ const acceptOffer = (game, session, offer) => { return warning; } - if (!isCompatibleOffer(session.player, { - name: offer.name, - gives: offer.gets, - gets: offer.gives - })) { - return `Unfortunately, trades were re-negotiated in transit and 1 ` + - `the deal is invalid!`; + if ( + !isCompatibleOffer(session.player, { + name: offer.name, + gives: offer.gets, + gets: offer.gives, + }) + ) { + return `Unfortunately, trades were re-negotiated in transit and 1 ` + `the deal is invalid!`; } /* Verify that the offer sent by the active player matches what - * the latest offer was that was received by the requesting player */ - if (!offer.name || offer.name !== 'The bank') { + * the latest offer was that was received by the requesting player */ + if (!offer.name || offer.name !== "The bank") { target = game.players[offer.color]; - if (offer.color in target.offerRejected) { + if (target.offerRejected && offer.color in target.offerRejected) { return `${target.name} rejected this offer.`; } if (!isCompatibleOffer(target, offer)) { - return `Unfortunately, trades were re-negotiated in transit and ` + - `the deal is invalid!`; + return `Unfortunately, trades were re-negotiated in transit and ` + `the deal is invalid!`; } - warning = checkPlayerOffer(game, target, { - gives: offer.gets, - gets: offer.gives + warning = checkPlayerOffer(game, target, { + gives: offer.gets, + gets: offer.gives, }); if (warning) { return warning; @@ -1931,19 +2089,19 @@ const acceptOffer = (game, session, offer) => { target = offer; } - debugChat(game, 'Before trade'); + debugChat(game, "Before trade"); /* Transfer goods */ - offer.gets.forEach(item => { - if (target.name !== 'The bank') { + offer.gets.forEach((item: any) => { + if (target.name !== "The bank") { target[item.type] -= item.count; target.resources -= item.count; } player[item.type] += item.count; player.resources += item.count; }); - offer.gives.forEach(item => { - if (target.name !== 'The bank') { + offer.gives.forEach((item: any) => { + if (target.name !== "The bank") { target[item.type] += item.count; target.resources += item.count; } @@ -1951,11 +2109,9 @@ const acceptOffer = (game, session, offer) => { player.resources -= item.count; }); - const from = (offer.name === 'The bank') ? 'the bank' : offer.name; - addChatMessage(game, session, `${session.name} traded ` + - ` ${offerToString(offer)} ` + - `from ${from}.`); - addActivity(game, session, `${session.name} accepted a trade from ${from}.`) + const from = offer.name === "The bank" ? "the bank" : offer.name; + addChatMessage(game, session, `${session.name} traded ` + ` ${offerToString(offer)} ` + `from ${from}.`); + addActivity(game, session, `${session.name} accepted a trade from ${from}.`); delete game.turn.offer; if (target) { delete target.gives; @@ -1965,14 +2121,14 @@ const acceptOffer = (game, session, offer) => { delete session.player.gets; delete game.turn.offer; - debugChat(game, 'After trade'); + debugChat(game, "After trade"); /* Debug!!! */ for (let key in game.players) { - if (!game.players[key].state === 'Active') { + if (game.players[key].state !== "Active") { continue; } - types.forEach(type => { + types.forEach((type) => { if (game.players[key][type] < 0) { throw new Error(`Player resources are below zero! BUG BUG BUG!`); } @@ -1981,52 +2137,52 @@ const acceptOffer = (game, session, offer) => { game.turn.actions = []; }; -const trade = (game, session, action, offer) => { +const trade = (game: any, session: any, action: string, offer: any) => { if (game.state !== "normal") { return `Game not in correct state to begin trading.`; } - if (!game.turn.actions || game.turn.actions.indexOf('trade') === -1) { + if (!game.turn.actions || game.turn.actions.indexOf("trade") === -1) { return startTrade(game, session); } /* Only the active player can cancel trading */ - if (action === 'cancel') { + if (action === "cancel") { return cancelTrade(game, session); } /* Any player can make an offer */ - if (action === 'offer') { + if (action === "offer") { return processOffer(game, session, offer); } /* Any player can reject an offer */ - if (action === 'reject') { + if (action === "reject") { return rejectOffer(game, session, offer); } /* Only the active player can accept an offer */ - if (action === 'accept') { - if (offer.name === 'The bank') { + if (action === "accept") { + if (offer.name === "The bank") { session.player.gets = offer.gets; session.player.gives = offer.gives; } return acceptOffer(game, session, offer); } -} +}; -const clearTimeNotice= (game, session) => { +const clearTimeNotice = (game: any, session: any) => { if (!session.player.turnNotice) { /* benign state; don't alert the user */ //return `You have not been idle.`; } session.player.turnNotice = ""; sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); }; -const startTurnTimer = (game, session) => { +const startTurnTimer = (game: any, session: any) => { const timeout = 90; if (!session.ws) { console.log(`${session.id}: Aborting turn timer as ${session.name} is disconnected.`); @@ -2042,27 +2198,27 @@ const startTurnTimer = (game, session) => { } game.turnTimer = setTimeout(() => { console.log(`${session.id}: Turn timer expired for ${session.name}`); - session.player.turnNotice = 'It is still your turn.'; + session.player.turnNotice = "It is still your turn."; sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); resetTurnTimer(game, session); }, timeout * 1000); -} +}; -const resetTurnTimer = (game, session) => { +const resetTurnTimer = (game: any, session: any): void => { startTurnTimer(game, session); -} +}; -const stopTurnTimer = (game) => { +const stopTurnTimer = (game: any): void => { if (game.turnTimer) { console.log(`${info}: Stopping turn timer.`); clearTimeout(game.turnTimer); game.turnTimer = 0; } -} +}; -const shuffle = (game, session) => { +const shuffle = (game: any, session: any): string | void => { if (game.state !== "lobby") { return `Game no longer in lobby (${game.state}). Can not shuffle board.`; } @@ -2079,23 +2235,23 @@ const shuffle = (game, session) => { robber: game.robber, robberName: game.robberName, signature: game.signature, - animationSeeds: game.animationSeeds + animationSeeds: game.animationSeeds, }); -} +}; -const pass = (game, session) => { +const pass = (game: any, session: any): string | void => { const name = session.name; if (game.turn.name !== name) { - return `You cannot pass when it isn't your turn.` + return `You cannot pass when it isn't your turn.`; } - + /* If the current turn is a robber placement, and everyone has - * discarded, set the limits for where the robber can be placed */ + * discarded, set the limits for where the robber can be placed */ if (game.turn && game.turn.robberInAction) { return `Robber is in action. Turn can not stop until all Robber tasks are resolved.`; } - if (game.state === 'volcano') { + if (game.state === "volcano") { return `You cannot not stop turn until you have finished the Volcano tasks.`; } @@ -2104,7 +2260,7 @@ const pass = (game, session) => { session.player.turnNotice = ""; game.turn = { name: next.name, - color: next.color + color: next.color, }; next.player.turnStart = Date.now(); startTurnTimer(game, next); @@ -2112,37 +2268,38 @@ const pass = (game, session) => { addActivity(game, session, `${name} passed their turn.`); addChatMessage(game, null, `It is ${next.name}'s turn.`); sendUpdateToPlayer(game, next, { - private: next.player + private: next.player, }); sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); delete game.dice; - + sendUpdateToPlayers(game, { turns: game.turns, turn: game.turn, chat: game.chat, activities: game.activities, - dice: game.dice + dice: game.dice, }); - saveGame(game); -} +}; -const placeRobber = (game, session, robber) => { +const placeRobber = (game: any, session: any, robber: any): string | void => { const name = session.name; - robber = parseInt(robber); + if (typeof robber === "string") { + robber = parseInt(robber); + } - if (game.state !== 'normal' && game.turn.roll !== 7) { + if (game.state !== "normal" && game.turn.roll !== 7) { return `You cannot place robber unless 7 was rolled!`; } if (game.turn.name !== name) { return `You cannot place the robber when it isn't your turn.`; } - + for (let color in game.players) { - if (game.players[color].status === 'Not active') { - continue; + if (game.players[color].status === "Not active") { + continue; } if (game.players[color].mustDiscard > 0) { return `You cannot place the robber until everyone has discarded!`; @@ -2158,30 +2315,35 @@ const placeRobber = (game, session, robber) => { pickRobber(game); addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); - let targets = []; - layout.tiles[robber].corners.forEach(cornerIndex => { - const active = game.placements.corners[cornerIndex]; - if (active && active.color - && active.color !== game.turn.color - && targets.findIndex(item => item.color === active.color) === -1) { + let targets: Array<{ color: string; name: string }> = []; + layout.tiles?.[robber]?.corners?.forEach((cornerIndex: number) => { + const active = game.placements?.corners?.[cornerIndex]; + if ( + active && + active.color && + active.color !== game.turn.color && + targets.findIndex((item) => item.color === active.color) === -1 + ) { targets.push({ color: active.color, - name: game.players[active.color].name + name: game.players?.[active.color]?.name || "", }); } }); if (targets.length) { - game.turn.actions = [ 'steal-resource' ], - game.turn.limits = { players: targets }; + (game.turn.actions = ["steal-resource"]), (game.turn.limits = { players: targets }); } else { game.turn.actions = []; game.turn.robberInAction = false; delete game.turn.limits; - addChatMessage(game, null, + addChatMessage( + game, + null, `The dread robber ${game.robberName} was placed on a terrain ` + - `with no other players, ` + - `so ${game.turn.name} does not steal resources from anyone.`); + `with no other players, ` + + `so ${game.turn.name} does not steal resources from anyone.` + ); } sendUpdateToPlayers(game, { @@ -2190,82 +2352,87 @@ const placeRobber = (game, session, robber) => { chat: game.chat, robber: game.robber, robberName: game.robberName, - activities: game.activities + activities: game.activities, }); sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); -} +}; -const stealResource = (game, session, color) => { - if (game.turn.actions.indexOf('steal-resource') === -1) { +const stealResource = (game: any, session: any, color: any): string | void => { + if (game.turn.actions.indexOf("steal-resource") === -1) { return `You can only steal a resource when it is valid to do so!`; } - if (game.turn.limits.players.findIndex(item => item.color === color) === -1) { + if (game.turn.limits.players.findIndex((item: any) => item.color === color) === -1) { return `You can only steal a resource from a player on this terrain!`; } - let victim; + let victim: any | undefined; for (let key in game.sessions) { if (game.sessions[key].color === color) { victim = game.sessions[key]; break; } } - if (!victim) { + if (!victim || !victim.player) { return `You sent a wierd color for the target to steal from.`; } - const cards = []; - [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(field => { - for (let i = 0; i < victim.player[field]; i++) { + const victimPlayer: Record = victim.player; + const sessionPlayer: Record = session.player; + const cards: string[] = []; + ["wheat", "brick", "sheep", "stone", "wood"].forEach((field: string) => { + for (let i = 0; i < (victimPlayer[field] || 0); i++) { cards.push(field); } }); - debugChat(game, 'Before steal'); - + debugChat(game, "Before steal"); + if (cards.length === 0) { - addChatMessage(game, session, - `${victim.name} ` + - `did not have any cards for ${session.name} to steal.`); + addChatMessage(game, session, `${victim.name} ` + `did not have any cards for ${session.name} to steal.`); game.turn.actions = []; game.turn.limits = {}; } else { let index = Math.floor(Math.random() * cards.length), type = cards[index]; - victim.player[type]--; - victim.player.resources--; - session.player[type]++; - session.player.resources++; + if (!type) { + // Defensive: no card type found + game.turn.actions = []; + game.turn.limits = {}; + return; + } + const t = String(type); + victimPlayer[t] = (victimPlayer[t] || 0) - 1; + victimPlayer["resources"] = (victimPlayer["resources"] || 0) - 1; + sessionPlayer[t] = (sessionPlayer[t] || 0) + 1; + sessionPlayer["resources"] = (sessionPlayer["resources"] || 0) + 1; game.turn.actions = []; game.turn.limits = {}; - trackTheft(game, victim.color, session.color, type, 1); + trackTheft(game, victim.color || "", session.color, type, 1); - addChatMessage(game, session, - `${session.name} randomly stole 1 ${type} from ` + - `${victim.name}.`); + addChatMessage(game, session, `${session.name} randomly stole 1 ${type} from ` + `${victim.name}.`); sendUpdateToPlayer(game, victim, { - private: victim.player + private: victim.player, }); } - debugChat(game, 'After steal'); + debugChat(game, "After steal"); game.turn.robberInAction = false; sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities, - players: getFilteredPlayers(game) + players: getFilteredPlayers(game), }); -} +}; -const buyDevelopment = (game, session) => { +const buyDevelopment = (game: any, session: any): string | undefined => { const player = session.player; - - if (game.state !== 'normal') { + + if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } @@ -2293,51 +2460,53 @@ const buyDevelopment = (game, session) => { return `You have already purchased a development card this turn.`; } - debugChat(game, 'Before development purchase'); + debugChat(game, "Before development purchase"); addActivity(game, session, `${session.name} purchased a development card.`); - addChatMessage(game, session, `${session.name} spent 1 stone, 1 wheat, 1 sheep to purchase a development card.`) + addChatMessage(game, session, `${session.name} spent 1 stone, 1 wheat, 1 sheep to purchase a development card.`); player.stone--; player.wheat--; player.sheep--; player.resources = 0; player.developmentCards++; - [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { + ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => { player.resources += player[resource]; }); - debugChat(game, 'After development purchase'); + debugChat(game, "After development purchase"); const card = game.developmentCards.pop(); - card.turn = game.turns; + card.turn = game.turns ? game.turns - 1 : 0; player.development.push(card); - if (isRuleEnabled(game, 'most-developed')) { - if (player.development.length >= 5 - && (!game.mostDeveloped - || player.developmentCards - > game.players[game.mostDeveloped].developmentCards)) { + if (isRuleEnabled(game, "most-developed")) { + if ( + player.development.length >= 5 && + (!game.mostDeveloped || player.developmentCards > game.players[game.mostDeveloped].developmentCards) + ) { if (game.mostDeveloped !== session.color) { game.mostDeveloped = session.color; - game.mostDevelopmentCards = player.developmentCards; - addChatMessage(game, session, `${session.name} now has the most development cards (${player.developmentCards})!`) + game.mostPortCount = player.developmentCards; + addChatMessage(game, session, `${session.name} now has the most development cards (${player.developmentCards})!`); } } } sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { chat: game.chat, activities: game.activities, mostDeveloped: game.mostDeveloped, - players: getFilteredPlayers(game) + players: getFilteredPlayers(game), }); -} + return undefined; +}; -const playCard = (game, session, card) => { - const name = session.name, player = session.player; +const playCard = (game: any, session: any, card: any): string | undefined => { + const name = session.name, + player = session.player; - if (game.state !== 'normal') { + if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { @@ -2346,28 +2515,25 @@ const playCard = (game, session, card) => { if (!game.turn.roll) { return `You cannot play a card until you have rolled.`; } - + if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not play a card until all Robber tasks are resolved.`; } - card = player.development.find( - item => item.type == card.type - && item.card == card.card - && !item.card.played); + card = player.development.find((item: any) => item.type == card.type && item.card == card.card && !item.card.played); if (!card) { return `The card you want to play was not found in your hand!`; } - if (player.playedCard === game.turns && card.type !== 'vp') { + if (player.playedCard === game.turns && card.type !== "vp") { return `You can only play one development card per turn!`; } /* Check if this is a victory point */ - if (card.type === 'vp') { + if (card.type === "vp") { let points = player.points; - player.development.forEach(item => { - if (item.type === 'vp') { + player.development.forEach((item: any) => { + if (item.type === "vp") { points++; } }); @@ -2377,63 +2543,78 @@ const playCard = (game, session, card) => { addChatMessage(game, session, `${name} played a Victory Point card.`); } - if (card.type === 'progress') { + if (card.type === "progress") { switch (card.card) { - case 'road-1': - case 'road-2': - const allowed = Math.min(player.roads, 2); - if (!allowed) { - addChatMessage(game, session, `${session.name} played a Road Building card, but has no roads to build.`); + case "road-1": + case "road-2": + const allowed = Math.min(player.roads, 2); + if (!allowed) { + addChatMessage(game, session, `${session.name} played a Road Building card, but has no roads to build.`); + break; + } + let roads = getValidRoads(game, session.color); + if (roads.length === 0) { + addChatMessage( + game, + session, + `${session.name} played a Road Building card, but they do not have any valid locations to place them.` + ); + break; + } + game.turn.active = "road-building"; + game.turn.free = true; + game.turn.freeRoads = allowed; + addChatMessage( + game, + session, + `${session.name} played a Road Building card. They now place ${allowed} roads for free.` + ); + setForRoadPlacement(game, roads); break; - } - let roads = getValidRoads(game, session.color); - if (roads.length === 0) { - addChatMessage(game, session, `${session.name} played a Road Building card, but they do not have any valid locations to place them.`); + case "monopoly": + game.turn.actions = ["select-resources"]; + game.turn.active = "monopoly"; + addActivity( + game, + session, + `${session.name} played the Monopoly card, and is selecting their resource type to claim.` + ); + break; + case "year-of-plenty": + game.turn.actions = ["select-resources"]; + game.turn.active = "year-of-plenty"; + addActivity(game, session, `${session.name} played the Year of Plenty card.`); + break; + default: + addActivity(game, session, `Oh no! ${card.card} isn't impmented yet!`); break; - } - game.turn.active = 'road-building'; - game.turn.free = true; - game.turn.freeRoads = allowed; - addChatMessage(game, session, `${session.name} played a Road Building card. They now place ${allowed} roads for free.`); - setForRoadPlacement(game, roads); - break; - case 'monopoly': - game.turn.actions = [ 'select-resources' ]; - game.turn.active = 'monopoly'; - addActivity(game, session, `${session.name} played the Monopoly card, and is selecting their resource type to claim.`); - break; - case 'year-of-plenty': - game.turn.actions = [ 'select-resources' ]; - game.turn.active = 'year-of-plenty'; - addActivity(game, session, `${session.name} played the Year of Plenty card.`); - break; - default: - addActivity(game, session, `Oh no! ${card.card} isn't impmented yet!`); - break; } } card.played = true; player.playedCard = game.turns; - if (card.type === 'army') { + if (card.type === "army") { player.army++; addChatMessage(game, session, `${session.name} played a Knight and must move the robber!`); - if (player.army > 2 && - (!game.largestArmy || game.players[game.largestArmy].army < player.army)) { + if (player.army > 2 && (!game.largestArmy || game.players[game.largestArmy].army < player.army)) { if (game.largestArmy !== session.color) { game.largestArmy = session.color; game.largestArmySize = player.army; - addChatMessage(game, session, `${session.name} now has the largest army (${player.army})!`) + addChatMessage(game, session, `${session.name} now has the largest army (${player.army})!`); } } game.turn.robberInAction = true; delete game.turn.placedRobber; - addChatMessage(game, null, `The robber ${game.robberName} has fled before the power of the Knight, ` + - `but a new robber has returned and ${session.name} must now place them.`); - game.turn.actions = [ 'place-robber', 'playing-knight' ]; - game.turn.limits = { pips: [] }; + addChatMessage( + game, + null, + `The robber ${game.robberName} has fled before the power of the Knight, ` + + `but a new robber has returned and ${session.name} must now place them.` + ); + game.turn.actions = ["place-robber", "playing-knight"]; + game.turn.limits = { pips: [] }; for (let i = 0; i < 19; i++) { if (i === game.robber) { continue; @@ -2443,7 +2624,7 @@ const playCard = (game, session, card) => { } sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { chat: game.chat, @@ -2451,32 +2632,30 @@ const playCard = (game, session, card) => { largestArmy: game.largestArmy, largestArmySize: game.largestArmySize, turn: game.turn, - players: getFilteredPlayers(game) + players: getFilteredPlayers(game), }); -} + return undefined; +}; -const placeSettlement = (game, session, index) => { +const placeSettlement = (game: any, session: any, index: any): string | void => { const player = session.player; - index = parseInt(index); - - if (game.state !== 'initial-placement' && game.state !== 'normal') { + if (typeof index === "string") index = parseInt(index); + + if (game.state !== "initial-placement" && game.state !== "normal") { return `You cannot place a settlement unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { return `It is not your turn! It is ${game.turn.name}'s turn.`; } - + /* index out of range... */ if (game.placements.corners[index] === undefined) { return `You have requested to place a settlement illegally!`; } /* If this is not a valid road in the turn limits, discard it */ - if (!game.turn - || !game.turn.limits - || !game.turn.limits.corners - || game.turn.limits.corners.indexOf(index) === -1) { + if (!game.turn || !game.turn.limits || !game.turn.limits.corners || game.turn.limits.corners.indexOf(index) === -1) { return `You tried to cheat! You should not try to break the rules.`; } const corner = game.placements.corners[index]; @@ -2488,11 +2667,11 @@ const placeSettlement = (game, session, index) => { player.banks = []; } - if (game.state === 'normal') { + if (game.state === "normal") { if (!game.turn.free) { if (player.brick < 1 || player.wood < 1 || player.wheat < 1 || player.sheep < 1) { return `You have insufficient resources to build a settlement.`; - } + } } if (player.settlements < 1) { @@ -2502,49 +2681,46 @@ const placeSettlement = (game, session, index) => { player.settlements--; if (!game.turn.free) { - addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood, 1 sheep, 1 wheat to purchase a settlement.`) + addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood, 1 sheep, 1 wheat to purchase a settlement.`); player.brick--; player.wood--; player.wheat--; player.sheep--; player.resources = 0; - [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { + ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => { player.resources += player[resource]; - }); + }); } delete game.turn.free; - + corner.color = session.color; - corner.type = 'settlement'; + corner.type = "settlement"; let bankType = undefined; - if (layout.corners[index].banks.length) { - layout.corners[index].banks.forEach(bank => { + const banks = layout.corners?.[index]?.banks; + if (banks && banks.length) { + banks.forEach((bank: any) => { const border = game.borderOrder[Math.floor(bank / 3)], - type = game.borders[border][bank % 3]; + type = game.borders?.[border]?.[bank % 3]; console.log(`${session.id}: Bank ${bank} = ${type}`); if (!type) { - console.log(`${session.id}: Bank ${bank}`) + console.log(`${session.id}: Bank ${bank}`); return; } - bankType = (type === 'bank') - ? '3 of anything for 1 resource' - : `2 ${type} for 1 resource`; + bankType = type === "bank" ? "3 of anything for 1 resource" : `2 ${type} for 1 resource`; if (player.banks.indexOf(type) === -1) { player.banks.push(type); } player.ports++; - if (isRuleEnabled(game, 'port-of-call')) { + if (isRuleEnabled(game, "port-of-call")) { console.log(`Checking port-of-call`, player.ports, game.mostPorts); - if (player.ports >= 3 - && (!game.mostPorts - || player.ports > game.mostPortCount)) { + if (player.ports >= 3 && (!game.mostPorts || player.ports > game.mostPortCount)) { if (game.mostPorts !== session.color) { game.mostPorts = session.color; game.mostPortCount = player.ports; - addChatMessage(game, session, `${session.name} now has the most ports (${player.ports})!`) + addChatMessage(game, session, `${session.name} now has the most ports (${player.ports})!`); } } } @@ -2553,30 +2729,28 @@ const placeSettlement = (game, session, index) => { game.turn.actions = []; game.turn.limits = {}; if (bankType) { - addActivity(game, session, - `${session.name} placed a settlement by a maritime bank that trades ${bankType}.`); + addActivity(game, session, `${session.name} placed a settlement by a maritime bank that trades ${bankType}.`); } else { - addActivity(game, session, `${session.name} placed a settlement.`); + addActivity(game, session, `${session.name} placed a settlement.`); } calculateRoadLengths(game, session); - } else if (game.state === 'initial-placement') { - if (game.direction && game.direction === 'backward') { - session.initialSettlement = index; + } else if (game.state === "initial-placement") { + if (game.direction && game.direction === "backward") { + session.initialSettlement = index; } corner.color = session.color; - corner.type = 'settlement'; + corner.type = "settlement"; let bankType = undefined; - if (layout.corners[index].banks.length) { - layout.corners[index].banks.forEach(bank => { + const banks2 = layout.corners?.[index]?.banks; + if (banks2 && banks2.length) { + banks2.forEach((bank: any) => { const border = game.borderOrder[Math.floor(bank / 3)], - type = game.borders[border][bank % 3]; + type = game.borders?.[border]?.[bank % 3]; console.log(`${session.id}: Bank ${bank} = ${type}`); if (!type) { return; } - bankType = (type === 'bank') - ? '3 of anything for 1 resource' - : `2 ${type} for 1 resource`; + bankType = type === "bank" ? "3 of anything for 1 resource" : `2 ${type} for 1 resource`; if (player.banks.indexOf(type) === -1) { player.banks.push(type); } @@ -2585,187 +2759,20 @@ const placeSettlement = (game, session, index) => { } player.settlements--; if (bankType) { - addActivity(game, session, + addActivity( + game, + session, `${session.name} placed a settlement by a maritime bank that trades ${bankType}. ` + - `Next, they need to place a road.`); + `Next, they need to place a road.` + ); } else { - addActivity(game, session, `${session.name} placed a settlement. ` + - `Next, they need to place a road.`); + addActivity(game, session, `${session.name} placed a settlement. ` + `Next, they need to place a road.`); } - setForRoadPlacement(game, layout.corners[index].roads); + setForRoadPlacement(game, layout.corners?.[index]?.roads || []); } sendUpdateToPlayer(game, session, { - private: session.player - }); - sendUpdateToPlayers(game, { - placements: game.placements, - activities: game.activities, - mostPorts: game.mostPorts, - turn: game.turn, - chat: game.chat, - players: getFilteredPlayers(game) - }); -} - -const placeRoad = (game, session, index) => { - const player = session.player; - index = parseInt(index); - if (game.state !== 'initial-placement' && game.state !== 'normal') { - return `You cannot purchase a place a road unless the game is active (${game.state}).`; - } - - if (session.color !== game.turn.color) { - return `It is not your turn! It is ${game.turn.name}'s turn.`; - } - - /* Valid index location */ - if (game.placements.roads[index] === undefined) { - return `You have requested to place a road illegally!`; - } - - /* If this is not a valid road in the turn limits, discard it */ - if (!game.turn - || !game.turn.limits - || !game.turn.limits.roads - || game.turn.limits.roads.indexOf(index) === -1) { - return `You tried to cheat! You should not try to break the rules.`; - } - - const road = game.placements.roads[index]; - if (road.color) { - return `This location already has a road belonging to ${game.players[road.color].name}!`; - } - - if (game.state === 'normal') { - if (!game.turn.free) { - if (player.brick < 1 || player.wood < 1) { - return `You have insufficient resources to build a road.`; - } - } - if (player.roads < 1) { - return `You have already built all of your roads.`; - } - - debugChat(game, 'Before road purchase'); - - player.roads--; - if (!game.turn.free) { - addChatMessage(game, session, `${session.name} spent 1 brick, 1 wood to purchase a road.`) - player.brick--; - player.wood--; - player.resources = 0; - [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { - player.resources += player[resource]; - }); - } - - debugChat(game, 'After road purchase'); - - road.color = session.color; - addActivity(game, session, `${session.name} placed a road.`); - calculateRoadLengths(game, session); - - let resetLimits = true; - if (game.turn.active === 'road-building') { - game.turn.freeRoads--; - if (game.turn.freeRoads === 0) { - delete game.turn.free; - delete game.turn.active; - delete game.turn.freeRaods; - } - - let roads = getValidRoads(game, session.color); - if (roads.length === 0) { - delete game.turn.active; - delete game.turn.freeRaods; - addActivity(game, session, `${session.name} has another road to play, but there are no more valid locations.`); - } else if (game.turn.freeRoads !== 0) { - game.turn.free = true; - setForRoadPlacement(game, roads); - resetLimits = false; - } - } - - if (resetLimits) { - delete game.turn.free; - game.turn.actions = []; - game.turn.limits = {}; - } - } else if (game.state === 'initial-placement') { - road.color = session.color; - addActivity(game, session, `${session.name} placed a road.`); - calculateRoadLengths(game, session); - - let next; - if (game.direction === 'forward' && getLastPlayerName(game) === session.name) { - game.direction = 'backward'; - next = session.player; - } else if (game.direction === 'backward' && getFirstPlayerName(game) === session.name) { - /* Done! */ - delete game.direction; - } else { - if (game.direction === 'forward') { - next = getNextPlayerSession(game, session.name); - } else { - next = getPrevPlayerSession(game, session.name); - } - } - if (next) { - game.turn = { - name: next.name, - color: next.color - }; - startTurnTimer(game, next); - setForSettlementPlacement(game, getValidCorners(game)); - calculateRoadLengths(game, session); - addChatMessage(game, null, `It is ${next.name}'s turn to place a settlement.`); - } else { - game.turn = { - actions: [], - limits: { }, - name: session.name, - color: getColorFromName(game, session.name) - }; - session.player.turnStart = Date.now(); - - addChatMessage(game, null, `Everyone has placed their two settlements!`); - - /* Figure out which players received which resources */ - for (let id in game.sessions) { - const session = game.sessions[id], player = session.player, - receives = {}; - if (!player) { - continue; - } - if (session.initialSettlement) { - layout.tiles.forEach((tile, index) => { - if (tile.corners.indexOf(session.initialSettlement) !== -1) { - const resource = staticData.tiles[game.tileOrder[index]].type; - if (!(resource in receives)) { - receives[resource] = 0; - } - receives[resource]++; - } - }); - let message = []; - for (let type in receives) { - player[type] += receives[type]; - player.resources += receives[type]; - sendUpdateToPlayer(game, session, { - private: player - }); - message.push(`${receives[type]} ${type}`); - } - addChatMessage(game, session, `${session.name} receives ${message.join(', ')} for initial settlement placement.`); - } - } - addChatMessage(game, null, `It is ${session.name}'s turn.`); - game.state = 'normal'; - } - } - sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { placements: game.placements, @@ -2774,98 +2781,114 @@ const placeRoad = (game, session, index) => { state: game.state, longestRoad: game.longestRoad, longestRoadLength: game.longestRoadLength, - players: getFilteredPlayers(game) + players: getFilteredPlayers(game), }); -} + return undefined; +}; -const getVictoryPointRule = (game) => { +const getVictoryPointRule = (game: any): number => { const minVP = 10; - if (!isRuleEnabled(game, 'victory-points') - || !('points' in game.rules['victory-points'])) { + if (!isRuleEnabled(game, "victory-points") || !("points" in game.rules["victory-points"])) { return minVP; } - return game.rules['victory-points'].points; -} - -const supportedRules = { - 'victory-points': (game, session, rule, rules) => { - if (!('points' in rules[rule])) { + return game.rules["victory-points"].points; +}; +const supportedRules: Record string | void> = { + "victory-points": (game: any, session: any, rule: any, rules: any) => { + if (!("points" in rules[rule])) { return `No points specified for victory-points`; } if (!rules[rule].enabled) { - addChatMessage(game, null, - `${getName(session)} has disabled the Victory Point ` + - `house rule.`); + addChatMessage(game, null, `${getName(session)} has disabled the Victory Point ` + `house rule.`); } else { - addChatMessage(game, null, - `${getName(session)} set the minimum Victory Points to ` + - `${rules[rule].points}`); + addChatMessage(game, null, `${getName(session)} set the minimum Victory Points to ` + `${rules[rule].points}`); } }, - 'roll-double-roll-again': (game, session, rule, rules) => { - addChatMessage(game, null, - `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Roll Double, Roll Again house rule.`); + "roll-double-roll-again": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Roll Double, Roll Again house rule.` + ); }, - 'volcano': (game, session, rule, rules) => { + volcano: (game: any, session: any, rule: any, rules: any) => { if (!rules[rule].enabled) { - addChatMessage(game, null, - `${getName(session)} has disabled the Volcano ` + - `house rule.`); + addChatMessage(game, null, `${getName(session)} has disabled the Volcano ` + `house rule.`); } else { if (!(rule in game.rules) || !game.rules[rule].enabled) { - addChatMessage(game, null, + addChatMessage( + game, + null, `${getName(session)} enabled the Volcano ` + - `house rule with roll set to ` + - `${rules[rule].number} and 'Volanoes have gold' mode ` + - `${rules[rule].gold ? 'en' : 'dis'}abled.`); + `house rule with roll set to ` + + `${rules[rule].number} and 'Volanoes have gold' mode ` + + `${rules[rule].gold ? "en" : "dis"}abled.` + ); } else { if (game.rules[rule].number !== rules[rule].number) { - addChatMessage(game, null, - `${getName(session)} set the Volcano roll to ` + - `${rules[rule].number}`); + addChatMessage(game, null, `${getName(session)} set the Volcano roll to ` + `${rules[rule].number}`); } if (game.rules[rule].gold !== rules[rule].gold) { - addChatMessage(game, null, - `${getName(session)} has ` + - `${rules[rule].gold ? 'en' : 'dis'}abled the ` + - `'Volcanoes have gold' mode.`); + addChatMessage( + game, + null, + `${getName(session)} has ` + `${rules[rule].gold ? "en" : "dis"}abled the ` + `'Volcanoes have gold' mode.` + ); } } } }, - 'twelve-and-two-are-synonyms': (game, session, rule, rules) => { - addChatMessage(game, null, - `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Twelve and Two are Synonyms house rule.`); + "twelve-and-two-are-synonyms": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Twelve and Two are Synonyms house rule.` + ); game.rules[rule] = rules[rule]; }, - 'most-developed': (game, session, rule, rules) => { - addChatMessage(game, null, - `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Most Developed house rule.`); + "most-developed": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Most Developed house rule.` + ); }, - 'port-of-call': (game, session, rule, rules) => { - addChatMessage(game, null, - `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Another Round of Port house rule.`); + "port-of-call": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Another Round of Port house rule.` + ); }, - 'slowest-turn': (game, session, rule, rules) => { - addChatMessage(game, null, - `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Slowest Turn house rule.`); + "slowest-turn": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Slowest Turn house rule.` + ); }, - 'tiles-start-facing-down': (game, session, rule, rules) => { - addChatMessage(game, null, - `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Tiles Start Facing Down house rule.`); + "tiles-start-facing-down": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Tiles Start Facing Down house rule.` + ); if (rules[rule].enabled) { shuffle(game, session); } }, - 'robin-hood-robber': (game, session, rule, rules) => { - addChatMessage(game, null, - `${getName(session)} has ${rules[rule].enabled ? 'en' : 'dis'}abled the Robin Hood Robber house rule.`); - } + "robin-hood-robber": (game: any, session: any, rule: any, rules: any) => { + addChatMessage( + game, + null, + `${getName(session)} has ${rules[rule].enabled ? "en" : "dis"}abled the Robin Hood Robber house rule.` + ); + }, }; -const setRules = (game, session, rules) => { - if (game.state !== 'lobby') { +const setRules = (game: any, session: any, rules: any): string | undefined => { + if (game.state !== "lobby") { return `You can not modify House Rules once the game has started.`; } @@ -2875,9 +2898,12 @@ const setRules = (game, session, rules) => { } if (rule in supportedRules) { - const warning = supportedRules[rule](game, session, rule, rules); - if (warning) { - return warning; + const handler = supportedRules[rule]; + if (handler) { + const warning = handler(game, session, rule, rules); + if (warning) { + return warning; + } } game.rules[rule] = rules[rule]; } else { @@ -2887,11 +2913,12 @@ const setRules = (game, session, rules) => { sendUpdateToPlayers(game, { rules: game.rules, - chat: game.chat + chat: game.chat, }); + return undefined; }; -const discard = (game, session, discards) => { +const discard = (game: any, session: any, discards: Record): string | void => { const player = session.player; if (game.turn.roll !== 7) { @@ -2899,15 +2926,17 @@ const discard = (game, session, discards) => { } let sum = 0; for (let type in discards) { - if (player[type] < parseInt(discards[type])) { - return `You have requested to discard more ${type} than you have.` + const val = discards[type]; + const parsed = typeof val === "string" ? parseInt(val) : Number(val); + if (player[type] < parsed) { + return `You have requested to discard more ${type} than you have.`; } - sum += parseInt(discards[type]); + sum += parsed; } if (sum > player.mustDiscard) { return `You can not discard that many cards! You can only discard ${player.mustDiscard}.`; } - + if (sum === 0) { return `You must discard at least one card.`; } @@ -2920,7 +2949,11 @@ const discard = (game, session, discards) => { } addChatMessage(game, null, `${session.name} discarded ${sum} resource cards.`); if (player.mustDiscard > 0) { - addChatMessage(game, null, `${session.name} did not discard enough and must discard ${player.mustDiscard} more cards.`); + addChatMessage( + game, + null, + `${session.name} did not discard enough and must discard ${player.mustDiscard} more cards.` + ); } let move = true; @@ -2933,8 +2966,8 @@ const discard = (game, session, discards) => { if (move) { addChatMessage(game, null, `Drat! A new robber has arrived and must be placed by ${game.turn.name}!`); - game.turn.actions = [ 'place-robber' ]; - game.turn.limits = { pips: [] }; + game.turn.actions = ["place-robber"]; + game.turn.limits = { pips: [] }; for (let i = 0; i < 19; i++) { if (i === game.robber) { continue; @@ -2943,19 +2976,19 @@ const discard = (game, session, discards) => { } } sendUpdateToPlayer(game, session, { - private: player + private: player, }); sendUpdateToPlayers(game, { players: getFilteredPlayers(game), chat: game.chat, - turn: game.turn + turn: game.turn, }); -} +}; -const buyRoad = (game, session) => { +const buyRoad = (game: any, session: any): string | void => { const player = session.player; - if (game.state !== 'normal') { + if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { @@ -2964,7 +2997,7 @@ const buyRoad = (game, session) => { if (!game.turn.roll) { return `You cannot build until you have rolled.`; } - + if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not purchase until all Robber tasks are resolved.`; } @@ -2984,28 +3017,26 @@ const buyRoad = (game, session) => { sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, - activities: game.activities + activities: game.activities, }); -} +}; -const selectResources = (game, session, cards) => { +const selectResources = (game: any, session: any, cards: string[]): string | void => { const player = session.player; - if (!game || !game.turn || !game.turn.actions || - game.turn.actions.indexOf('select-resources') === -1) { + if (!game || !game.turn || !game.turn.actions || game.turn.actions.indexOf("select-resources") === -1) { return `Please, let's not cheat. Ok?`; } - if ((session.color !== game.turn.color) - && (!game.turn.select || !(session.color in game.turn.select))) { + if (session.color !== game.turn.color && (!game.turn.select || !(session.color in game.turn.select))) { console.log(session.color, game.turn.color, game.turn.select); return `It is not your turn! It is ${game.turn.name}'s turn.`; } let count = 2; - if (game.turn && game.turn.active === 'monopoly') { + if (game.turn && game.turn.active === "monopoly") { count = 1; } - if (game.state === 'volcano') { + if (game.state === "volcano") { console.log({ cards, turn: game.turn }); if (!game.turn.select) { count = 0; @@ -3013,7 +3044,11 @@ const selectResources = (game, session, cards) => { count = game.turn.select[session.color]; delete game.turn.select[session.color]; if (Object.getOwnPropertyNames(game.turn.select).length === 0) { - addChatMessage(game, null, `${game.turn.name} must roll the die to determine which direction the lava will flow!`); + addChatMessage( + game, + null, + `${game.turn.name} must roll the die to determine which direction the lava will flow!` + ); delete game.turn.select; } } else { @@ -3025,112 +3060,119 @@ const selectResources = (game, session, cards) => { return `You have chosen the wrong number of cards!`; } - const isValidCard = (type) => { + const isValidCard = (type: string): boolean => { switch (type.trim()) { - case 'wheat': - case 'brick': - case 'sheep': - case 'stone': - case 'wood': - return true; - default: - return false; - }; - } + case "wheat": + case "brick": + case "sheep": + case "stone": + case "wood": + return true; + default: + return false; + } + }; - const selected = {}; - cards.forEach(card => { + const selected: Record = {}; + for (const card of cards) { if (!isValidCard(card)) { return `Invalid resource type!`; } - if (card in selected) { - selected[card]++; - } else { - selected[card] = 1; - } - }); - const display = []; + selected[card] = (selected[card] || 0) + 1; + } + const display: string[] = []; for (let card in selected) { display.push(`${selected[card]} ${card}`); } switch (game.turn.active) { - case 'monopoly': - const gave = [], type = cards[0]; - let total = 0; - for (let color in game.players) { - const player = game.players[color]; - if (player.status === 'Not active') { - continue - } - if (color === session.color) { - continue; - } - if (player[type]) { - gave.push(`${player.name} gave ${player[type]} ${type}`); - session.player[type] += player[type]; - session.resources += player[type]; - total += player[type]; - player[type] = 0; - for (let key in game.sessions) { - if (game.sessions[key].player === player) { - sendUpdateToPlayer(game, game.sessions[key], { - private: game.sessions[key].player - }); - break; + case "monopoly": + const gave: string[] = [], + type = String(cards[0]); + let total = 0; + for (let color in game.players) { + const player = game.players[color]; + if (player.status === "Not active") { + continue; + } + if (color === session.color) { + continue; + } + if ((player as any)[type]) { + gave.push(`${player.name} gave ${(player as any)[type]} ${type}`); + (session.player as any)[type] += (player as any)[type]; + session.resources += (player as any)[type]; + total += (player as any)[type]; + (player as any)[type] = 0; + for (let key in game.sessions) { + if (game.sessions[key].player === player) { + sendUpdateToPlayer(game, game.sessions[key], { + private: game.sessions[key].player, + }); + break; + } } } } - } - if (gave.length) { - addChatMessage(game, session, `${session.name} played Monopoly and selected ${display.join(', ')}. ` + - `Players ${gave.join(', ')}. In total, they received ${total} ${type}.`); - } else { - - addActivity(game, session, `${session.name} has chosen ${display.join(', ')}! Unfortunately, no players had that resource. Wa-waaaa.`); - } - delete game.turn.active; - game.turn.actions = []; - break; - - case 'year-of-plenty': - cards.forEach(type => { - session.player[type]++; - session.player.resources++; - }); - addChatMessage(game, session, `${session.name} player Year of Plenty.` + - `They chose to receive ${display.join(', ')} from the bank.`); - delete game.turn.active; - game.turn.actions = []; - break; - case 'volcano': - cards.forEach(type => { - session.player[type]++; - session.player.resources++; - }); - addChatMessage(game, session, `${session.name} player mined ${display.join(', ')} from the Volcano!`); - if (!game.turn.select) { + if (gave.length) { + addChatMessage( + game, + session, + `${session.name} played Monopoly and selected ${display.join(", ")}. ` + + `Players ${gave.join(", ")}. In total, they received ${total} ${type}.` + ); + } else { + addActivity( + game, + session, + `${session.name} has chosen ${display.join(", ")}! Unfortunately, no players had that resource. Wa-waaaa.` + ); + } delete game.turn.active; game.turn.actions = []; - } - break; + break; + + case "year-of-plenty": + cards.forEach((type) => { + session.player[type]++; + session.player.resources++; + }); + addChatMessage( + game, + session, + `${session.name} player Year of Plenty.` + `They chose to receive ${display.join(", ")} from the bank.` + ); + delete game.turn.active; + game.turn.actions = []; + break; + case "volcano": + cards.forEach((type) => { + session.player[type]++; + session.player.resources++; + }); + addChatMessage(game, session, `${session.name} player mined ${display.join(", ")} from the Volcano!`); + if (!game.turn.select) { + delete game.turn.active; + game.turn.actions = []; + } + break; } - + sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, activities: game.activities, - players: getFilteredPlayers(game) + players: getFilteredPlayers(game), }); -} +}; -const buySettlement = (game, session) => { +const buySettlement = (game: any, session: any): string | undefined => { const player = session.player; - if (game.state !== 'normal') { + if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { @@ -3139,7 +3181,7 @@ const buySettlement = (game, session) => { if (!game.turn.roll) { return `You cannot build until you have rolled.`; } - + if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not purchase until all Robber tasks are resolved.`; } @@ -3154,19 +3196,19 @@ const buySettlement = (game, session) => { if (corners.length === 0) { return `There are no valid locations for you to place a settlement.`; } - setForSettlementPlacement(game, corners); + setForSettlementPlacement(game, corners, undefined); addActivity(game, session, `${game.turn.name} is considering placing a settlement.`); - sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, - activities: game.activities + activities: game.activities, }); -} + return undefined; +}; -const buyCity = (game, session) => { +const buyCity = (game: any, session: any): string | void => { const player = session.player; - if (game.state !== 'normal') { + if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { @@ -3174,11 +3216,11 @@ const buyCity = (game, session) => { } if (!game.turn.roll) { return `You cannot build until you have rolled.`; - } + } if (player.wheat < 2 || player.stone < 3) { return `You have insufficient resources to build a city.`; } - + if (game.turn && game.turn.robberInAction) { return `Robber is in action. You can not purchase until all Robber tasks are resolved.`; } @@ -3186,7 +3228,7 @@ const buyCity = (game, session) => { if (player.city < 1) { return `You have already built all of your cities.`; } - const corners = getValidCorners(game, session.color, 'settlement'); + const corners = getValidCorners(game, session.color, "settlement"); if (corners.length === 0) { return `There are no valid locations for you to place a city.`; } @@ -3195,14 +3237,14 @@ const buyCity = (game, session) => { sendUpdateToPlayers(game, { turn: game.turn, chat: game.chat, - activities: game.activities + activities: game.activities, }); -} +}; -const placeCity = (game, session, index) => { +const placeCity = (game: any, session: any, index: any): string | void => { const player = session.player; - index = parseInt(index); - if (game.state !== 'normal') { + if (typeof index === "string") index = parseInt(index); + if (game.state !== "normal") { return `You cannot purchase a development card unless the game is active (${game.state}).`; } if (session.color !== game.turn.color) { @@ -3213,62 +3255,54 @@ const placeCity = (game, session, index) => { return `You have requested to place a city illegally!`; } /* If this is not a placement the turn limits, discard it */ - if (!game.turn - || !game.turn.limits - || !game.turn.limits.corners - || game.turn.limits.corners.indexOf(index) === -1) { + if (!game.turn || !game.turn.limits || !game.turn.limits.corners || game.turn.limits.corners.indexOf(index) === -1) { return `You tried to cheat! You should not try to break the rules.`; } const corner = game.placements.corners[index]; if (corner.color !== session.color) { return `This location already has a settlement belonging to ${game.players[corner.color].name}!`; } - if (corner.type !== 'settlement') { + if (corner.type !== "settlement") { return `This location already has a city!`; } - if (!game.turn.free) { - if (player.wheat < 2 || player.stone < 3) { - return `You have insufficient resources to build a city.`; - } + if (game.turn.free) { + delete game.turn.free; } - if (player.city < 1) { - return `You have already built all of your cities.`; - } - - corner.color = session.color; - corner.type = 'city'; - debugChat(game, 'Before city purchase'); + debugChat(game, "Before city placement"); + + corner.color = session.color; + corner.type = "city"; player.cities--; player.settlements++; if (!game.turn.free) { - addChatMessage(game, session, `${session.name} spent 2 wheat, 3 stone to upgrade to a city.`) + addChatMessage(game, session, `${session.name} spent 2 wheat, 3 stone to upgrade to a city.`); player.wheat -= 2; player.stone -= 3; player.resources = 0; - [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { + ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => { player.resources += player[resource]; - }); + }); } delete game.turn.free; - debugChat(game, 'After city purchase'); + debugChat(game, "After city placement"); game.turn.actions = []; game.turn.limits = {}; addActivity(game, session, `${session.name} upgraded a settlement to a city!`); sendUpdateToPlayer(game, session, { - private: session.player + private: session.player, }); sendUpdateToPlayers(game, { placements: game.placements, turn: game.turn, chat: game.chat, activities: game.activities, - players: getFilteredPlayers(game) + players: getFilteredPlayers(game), }); -} +}; -const ping = (session) => { +const ping = (session: any) => { if (!session.ws) { console.log(`[no socket] Not sending ping to ${session.name} -- connection does not exist.`); return; @@ -3276,22 +3310,29 @@ const ping = (session) => { session.ping = Date.now(); // console.log(`Sending ping to ${session.name}`); - session.ws.send(JSON.stringify({ type: 'ping', ping: session.ping })); + session.ws.send(JSON.stringify({ type: "ping", ping: session.ping })); if (session.keepAlive) { clearTimeout(session.keepAlive); } - session.keepAlive = setTimeout(() => { ping(session); }, 2500); -} + session.keepAlive = setTimeout(() => { + ping(session); + }, 2500); +}; -const wsInactive = (game, req) => { - const session = getSession(game, req.cookies.player); +const wsInactive = (game: any, req: any) => { + const playerCookie = req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : ""; + const session = getSession(game, playerCookie || ""); if (session && session.ws) { console.log(`Closing WebSocket to ${session.name} due to inactivity.`); try { // Defensive: close only if a socket exists; swallow any errors from closing if (session.ws) { - try { session.ws.close(); } catch (e) { /* ignore close errors */ } + try { + session.ws.close(); + } catch (e) { + /* ignore close errors */ + } } } catch (e) { /* ignore */ @@ -3303,9 +3344,9 @@ const wsInactive = (game, req) => { if (req.keepAlive) { clearTimeout(req.keepAlive); } -} +}; -const setGameState = (game, session, state) => { +const setGameState = (game: any, session: any, state: any): string | void => { if (!state) { return `Invalid state.`; } @@ -3319,42 +3360,41 @@ const setGameState = (game, session, state) => { } switch (state) { - case "game-order": - if (game.state !== 'lobby') { - return `You can only start the game from the lobby.`; - } - const active = getActiveCount(game); - if (active < 2) { - return `You need at least two players to start the game.`; - } - /* Delete any non-played colors from the player map; reduces all - * code that would otherwise have to filter out players by checking - * the 'Not active' state of player.status */ - for (let key in game.players) { - if (game.players[key].status !== 'Active') { - delete game.players[key]; + case "game-order": + if (game.state !== "lobby") { + return `You can only start the game from the lobby.`; } - } - addChatMessage(game, null, `${session.name} requested to start the game.`); - game.state = state; + const active = getActiveCount(game); + if (active < 2) { + return `You need at least two players to start the game.`; + } + /* Delete any non-played colors from the player map; reduces all + * code that would otherwise have to filter out players by checking + * the 'Not active' state of player.status */ + for (let key in game.players) { + if (game.players[key].status !== "Active") { + delete game.players[key]; + } + } + addChatMessage(game, null, `${session.name} requested to start the game.`); + game.state = state; - sendUpdateToPlayers(game, { - state: game.state, - chat: game.chat - }); - break; + sendUpdateToPlayers(game, { + state: game.state, + chat: game.chat, + }); + break; } +}; -} - -const resetDisconnectCheck = (game, req) => { +const resetDisconnectCheck = (game: any, req: any): void => { if (req.disconnectCheck) { clearTimeout(req.disconnectCheck); } //req.disconnectCheck = setTimeout(() => { wsInactive(game, req) }, 20000); -} +}; -const join = (peers, session, { hasVideo, hasAudio }) => { +const join = (peers: any, session: any, { hasVideo, hasAudio }: { hasVideo?: boolean; hasAudio?: boolean }): void => { const ws = session.ws; if (!session.name) { @@ -3364,7 +3404,7 @@ const join = (peers, session, { hasVideo, hasAudio }) => { console.log(`${session.id}: <- join - ${session.name}`); console.log(`${all}: -> addPeer - ${session.name}`); - + if (session.name in peers) { console.log(`${session.id}:${session.name} - Already joined to Audio.`); return; @@ -3372,36 +3412,41 @@ const join = (peers, session, { hasVideo, hasAudio }) => { for (let peer in peers) { /* Add this caller to all peers */ - peers[peer].ws.send(JSON.stringify({ - type: 'addPeer', - data: { - peer_id: session.name, - should_create_offer: false, - hasAudio, hasVideo - } - })); + peers[peer].ws.send( + JSON.stringify({ + type: "addPeer", + data: { + peer_id: session.name, + should_create_offer: false, + hasAudio, + hasVideo, + }, + }) + ); /* Add each other peer to the caller */ - ws.send(JSON.stringify({ - type: 'addPeer', - data: { - peer_id: peer, - should_create_offer: true, - hasAudio: peers[peer].hasAudio, - hasVideo: peers[peer].hasVideo - } - })); + ws.send( + JSON.stringify({ + type: "addPeer", + data: { + peer_id: peer, + should_create_offer: true, + hasAudio: peers[peer].hasAudio, + hasVideo: peers[peer].hasVideo, + }, + }) + ); } /* Add this user as a peer connected to this WebSocket */ peers[session.name] = { ws, hasAudio, - hasVideo + hasVideo, }; }; -const part = (peers, session) => { +const part = (peers: any, session: any): void => { const ws = session.ws; if (!session.name) { @@ -3422,28 +3467,31 @@ const part = (peers, session) => { /* Remove this peer from all other peers, and remove each * peer from this peer */ for (let peer in peers) { - peers[peer].ws.send(JSON.stringify({ - type: 'removePeer', - data: {'peer_id': session.name} - })); - ws.send(JSON.stringify({ - type: 'removePeer', - data: {'peer_id': session.name} - })); + peers[peer].ws.send( + JSON.stringify({ + type: "removePeer", + data: { peer_id: session.name }, + }) + ); + ws.send( + JSON.stringify({ + type: "removePeer", + data: { peer_id: session.name }, + }) + ); } }; +const getName = (session: any): string => { + return session ? (session.name ? session.name : session.id) : "Admin"; +}; -const getName = (session) => { - return session ? (session.name ? session.name : session.id) : 'Admin'; -} - -const saveGame = async (game) => { +const saveGame = async (game: any): Promise => { /* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then * delete the player field from them */ const reducedGame = Object.assign({}, game, { sessions: {} }), reducedSessions = []; - + for (let id in game.sessions) { const reduced = Object.assign({}, game.sessions[id]); // Remove private or non-serializable fields from the session copy @@ -3454,9 +3502,9 @@ const saveGame = async (game) => { // non-primitive values such as functions or timers which may cause // JSON.stringify to throw due to circular structures. Object.keys(reduced).forEach((k) => { - if (k.startsWith('_')) { + if (k.startsWith("_")) { delete reduced[k]; - } else if (typeof reduced[k] === 'function') { + } else if (typeof reduced[k] === "function") { delete reduced[k]; } }); @@ -3464,7 +3512,7 @@ const saveGame = async (game) => { if (reduced._initialSnapshotSent) { delete reduced._initialSnapshotSent; } - + reducedGame.sessions[id] = reduced; /* Do not send session-id as those are secrets */ @@ -3493,28 +3541,26 @@ const saveGame = async (game) => { } catch (e) { console.error(`${info}: gameDB.saveGameState failed for ${game.id}`, e); } -} +}; -const departLobby = (game, session, color) => { - const update = {}; +const departLobby = (game: any, session: any, color?: string): void => { + const update: any = {}; update.unselected = getFilteredUnselected(game); if (session.player) { session.player.live = false; update.players = game.players; - } + } if (session.name) { if (session.color) { - addChatMessage(game, null, `${session.name} has disconnected ` + - `from the game.`); + addChatMessage(game, null, `${session.name} has disconnected ` + `from the game.`); } else { addChatMessage(game, null, `${session.name} has left the lobby.`); } update.chat = game.chat; } else { - console.log(`${session.id}: departLobby - ${getName(session)} is ` + - `being removed from ${game.id}'s sessions.`); + console.log(`${session.id}: departLobby - ${getName(session)} is ` + `being removed from ${game.id}'s sessions.`); for (let id in game.sessions) { if (game.sessions[id] === session) { delete game.sessions[id]; @@ -3524,44 +3570,29 @@ const departLobby = (game, session, color) => { } sendUpdateToPlayers(game, update); -} +}; -const all = `[ all ]`; -const info = `[ info ]`; -const todo = `[ todo ]`; - -/* Per-session send throttle (milliseconds). Coalesce rapid updates to avoid - * tight send loops that can overwhelm clients. If multiple updates are - * enqueued within the throttle window, the latest one replaces prior pending - * updates so the client receives a single consolidated message. */ -const SEND_THROTTLE_MS = 50; -// Batch incoming 'get' requests from a single websocket session so multiple -// rapid get requests (often caused by render churn) are combined into one -// response. This helps avoid processing and responding to many near-duplicate -// get messages during connection startup. Window in ms. -const INCOMING_GET_BATCH_MS = 20; - -const queueSend = (session, message) => { +const queueSend = (session: any, message: any): void => { if (!session || !session.ws) return; try { // Ensure we compare a stable serialization: if message is JSON text, // parse it and re-serialize with sorted keys so semantically-equal // objects compare equal even when property order differs. - const stableStringify = (msg) => { + const stableStringify = (msg: any): string => { try { - const obj = typeof msg === 'string' ? JSON.parse(msg) : msg; - const ordered = (v) => { - if (v === null || typeof v !== 'object') return v; + const obj = typeof msg === "string" ? JSON.parse(msg) : msg; + const ordered = (v: any): any => { + if (v === null || typeof v !== "object") return v; if (Array.isArray(v)) return v.map(ordered); const keys = Object.keys(v).sort(); - const out = {}; + const out: any = {}; for (const k of keys) out[k] = ordered(v[k]); return out; }; return JSON.stringify(ordered(obj)); } catch (e) { // If parsing fails, fall back to original string representation - return typeof msg === 'string' ? msg : JSON.stringify(msg); + return typeof msg === "string" ? msg : JSON.stringify(msg); } }; const stableMessage = stableStringify(message); @@ -3578,7 +3609,7 @@ const queueSend = (session, message) => { // If we haven't sent recently and there's no pending timer, send now if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) { try { - session.ws.send(typeof message === 'string' ? message : JSON.stringify(message)); + session.ws.send(typeof message === "string" ? message : JSON.stringify(message)); session._lastSent = Date.now(); session._lastMessage = stableMessage; } catch (e) { @@ -3593,7 +3624,7 @@ const queueSend = (session, message) => { if (session._lastMessage === stableMessage) { return; } - session._pendingMessage = typeof message === 'string' ? message : JSON.stringify(message); + session._pendingMessage = typeof message === "string" ? message : JSON.stringify(message); if (session._pendingTimeout) { // already scheduled; newest message will be sent when timer fires return; @@ -3624,14 +3655,14 @@ const queueSend = (session, message) => { } }; -const sendGameToPlayer = (game, session) => { +const sendGameToPlayer = (game: any, session: any): void => { console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`); if (!session.ws) { console.log(`${session.id}: -> sendGamePlayer:: Currently no connection`); return; } - let update; + let update: any; /* Only send empty name data to unnamed players */ if (!session.name) { @@ -3640,26 +3671,26 @@ const sendGameToPlayer = (game, session) => { } else { update = getFilteredGameForPlayer(game, session); } - + const message = JSON.stringify({ - type: 'game-update', - update: update + type: "game-update", + update: update, }); queueSend(session, message); }; -const sendGameToPlayers = (game) => { +const sendGameToPlayers = (game: any): void => { console.log(`${all}: -> sendGamePlayers - full game`); for (let key in game.sessions) { sendGameToPlayer(game, game.sessions[key]); - } + } }; -const sendUpdateToPlayers = async (game, update) => { +const sendUpdateToPlayers = async (game: any, update: any): Promise => { /* Ensure clearing of a field actually gets sent by setting - * undefined to 'false' - */ + * undefined to 'false' + */ for (let key in update) { if (update[key] === undefined) { update[key] = false; @@ -3672,37 +3703,37 @@ const sendUpdateToPlayers = async (game, update) => { console.log(`[ all ]: -> sendUpdateToPlayers - `, update); } else { const keys = Object.getOwnPropertyNames(update); - console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(',')}`); + console.log(`[ all ]: -> sendUpdateToPlayers - ${keys.join(",")}`); } - + const message = JSON.stringify({ - type: 'game-update', - update + type: "game-update", + update, }); for (let key in game.sessions) { const session = game.sessions[key]; /* Only send player and game data to named players */ if (!session.name) { - console.log(`${session.id}: -> sendUpdateToPlayers:` + - `${getName(session)} - only sending empty name`); + console.log(`${session.id}: -> sendUpdateToPlayers:` + `${getName(session)} - only sending empty name`); if (session.ws) { - session.ws.send(JSON.stringify({ - type: 'game-update', - update: { name: "" } - })); + session.ws.send( + JSON.stringify({ + type: "game-update", + update: { name: "" }, + }) + ); } continue; } if (!session.ws) { - console.log(`${session.id}: -> sendUpdateToPlayers: ` + - `Currently no connection.`); + console.log(`${session.id}: -> sendUpdateToPlayers: ` + `Currently no connection.`); } else { queueSend(session, message); } } -} +}; -const sendUpdateToPlayer = async (game, session, update) => { +const sendUpdateToPlayer = async (game: any, session: any, update: any): Promise => { /* If this player does not have a name, *ONLY* send the name, regardless * of what is requested */ if (!session.name) { @@ -3711,108 +3742,115 @@ const sendUpdateToPlayer = async (game, session, update) => { } /* Ensure clearing of a field actually gets sent by setting - * undefined to 'false' - */ + * undefined to 'false' + */ for (let key in update) { if (update[key] === undefined) { update[key] = false; } } - + calculatePoints(game, update); if (debug.update) { console.log(`${session.id}: -> sendUpdateToPlayer:${getName(session)} - `, update); } else { const keys = Object.getOwnPropertyNames(update); - console.log(`${session.id}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(',')}`); + console.log(`${session.id}: -> sendUpdateToPlayer:${getName(session)} - ${keys.join(",")}`); } const message = JSON.stringify({ - type: 'game-update', - update + type: "game-update", + update, }); if (!session.ws) { - console.log(`${session.id}: -> sendUpdateToPlayer: ` + - `Currently no connection.`); + console.log(`${session.id}: -> sendUpdateToPlayer: ` + `Currently no connection.`); } else { queueSend(session, message); } -} +}; -const getFilteredUnselected = (game) => { +const getFilteredUnselected = (game: any): string[] => { if (!game.unselected) { return []; } - return game.unselected - .filter(session => session.live) - .map(session => session.name); -} + return game.unselected.filter((session: any) => session.live).map((session: any) => session.name); +}; -const parseChatCommands = (game, message) => { +const parseChatCommands = (game: any, message: string): void => { /* Chat messages can set game flags and fields */ - const parts = message.match(/^set +([^ ]*) +(.*)$/i); - if (!parts || parts.length !== 3) { + const partsRaw = message.match(/^set +([^ ]*) +(.*)$/i) as RegExpMatchArray | null; + if (!partsRaw || partsRaw.length !== 3) { return; } - switch (parts[1].toLowerCase()) { - case 'game': - if (parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) { - setBeginnerGame(game); - addChatMessage(game, session, `${session.name} set game board to the Beginner's Layout.`); - break; - } - const signature = parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i); - if (signature) { - if (setGameFromSignature(game, signature[1], signature[2], signature[3])) { - game.signature = parts[2]; - addChatMessage(game, session, `${session.name} set game board to ${parts[2]}.`); - } else { - addChatMessage(game, session, `${session.name} requested an invalid game board.`); + const parts = partsRaw as RegExpMatchArray; + const key = parts[1] || ""; + switch (key.toLowerCase()) { + case "game": + if (parts[2] && parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) { + setBeginnerGame(game); + addChatMessage(game, null, `Game board set to the Beginner's Layout.`); + break; } - } - break; + const signature = parts[2] ? parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i) : null; + if (signature) { + if (setGameFromSignature(game, signature[1] || "", signature[2] || "", signature[3] || "")) { + game.signature = parts[2]; + addChatMessage(game, null, `Game board set to ${parts[2]}.`); + } else { + addChatMessage(game, null, `Requested an invalid game board.`); + } + } + break; } }; -const sendError = (session, error) => { - session.ws.send(JSON.stringify({ type: 'error', error })); -} +const sendError = (session: any, error: string): void => { + try { + session?.ws?.send(JSON.stringify({ type: "error", error })); + } catch (e) { + /* ignore */ + } +}; -const sendWarning = (session, warning) => { - session.ws.send(JSON.stringify({ type: 'warning', warning })); -} +const sendWarning = (session: any, warning: string): void => { + try { + session?.ws?.send(JSON.stringify({ type: "warning", warning })); + } catch (e) { + /* ignore */ + } +}; -const getFilteredPlayers = (game) => { - const filtered = {}; +const getFilteredPlayers = (game: any): Record => { + const filtered: Record = {}; for (let color in game.players) { const player = Object.assign({}, game.players[color]); filtered[color] = player; - if (player.status === 'Not active') { - if (game.state !== 'lobby') { + if (player.status === "Not active") { + if (game.state !== "lobby") { delete filtered[color]; } continue; } player.resources = 0; - [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].forEach(resource => { - player.resources += player[resource]; - delete player[resource]; + ["wheat", "brick", "sheep", "stone", "wood"].forEach((resource: string) => { + player.resources += (player as any)[resource]; + delete (player as any)[resource]; }); delete player.development; } return filtered; }; -const calculatePoints = (game, update) => { - if (game.state === 'winner') { +const calculatePoints = (game: any, update: any): void => { + if (game.state === "winner") { return; } /* Calculate points and determine if there is a winner */ for (let key in game.players) { const player = game.players[key]; - if (player.status === 'Not active') { + if (player.status === "Not active") { continue; } const currentPoints = player.points; @@ -3832,11 +3870,11 @@ const calculatePoints = (game, update) => { } player.points += MAX_SETTLEMENTS - player.settlements; player.points += 2 * (MAX_CITIES - player.cities); - + player.unplayed = 0; player.potential = 0; - player.development.forEach(card => { - if (card.type === 'vp') { + player.development.forEach((card: any) => { + if (card.type === "vp") { if (card.played) { player.points++; } else { @@ -3861,56 +3899,56 @@ const calculatePoints = (game, update) => { * player and if so, declare victory! */ console.log(`${info}: Whoa! ${player.name} has ${player.points}!`); for (let key in game.sessions) { - if (game.sessions[key].color !== player.color - || game.sessions[key].status === 'Not active') { + if (game.sessions[key].color !== player.color || game.sessions[key].status === "Not active") { continue; } - const message = `Wahoo! ${player.name} has ${player.points} ` + - `points on their turn and has won!`; - addChatMessage(game, null, message) + const message = `Wahoo! ${player.name} has ${player.points} ` + `points on their turn and has won!`; + addChatMessage(game, null, message); console.log(`${info}: ${message}`); - update.winner = Object.assign({}, player, { - state: 'winner', + update.winner = Object.assign({}, player, { + state: "winner", stolen: game.stolen, chat: game.chat, turns: game.turns, players: game.players, - elapsedTime: Date.now() - game.startTime + elapsedTime: Date.now() - game.startTime, }); game.winner = update.winner; - game.state = 'winner'; + game.state = "winner"; game.waiting = []; stopTurnTimer(game); sendUpdateToPlayers(game, { state: game.state, winner: game.winner, - players: game.players /* unfiltered */ + players: game.players /* unfiltered */, }); } } - /* If the game isn't in a win state, do not share development card information - * with other players */ - if (game.state !== 'winner') { + /* If the game isn't in a win state, do not share development card information + * with other players */ + if (game.state !== "winner") { for (let key in game.players) { const player = game.players[key]; - if (player.status === 'Not active') { + if (player.status === "Not active") { continue; } - delete player.potential; + delete player.potential; } } -} +}; -const clearGame = (game, session) => { +const clearGame = (game: any, session: any): void => { resetGame(game); - addChatMessage(game, null, - `The game has been reset. You can play again with this board, or ` + - `click 'New Table' to mix things up a bit.`); + addChatMessage( + game, + null, + `The game has been reset. You can play again with this board, or ` + `click 'New Table' to mix things up a bit.` + ); sendGameToPlayers(game); }; -const gotoLobby = (game, session) => { +const gotoLobby = (game: any, session: any): string | undefined => { if (!game.waiting) { game.waiting = []; } @@ -3931,48 +3969,55 @@ const gotoLobby = (game, session) => { game.waiting.push(session.name); addChatMessage(game, null, `${session.name} has gone to the lobby.`); } else if (waitingFor.length !== 0) { - return `You are already waiting in the lobby. ` + - `${waitingFor.join(',')} still needs to go to the lobby.`; + return `You are already waiting in the lobby. ` + `${waitingFor.join(",")} still needs to go to the lobby.`; } if (waitingFor.length === 0) { resetGame(game); addChatMessage(game, null, `All players are back to the lobby.`); - addChatMessage(game, null, - `The game has been reset. You can play again with this board, or `+ - `click 'New Table' to mix things up a bit.`); + addChatMessage( + game, + null, + `The game has been reset. You can play again with this board, or ` + `click 'New Table' to mix things up a bit.` + ); sendGameToPlayers(game); return; } - addChatMessage(game, null, `Waiting for ${waitingFor.join(',')} to go to lobby.`); + addChatMessage(game, null, `Waiting for ${waitingFor.join(",")} to go to lobby.`); sendUpdateToPlayers(game, { - chat: game.chat + chat: game.chat, }); -} + return undefined; +}; router.ws("/ws/:id", async (ws, req) => { - if (!req.cookies || !req.cookies.player) { + if (!req.cookies || !(req.cookies as any)["player"]) { // If the client hasn't established a session cookie, they cannot // participate in a websocket-backed game session. Log the request // headers to aid debugging (e.g. missing Cookie header due to // cross-site requests or proxy configuration) and close the socket // with a sensible code so the client sees a deterministic close. try { - const remote = req.ip || (req.headers && (req.headers['x-forwarded-for'] || req.connection && req.connection.remoteAddress)) || 'unknown'; - console.warn(`[ws] Rejecting connection from ${remote} - missing session cookie. headers=${JSON.stringify(req.headers || {})}`); + const remote = + req.ip || + (req.headers && (req.headers["x-forwarded-for"] || (req.connection && req.connection.remoteAddress))) || + "unknown"; + console.warn( + `[ws] Rejecting connection from ${remote} - missing session cookie. headers=${JSON.stringify(req.headers || {})}` + ); } catch (e) { - console.warn('[ws] Rejecting connection - missing session cookie (unable to serialize headers)'); + console.warn("[ws] Rejecting connection - missing session cookie (unable to serialize headers)"); } try { // Inform the client why we are closing, then close the socket. - ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` })); + ws.send(JSON.stringify({ type: "error", error: `Unable to find session cookie` })); } catch (e) { /* ignore send errors */ } try { // 1008 = Policy Violation - appropriate for missing auth cookie - ws.close && ws.close(1008, 'Missing session cookie'); + ws.close && ws.close(1008, "Missing session cookie"); } catch (e) { /* ignore close errors */ } @@ -3982,8 +4027,19 @@ router.ws("/ws/:id", async (ws, req) => { const { id } = req.params; const gameId = id; - const short = `[${req.cookies.player.substring(0, 8)}]`; - ws.id = short; + if (!gameId) { + try { + ws.send(JSON.stringify({ type: "error", error: "Missing game id" })); + } catch (e) {} + try { + ws.close && ws.close(1008, "Missing game id"); + } catch (e) {} + return; + } + + const playerCookie = req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : ""; + const short = playerCookie ? `[${playerCookie.substring(0, 8)}]` : "[unknown]"; + (ws as any).id = short; console.log(`${short}: Game ${gameId} - New connection from client.`); try { @@ -3991,22 +4047,27 @@ router.ws("/ws/:id", async (ws, req) => { } catch (e) { /* ignore logging errors */ } - if (!(id in audio)) { - audio[id] = {}; /* List of peer sockets using session.name as index. */ - console.log(`${short}: Game ${id} - New Game Audio`); + if (!(gameId in audio)) { + audio[gameId] = {}; /* List of peer sockets using session.name as index. */ + console.log(`${short}: Game ${gameId} - New Game Audio`); } else { - console.log(`${short}: Game ${id} - Already has Audio`); + console.log(`${short}: Game ${gameId} - Already has Audio`); } /* Setup WebSocket event handlers prior to performing any async calls or * we may miss the first messages from clients */ - ws.on('error', async (event) => { + ws.on("error", async (event) => { console.error(`WebSocket error: `, event && event.message ? event.message : event); const game = await loadGame(gameId); if (!game) { return; } - const session = getSession(game, req.cookies.player); + const _session = getSession( + game, + req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" + ); + if (!_session) return; + const session = _session; session.live = false; try { console.log(`${short}: ws.on('error') - session.ws === ws? ${session.ws === ws}`); @@ -4014,7 +4075,11 @@ router.ws("/ws/:id", async (ws, req) => { console.log(`${short}: ws.on('error') - stack:`, new Error().stack); // Only close the session.ws if it is the same socket that errored. if (session.ws && session.ws === ws) { - try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws:`, e); } + try { + session.ws.close(); + } catch (e) { + console.warn(`${short}: error while closing session.ws:`, e); + } session.ws = undefined; } } catch (e) { @@ -4024,14 +4089,21 @@ router.ws("/ws/:id", async (ws, req) => { departLobby(game, session); }); - ws.on('close', async (event) => { - console.log(`${short} - closed connection (event: ${event && typeof event === 'object' ? JSON.stringify(event) : event})`); + ws.on("close", async (event) => { + console.log( + `${short} - closed connection (event: ${event && typeof event === "object" ? JSON.stringify(event) : event})` + ); const game = await loadGame(gameId); if (!game) { return; } - const session = getSession(game, req.cookies.player); + const _session = getSession( + game, + req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" + ); + if (!_session) return; + const session = _session; if (session.player) { session.player.live = false; } @@ -4039,13 +4111,23 @@ router.ws("/ws/:id", async (ws, req) => { // Only cleanup the session.ws if it references the same socket object try { console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`); - console.log(`${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}`); + console.log( + `${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}` + ); if (session.ws && session.ws === ws) { /* Cleanup any voice channels */ - if (id in audio) { - try { part(audio[id], session); } catch (e) { console.warn(`${short}: Error during part():`, e); } + if (gameId in audio) { + try { + part(audio[gameId], session); + } catch (e) { + console.warn(`${short}: Error during part():`, e); + } + } + try { + session.ws.close(); + } catch (e) { + console.warn(`${short}: error while closing session.ws in on('close'):`, e); } - try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws in on('close'):`, e); } session.ws = undefined; console.log(`${short}:WebSocket closed for ${getName(session)}`); } @@ -4057,7 +4139,7 @@ router.ws("/ws/:id", async (ws, req) => { /* Check for a game in the Winner state with no more connections * and remove it */ - if (game.state === 'winner') { + if (game.state === "winner") { let dead = true; for (let id in game.sessions) { if (game.sessions[id].live && game.sessions[id].name) { @@ -4065,12 +4147,10 @@ router.ws("/ws/:id", async (ws, req) => { } } if (dead) { - console.log(`${session.id}: No more players in ${game.id}. ` + - `Removing.`); - addChatMessage(game, null, `No more active players in game. ` + - `It is being removed from the server.`); + console.log(`${session.id}: No more players in ${game.id}. ` + `Removing.`); + addChatMessage(game, null, `No more active players in game. ` + `It is being removed from the server.`); sendUpdateToPlayers(game, { - chat: game.chat + chat: game.chat, }); for (let id in game.sessions) { if (game.sessions[id].ws) { @@ -4084,22 +4164,22 @@ router.ws("/ws/:id", async (ws, req) => { delete game.sessions[id]; } } - delete audio[id]; - delete games[id]; + delete audio[gameId]; + delete games[gameId]; try { if (!gameDB || !gameDB.deleteGame) { console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`); } else { - await gameDB.deleteGame(id); + await gameDB.deleteGame(gameId); } } catch (error) { console.error(`${session.id}: Unable to remove game ${id} via gameDB.deleteGame`, error); } } - } + } }); - ws.on('message', async (message) => { + ws.on("message", async (message) => { // Normalize the incoming message to { type, data } so handlers can // reliably access the payload without repeated defensive checks. const incoming = normalizeIncoming(message); @@ -4109,17 +4189,21 @@ router.ws("/ws/:id", async (ws, req) => { try { console.error(`${all}: parse/normalize error`, message); } catch (e) { - console.error('parse/normalize error'); + console.error("parse/normalize error"); } return; } - const data = incoming.data; + const data = (incoming.data as any) || {}; const game = await loadGame(gameId); - const session = getSession(game, req.cookies.player); + const _session = getSession( + game, + req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" + ); + if (!_session) return; + const session = _session; // Keep track of any previously attached websocket so we can detect // first-time attaches and websocket replacements (reconnects). const previousWs = session.ws; - const wasAttached = !!previousWs; // If there was a previous websocket and it's a different object, try to // close it to avoid stale sockets lingering in memory. if (previousWs && previousWs !== ws) { @@ -4136,8 +4220,10 @@ router.ws("/ws/:id", async (ws, req) => { } session.live = true; session.lastActive = Date.now(); - - let error, warning, update, processed = true; + + let error: string | undefined; + let warning: string | void | undefined; + let processed = true; // If this is the first time the session attached a WebSocket, or if the // websocket was just replaced (reconnect), send an initial consolidated @@ -4151,271 +4237,302 @@ router.ws("/ws/:id", async (ws, req) => { console.error(`${session.id}: error sending initial snapshot`, e); } } - - switch (incoming.type) { - case 'join': - // Accept either legacy `config` or newer `data` field from clients - join(audio[id], session, data.config || data.data || {}); - break; - case 'part': - part(audio[id], session); - break; + switch (incoming.type) { + case "join": + // Accept either legacy `config` or newer `data` field from clients + - case 'relayICECandidate': { - if (!(id in audio)) { - console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`); - return; - } - // Support both { config: {...} } and { data: {...} } client payloads - const cfg = data.config || data.data || {}; - const { peer_id, candidate } = cfg; - if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate); - - message = JSON.stringify({ - type: 'iceCandidate', - data: {'peer_id': getName(session), 'candidate': candidate } - }); - - if (peer_id in audio[id]) { - audio[id][peer_id].ws.send(message); - } - } break; - - case 'relaySessionDescription': { - if (!(id in audio)) { - console.error(`${id} - relaySessionDescription - Does not have Audio`); - return; - } - - // Support both { config: {...} } and { data: {...} } client payloads - const cfg = data.config || data.data || {}; - const { peer_id, session_description } = cfg; - if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, session_description); - message = JSON.stringify({ - type: 'sessionDescription', - data: {'peer_id': getName(session), 'session_description': session_description } - }); - if (peer_id in audio[id]) { - audio[id][peer_id].ws.send(message); - } - } break; - - case 'pong': - resetDisconnectCheck(game, req); - break; - - case 'game-update': - console.log(`${short}: <- game-update ${getName(session)} - full game update.`); - sendGameToPlayer(game, session); - break; - - case 'peer_state_update': { - // Broadcast a peer state update (muted/video_on) to other peers in the game audio map - if (!(id in audio)) { - console.error(`${session.id}:${id} <- peer_state_update - Does not have Audio`); - return; - } - - const cfg = data.config || data.data || {}; - const { peer_id, muted, video_on } = cfg; - if (!session.name) { - console.error(`${session.id}: peer_state_update - unnamed session`); - return; - } - - const messagePayload = JSON.stringify({ - type: 'peer_state_update', - data: { peer_id: session.name, muted, video_on }, - }); - - // Send to all other peers - for (const other in audio[id]) { - if (other === session.name) continue; - try { - audio[id][other].ws.send(messagePayload); - } catch (e) { - console.warn(`Failed sending peer_state_update to ${other}:`, e); - } - } - } break; - - case 'player-name': - // Support both legacy { type: 'player-name', name: 'Foo' } - // and normalized { type: 'player-name', data: { name: 'Foo' } } - const _pname = (data && data.name) || (data && data.data && data.data.name); - console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${_pname}`) - error = setPlayerName(game, session, _pname); - if (error) { - sendError(session, error); - }else { - saveGame(game); - } - break; - - case 'set': - console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`); - switch (data.field) { - case 'state': - warning = setGameState(game, session, data.value); - if (warning) { - sendWarning(session, warning); - } else { - saveGame(game); - } + join(audio[gameId], session, data.config || data.data || {}); break; - case 'color': - warning = setPlayerColor(game, session, data.value); - if (warning) { - sendWarning(session, warning); - } else { - saveGame(game); - } + case "part": + part(audio[gameId], session); break; - default: - console.warn(`WARNING: Requested SET unsupported field: ${data.field}`); - break; - } - break; - case 'get': - // Batch 'get' requests per-session for a short window so multiple - // near-simultaneous requests are merged into one response. This - // reduces CPU and network churn during client startup. - const requestedFields = Array.isArray(data.fields) - ? data.fields - : (data.data && Array.isArray(data.data.fields)) - ? data.data.fields - : []; - console.log(`${short}: <- get:${getName(session)} ${requestedFields.length ? requestedFields.join(',') : ''}`); + case "relayICECandidate": + { + if (!(gameId in audio)) { + console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`); + return; + } - // Ensure a batch structure exists on the session - if (!session._getBatch) { - session._getBatch = { fields: new Set(), timer: undefined }; - } - // Merge requested fields into the batch set - requestedFields.forEach(f => session._getBatch.fields.add(f)); + // Support both { config: {...} } and { data: {...} } client payloads + const cfg = data.config || data.data || {}; + const { peer_id, candidate } = cfg; + if (debug.audio) + console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate); - // If a timer is already scheduled, we will respond when it fires. - if (session._getBatch.timer) { - break; - } + message = JSON.stringify({ + type: "iceCandidate", + data: { peer_id: getName(session), candidate: candidate }, + }) as any; - // Schedule a single reply after the batching window - session._getBatch.timer = setTimeout(() => { - try { - const fieldsArray = Array.from(session._getBatch.fields); - const batchedUpdate = {}; - fieldsArray.forEach((field) => { - switch (field) { - case 'player': - sendWarning(session, `'player' is not a valid item. use 'private' instead`); - batchedUpdate.player = undefined; - break; - case 'id': - case 'chat': - case 'startTime': - case 'state': - case 'turn': - case 'turns': - case 'winner': - case 'placements': - case 'longestRoadLength': - case 'robber': - case 'robberName': - case 'pips': - case 'pipsOrder': - case 'borders': - case 'tileOrder': - case 'active': - case 'largestArmy': - case 'mostDeveloped': - case 'mostPorts': - case 'longestRoad': - case 'tiles': - case 'pipOrder': - case 'signature': - case 'borderOrder': - case 'dice': - case 'activities': - batchedUpdate[field] = game[field]; - break; - case 'rules': - batchedUpdate[field] = game.rules ? game.rules : {}; - break; - case 'name': - batchedUpdate.name = session.name; - break; - case 'unselected': - batchedUpdate.unselected = getFilteredUnselected(game); - break; - case 'private': - batchedUpdate.private = session.player; - break; - case 'players': - batchedUpdate.players = getFilteredPlayers(game); - break; - case 'color': - console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`); - batchedUpdate.color = session.color; - break; - case 'timestamp': - batchedUpdate.timestamp = Date.now(); - break; - default: - if (field in game) { - console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`); - batchedUpdate[field] = game[field]; - } else if (field in session) { - console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`); - batchedUpdate[field] = session[field]; - } else { - console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`); - } - break; + if (peer_id in audio[gameId]) { + try { + (audio[gameId][peer_id] as any).ws.send(message as any); + } catch (e) { + /* ignore */ } - }); - sendUpdateToPlayer(game, session, batchedUpdate); - } catch (e) { - console.warn(`${session.id}: get batch handler failed:`, e); + } } - // clear batch - session._getBatch.fields.clear(); - clearTimeout(session._getBatch.timer); - session._getBatch.timer = undefined; - }, INCOMING_GET_BATCH_MS); - break; - - case 'chat': - /* If the chat message is empty, do not add it to the chat */ - if (data.message.trim() == '') { break; - } - console.log(`${short}:${id} - ${data.type} - "${data.message}"`) - addChatMessage(game, session, `${session.name}: ${data.message}`, true); - parseChatCommands(game, data.message); - sendUpdateToPlayers(game, { chat: game.chat }); - saveGame(game); - break; - case 'media-status': - console.log(`${short}: <- media-status - `, data.audio, data.video); - session.video = data.video; - session.audio = data.audio; - break; + case "relaySessionDescription": + { + if (!(gameId in audio)) { + console.error(`${gameId} - relaySessionDescription - Does not have Audio`); + return; + } - default: - processed = false; - break; + // Support both { config: {...} } and { data: {...} } client payloads + const cfg = data.config || data.data || {}; + const { peer_id, session_description } = cfg; + if (debug.audio) + console.log( + `${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, + session_description + ); + message = JSON.stringify({ + type: "sessionDescription", + data: { peer_id: getName(session), session_description: session_description }, + }) as any; + if (peer_id in audio[gameId]) { + try { + (audio[gameId][peer_id] as any).ws.send(message as any); + } catch (e) { + /* ignore */ + } + } + } + break; + + case "pong": + resetDisconnectCheck(game, req); + break; + + case "game-update": + console.log(`${short}: <- game-update ${getName(session)} - full game update.`); + sendGameToPlayer(game, session); + break; + + case "peer_state_update": + { + // Broadcast a peer state update (muted/video_on) to other peers in the game audio map + if (!(gameId in audio)) { + console.error(`${session.id}:${gameId} <- peer_state_update - Does not have Audio`); + return; + } + + const cfg = data.config || data.data || {}; + const { peer_id, muted, video_on } = cfg; + if (!session.name) { + console.error(`${session.id}: peer_state_update - unnamed session`); + return; + } + + const messagePayload = JSON.stringify({ + type: "peer_state_update", + data: { peer_id: session.name, muted, video_on }, + }); + + // Send to all other peers + for (const other in audio[gameId]) { + if (other === session.name) continue; + try { + try { + (audio[gameId][other] as any).ws.send(messagePayload as any); + } catch (e) { + /* ignore */ + } + } catch (e) { + console.warn(`Failed sending peer_state_update to ${other}:`, e); + } + } + } + break; + + case "player-name": + // Support both legacy { type: 'player-name', name: 'Foo' } + // and normalized { type: 'player-name', data: { name: 'Foo' } } + const _pname = (data && data.name) || (data && data.data && data.data.name); + console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${_pname}`); + error = setPlayerName(game, session, _pname); + if (error) { + sendError(session, error); + } else { + saveGame(game); + } + break; + + case "set": + console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`); + switch (data.field) { + case "state": + warning = setGameState(game, session, data.value); + if (warning) { + sendWarning(session, warning); + } else { + saveGame(game); + } + break; + + case "color": + warning = setPlayerColor(game, session, data.value); + if (warning) { + sendWarning(session, warning); + } else { + saveGame(game); + } + break; + default: + console.warn(`WARNING: Requested SET unsupported field: ${data.field}`); + break; + } + break; + + case "get": + // Batch 'get' requests per-session for a short window so multiple + // near-simultaneous requests are merged into one response. This + // reduces CPU and network churn during client startup. + const requestedFields: string[] = Array.isArray(data.fields) + ? (data.fields as string[]) + : data.data && Array.isArray(data.data.fields) + ? (data.data.fields as string[]) + : []; + console.log( + `${short}: <- get:${getName(session)} ${requestedFields.length ? requestedFields.join(",") : ""}` + ); + + // Ensure a batch structure exists on the session + if (!session._getBatch) { + session._getBatch = { fields: new Set(), timer: undefined }; + } + // Merge requested fields into the batch set + requestedFields.forEach((f: string) => session._getBatch && session._getBatch.fields.add(f)); + + // If a timer is already scheduled, we will respond when it fires. + if (session._getBatch.timer) { + break; + } + + // Schedule a single reply after the batching window + session._getBatch.timer = setTimeout(() => { + try { + if (!session._getBatch) return; + const fieldsArray: string[] = Array.from(session._getBatch.fields) as string[]; + const batchedUpdate: any = {}; + fieldsArray.forEach((field: string) => { + switch (field) { + case "player": + sendWarning(session, `'player' is not a valid item. use 'private' instead`); + batchedUpdate.player = undefined; + break; + case "id": + case "chat": + case "startTime": + case "state": + case "turn": + case "turns": + case "winner": + case "placements": + case "longestRoadLength": + case "robber": + case "robberName": + case "pips": + case "pipsOrder": + case "borders": + case "tileOrder": + case "active": + case "largestArmy": + case "mostDeveloped": + case "mostPorts": + case "longestRoad": + case "tiles": + case "pipOrder": + case "signature": + case "borderOrder": + case "dice": + case "activities": + batchedUpdate[field] = game[field]; + break; + case "rules": + batchedUpdate[field] = game.rules ? game.rules : {}; + break; + case "name": + batchedUpdate.name = session.name; + break; + case "unselected": + batchedUpdate.unselected = getFilteredUnselected(game); + break; + case "private": + batchedUpdate.private = session.player; + break; + case "players": + batchedUpdate.players = getFilteredPlayers(game); + break; + case "color": + console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`); + batchedUpdate.color = session.color; + break; + case "timestamp": + batchedUpdate.timestamp = Date.now(); + break; + default: + if (field in game) { + console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`); + batchedUpdate[String(field)] = (game as any)[String(field)]; + } else if (field in session) { + console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`); + batchedUpdate[String(field)] = (session as any)[String(field)]; + } else { + console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`); + } + break; + } + }); + sendUpdateToPlayer(game, session, batchedUpdate); + } catch (e) { + console.warn(`${session.id}: get batch handler failed:`, e); + } + // clear batch + if (session._getBatch) { + session._getBatch.fields.clear(); + clearTimeout(session._getBatch.timer as any); + session._getBatch.timer = undefined; + } + }, INCOMING_GET_BATCH_MS); + break; + + case "chat": + /* If the chat message is empty, do not add it to the chat */ + if (data.message.trim() == "") { + break; + } + console.log(`${short}:${id} - ${data.type} - "${data.message}"`); + addChatMessage(game, session, `${session.name}: ${data.message}`, true); + parseChatCommands(game, data.message); + sendUpdateToPlayers(game, { chat: game.chat }); + saveGame(game); + break; + + case "media-status": + console.log(`${short}: <- media-status - `, data.audio, data.video); + session["video"] = data.video; + session["audio"] = data.audio; + break; + + default: + processed = false; + break; } if (processed) { /* saveGame(game); -- do not save here; only save on changes */ return; } - + /* The rest of the actions and commands require an active game * participant */ @@ -4424,173 +4541,173 @@ router.ws("/ws/:id", async (ws, req) => { sendError(session, error); return; } - + processed = true; - const priorSession = session; - - switch (incoming.type) { - case 'roll': - console.log(`${short}: <- roll:${getName(session)}`); - warning = roll(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'shuffle': - console.log(`${short}: <- shuffle:${getName(session)}`); - warning = shuffle(game, session); - if (warning) { - warning(session, error); - } - break; - case 'place-settlement': - console.log(`${short}: <- place-settlement:${getName(session)} ${data.index}`); - warning = placeSettlement(game, session, data.index); - if (warning) { - sendWarning(session, warning); - } - break; - case 'place-city': - console.log(`${short}: <- place-city:${getName(session)} ${data.index}`); - warning = placeCity(game, session, data.index); - if (warning) { - sendWarning(session, warning); - } - break; - case 'place-road': - console.log(`${short}: <- place-road:${getName(session)} ${data.index}`); - warning = placeRoad(game, session, data.index); - if (warning) { - sendWarning(session, warning); - } - break; - case 'place-robber': - console.log(`${short}: <- place-robber:${getName(session)} ${data.index}`); - warning = placeRobber(game, session, data.index); - if (warning) { - sendWarning(session, warning); - } - break; - case 'steal-resource': - console.log(`${short}: <- steal-resource:${getName(session)} ${data.color}`); - warning = stealResource(game, session, data.color); - if (warning) { - sendWarning(session, warning); - } - break; - case 'discard': - console.log(`${short}: <- discard:${getName(session)}`); - warning = discard(game, session, data.discards); - if (warning) { - sendWarning(session, warning); - } - break; - case 'pass': - console.log(`${short}: <- pass:${getName(session)}`); - warning = pass(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'select-resources': - console.log(`${short}: <- select-resources:${getName(session)} - `, data.cards); - warning = selectResources(game, session, data.cards); - if (warning) { - sendWarning(session, warning); - } - break; - case 'buy-city': - console.log(`${short}: <- buy-city:${getName(session)}`); - warning = buyCity(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'buy-road': - console.log(`${short}: <- buy-road:${getName(session)}`); - warning = buyRoad(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'buy-settlement': - console.log(`${short}: <- buy-settlement:${getName(session)}`); - warning = buySettlement(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'buy-development': - console.log(`${short}: <- buy-development:${getName(session)}`); - warning = buyDevelopment(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'play-card': - console.log(`${short}: <- play-card:${getName(session)}`); - warning = playCard(game, session, data.card); - if (warning) { - sendWarning(session, warning); - } - break; - case 'trade': - console.log(`${short}: <- trade:${getName(session)} - ` + - (data.action ? data.action : 'start') + ` -`, - data.offer ? data.offer : 'no trade yet'); - warning = trade(game, session, data.action, data.offer); - if (warning) { - sendWarning(session, warning); - } else { - for (let key in game.sessions) { - const tmp = game.sessions[key]; - if (tmp.player) { - sendUpdateToPlayer(game, tmp, { - private: tmp.player - }); - } + const _priorSession = session; + + switch (incoming.type) { + case "roll": + console.log(`${short}: <- roll:${getName(session)}`); + warning = roll(game, session); + if (warning) { + sendWarning(session, warning); } - sendUpdateToPlayers(game, { - turn: game.turn, - activities: game.activities, - chat: game.chat, - players: getFilteredPlayers(game) - }); - } - break; - case 'turn-notice': - console.log(`${short}: <- turn-notice:${getName(session)}`); - warning = clearTimeNotice(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'clear-game': - console.log(`${short}: <- clear-game:${getName(session)}`); - warning = clearGame(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'goto-lobby': - console.log(`${short}: <- goto-lobby:${getName(session)}`); - warning = gotoLobby(game, session); - if (warning) { - sendWarning(session, warning); - } - break; - case 'rules': - console.log(`${short} - <- rules:${getName(session)} - `, - data.rules); - warning = setRules(game, session, data.rules); - if (warning) { - sendWarning(session, warning); - } - break; - default: - console.warn(`Unsupported request: ${data.type}`); - processed = false; - break; + break; + case "shuffle": + console.log(`${short}: <- shuffle:${getName(session)}`); + warning = shuffle(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "place-settlement": + console.log(`${short}: <- place-settlement:${getName(session)} ${data.index}`); + warning = placeSettlement(game, session, data.index); + if (warning) { + sendWarning(session, warning); + } + break; + case "place-city": + console.log(`${short}: <- place-city:${getName(session)} ${data.index}`); + warning = placeCity(game, session, data.index); + if (warning) { + sendWarning(session, warning); + } + break; + case "place-road": + console.log(`${short}: <- place-road:${getName(session)} ${data.index}`); + warning = placeRoad(game, session, data.index); + if (warning) { + sendWarning(session, warning); + } + break; + case "place-robber": + console.log(`${short}: <- place-robber:${getName(session)} ${data.index}`); + warning = placeRobber(game, session, data.index); + if (warning) { + sendWarning(session, warning); + } + break; + case "steal-resource": + console.log(`${short}: <- steal-resource:${getName(session)} ${data.color}`); + warning = stealResource(game, session, data.color); + if (warning) { + sendWarning(session, warning); + } + break; + case "discard": + console.log(`${short}: <- discard:${getName(session)}`); + warning = discard(game, session, data.discards); + if (warning) { + sendWarning(session, warning); + } + break; + case "pass": + console.log(`${short}: <- pass:${getName(session)}`); + warning = pass(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "select-resources": + console.log(`${short}: <- select-resources:${getName(session)} - `, data.cards); + warning = selectResources(game, session, data.cards); + if (warning) { + sendWarning(session, warning); + } + break; + case "buy-city": + console.log(`${short}: <- buy-city:${getName(session)}`); + warning = buyCity(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "buy-road": + console.log(`${short}: <- buy-road:${getName(session)}`); + warning = buyRoad(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "buy-settlement": + console.log(`${short}: <- buy-settlement:${getName(session)}`); + warning = buySettlement(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "buy-development": + console.log(`${short}: <- buy-development:${getName(session)}`); + warning = buyDevelopment(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "play-card": + console.log(`${short}: <- play-card:${getName(session)}`); + warning = playCard(game, session, data.card); + if (warning) { + sendWarning(session, warning); + } + break; + case "trade": + console.log( + `${short}: <- trade:${getName(session)} - ` + (data.action ? data.action : "start") + ` -`, + data.offer ? data.offer : "no trade yet" + ); + warning = trade(game, session, data.action, data.offer); + if (warning) { + sendWarning(session, warning); + } else { + for (let key in game.sessions) { + const tmp = game.sessions[key]; + if (tmp.player) { + sendUpdateToPlayer(game, tmp, { + private: tmp.player, + }); + } + } + sendUpdateToPlayers(game, { + turn: game.turn, + activities: game.activities, + chat: game.chat, + players: getFilteredPlayers(game), + }); + } + break; + case "turn-notice": + console.log(`${short}: <- turn-notice:${getName(session)}`); + warning = clearTimeNotice(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "clear-game": + console.log(`${short}: <- clear-game:${getName(session)}`); + warning = clearGame(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "goto-lobby": + console.log(`${short}: <- goto-lobby:${getName(session)}`); + warning = gotoLobby(game, session); + if (warning) { + sendWarning(session, warning); + } + break; + case "rules": + console.log(`${short} - <- rules:${getName(session)} - `, data.rules); + warning = setRules(game, session, data.rules); + if (warning) { + sendWarning(session, warning); + } + break; + default: + console.warn(`Unsupported request: ${data.type}`); + processed = false; + break; } /* If action was taken, persist the game */ @@ -4599,12 +4716,11 @@ router.ws("/ws/:id", async (ws, req) => { } /* If the current player took an action, reset the session timer */ - if (processed && session.color === game.turn.color && game.state !== 'winner') { + if (processed && session.color === game.turn.color && game.state !== "winner") { resetTurnTimer(game, session); - } + } }); - /* This will result in the node tick moving forward; if we haven't already * setup the event handlers, a 'message' could come through prior to this * completing */ @@ -4614,7 +4730,12 @@ router.ws("/ws/:id", async (ws, req) => { return; } - const session = getSession(game, req.cookies.player); + const _session2 = getSession( + game, + req.cookies && (req.cookies as any)["player"] ? String((req.cookies as any)["player"]) : "" + ); + if (!_session2) return; + const session = _session2; session.ws = ws; if (session.player) { session.player.live = true; @@ -4635,12 +4756,12 @@ router.ws("/ws/:id", async (ws, req) => { if (session.name) { sendUpdateToPlayers(game, { players: getFilteredPlayers(game), - unselected: getFilteredUnselected(game) + unselected: getFilteredUnselected(game), }); } - + /* If the current turn player just rejoined, set their turn timer */ - if (game.turn && game.turn.color === session.color && game.state !== 'winner') { + if (game.turn && game.turn.color === session.color && game.state !== "winner") { resetTurnTimer(game, session); } @@ -4662,30 +4783,35 @@ router.ws("/ws/:id", async (ws, req) => { ping(session); } else { clearTimeout(session.keepAlive); - session.keepAlive = setTimeout(() => { ping(session); }, 2500); + session.keepAlive = setTimeout(() => { + ping(session); + }, 2500); } }); -const debugChat = (game, preamble) => { +const debugChat = (game: any, preamble: any) => { preamble = `Degug ${preamble.trim()}`; let playerInventory = preamble; for (let key in game.players) { const player = game.players[key]; - if (player.status === 'Not active') { + if (player.status === "Not active") { continue; } - if (playerInventory !== '') { - playerInventory += ' player'; + if (playerInventory !== "") { + playerInventory += " player"; } else { - playerInventory += ' Player' + playerInventory += " Player"; } playerInventory += ` ${player.name} has `; - const has = [ 'wheat', 'brick', 'sheep', 'stone', 'wood' ].map(resource => { - const count = player[resource] ? player[resource] : 0; - return `${count} ${resource}`; - }).filter(item => item !== '').join(', '); + const has = ["wheat", "brick", "sheep", "stone", "wood"] + .map((resource) => { + const count = player[resource] ? player[resource] : 0; + return `${count} ${resource}`; + }) + .filter((item) => item !== "") + .join(", "); if (has) { playerInventory += `${has}, `; } else { @@ -4693,19 +4819,18 @@ const debugChat = (game, preamble) => { } } if (game.debug) { - addChatMessage(game, null, playerInventory.replace(/, $/, '').trim()); + addChatMessage(game, null, playerInventory.replace(/, $/, "").trim()); } else { - console.log(playerInventory.replace(/, $/, '').trim()); + console.log(playerInventory.replace(/, $/, "").trim()); } -} +}; -const getFilteredGameForPlayer = (game, session) => { - - /* Shallow copy game, filling its sessions with a shallow copy of +const getFilteredGameForPlayer = (game: any, session: any) => { + /* Shallow copy game, filling its sessions with a shallow copy of * sessions so we can then delete the player field from them */ const reducedGame = Object.assign({}, game, { sessions: {} }), reducedSessions = []; - + for (let id in game.sessions) { // Make a shallow copy and then scrub any fields that are private, // non-serializable (timers, sockets), or internal (prefixed with '_'). @@ -4713,33 +4838,37 @@ const getFilteredGameForPlayer = (game, session) => { const reduced = Object.assign({}, original); // Remove obvious non-serializable fields - if ('player' in reduced) delete reduced.player; - if ('ws' in reduced) delete reduced.ws; - if ('keepAlive' in reduced) delete reduced.keepAlive; + if ("player" in reduced) delete reduced.player; + if ("ws" in reduced) delete reduced.ws; + if ("keepAlive" in reduced) delete reduced.keepAlive; // Remove internal helper fields (e.g. _pendingTimeout) and functions Object.keys(reduced).forEach((k) => { try { - if (k.startsWith('_')) { + if (k.startsWith("_")) { delete reduced[k]; - } else if (typeof reduced[k] === 'function') { + } else if (typeof reduced[k] === "function") { delete reduced[k]; } else { // Remove values that are likely to be non-serializable objects // such as Timers that may appear on some runtime fields. const v = reduced[k]; - if (typeof v === 'object' && v !== null) { + if (typeof v === "object" && v !== null) { // A quick heuristic: if the object has constructor name 'Timeout' or // properties typical of timer internals, drop it to avoid circular refs. - const ctor = v.constructor && v.constructor.name ? v.constructor.name : ''; - if (ctor === 'Timeout' || ctor === 'TimersList') { + const ctor = v.constructor && v.constructor.name ? v.constructor.name : ""; + if (ctor === "Timeout" || ctor === "TimersList") { delete reduced[k]; } } } } catch (e) { // Defensive: if introspection fails, delete the key to be safe - try { delete reduced[k]; } catch (err) { /* ignore */ } + try { + delete reduced[k]; + } catch (err) { + /* ignore */ + } } }); @@ -4763,28 +4892,30 @@ const getFilteredGameForPlayer = (game, session) => { status: session.error ? session.error : "success", name: session.name, color: session.color, - order: (session.color in game.players) ? game.players[session.color].order : 0, + order: session.color in game.players ? game.players[session.color].order : 0, private: player, sessions: reducedSessions, layout: layout, players: getFilteredPlayers(game), }); -} +}; /** * Send a consolidated initial snapshot to a single session. * This is used to allow clients (and tests) to render the full * game state deterministically on first attach instead of having - * to wait for many incremental `game-update` messages. + * to wait for a flurry of incremental game-update events. */ -const sendInitialGameSnapshot = (game, session) => { +const sendInitialGameSnapshot = (game: any, session: any) => { try { const snapshot = getFilteredGameForPlayer(game, session); - const message = JSON.stringify({ type: 'initial-game', snapshot }); + const message = JSON.stringify({ type: "initial-game", snapshot }); // Small debug log to help test harnesses detect that the server sent // the consolidated snapshot. Keep output small to avoid noisy logs. try { - const topKeys = Object.keys(snapshot || {}).slice(0, 10).join(','); + const topKeys = Object.keys(snapshot || {}) + .slice(0, 10) + .join(","); console.log(`${session.id}: sending initial-game snapshot keys: ${topKeys}`); } catch (e) { /* ignore logging errors */ @@ -4797,7 +4928,7 @@ const sendInitialGameSnapshot = (game, session) => { } catch (err) { console.error(`${session.id}: error in sendInitialGameSnapshot`, err); } -} +}; /* Example: "stolen": { @@ -4834,21 +4965,21 @@ const sendInitialGameSnapshot = (game, session) => { } } */ -const trackTheft = (game, from, to, type, count) => { +const trackTheft = (game: any, from: any, to: any, type: any, count: any) => { const stats = game.stolen; /* Initialize the stole / stolen structures */ - [ to, from ].forEach(player => { + [to, from].forEach((player) => { if (!(player in stats)) { stats[player] = { - stole: { /* the resources this player stole */ - total: 0 + stole: { + /* the resources this player stole */ total: 0, + }, + stolen: { + /* the resources stolen from this player */ total: 0, + player: 0 /* by players */, + robber: 0 /* by robber */, }, - stolen: { /* the resources stolen from this player */ - total: 0, - player: 0, /* by players */ - robber: 0 /* by robber */ - } }; } }); @@ -4863,7 +4994,7 @@ const trackTheft = (game, from, to, type, count) => { /* Update counts */ stats[from].stolen.total += count; - if (to === 'robber') { + if (to === "robber") { stats[from].stolen.robber += count; } else { stats[from].stolen.player += count; @@ -4871,14 +5002,14 @@ const trackTheft = (game, from, to, type, count) => { stats[from].stolen[type] += count; stats[to].stole.total += count; stats[to].stole[type] += count; -} +}; -const resetGame = (game) => { +const resetGame = (game: any) => { Object.assign(game, { startTime: Date.now(), - state: 'lobby', + state: "lobby", turns: 0, - step: 0, /* used for the suffix # in game backups */ + step: 0 /* used for the suffix # in game backups */, turn: {}, sheep: 19, ore: 19, @@ -4887,7 +5018,7 @@ const resetGame = (game) => { wheat: 19, placements: { corners: [], - roads: [] + roads: [], }, developmentCards: [], chat: [], @@ -4900,42 +5031,42 @@ const resetGame = (game) => { stolen: { robber: { stole: { - total: 0 - } + total: 0, + }, }, - total: 0 + total: 0, }, - longestRoad: '', + longestRoad: "", longestRoadLength: 0, - largestArmy: '', + largestArmy: "", largestArmySize: 0, - mostDeveloped: '', + mostDeveloped: "", mostDevelopmentCards: 0, - mostPorts: '', + mostPorts: "", mostPortCount: 0, winner: undefined, - active: 0 + active: 0, }); stopTurnTimer(game); - /* Populate the game corner and road placement data as cleared */ + /* Populate the game corner and road placement data as cleared */ for (let i = 0; i < layout.corners.length; i++) { game.placements.corners[i] = { color: undefined, - type: undefined + type: undefined, }; } for (let i = 0; i < layout.roads.length; i++) { game.placements.roads[i] = { color: undefined, - longestRoad: undefined + longestRoad: undefined, }; } /* Put the robber back on the Desert */ - for (let i = 0; i < game.pipOrder.length; i++) { + for (let i = 0; i < game.pipOrder.length; i++) { if (game.pipOrder[i] === 18) { game.robber = i; break; @@ -4945,27 +5076,29 @@ const resetGame = (game) => { /* Populate the game development cards with a fresh deck */ for (let i = 1; i <= 14; i++) { game.developmentCards.push({ - type: 'army', - card: i + type: "army", + card: i, }); } - [ 'monopoly', 'monopoly', 'road-1', 'road-2', 'year-of-plenty', 'year-of-plenty'] - .forEach(card => game.developmentCards.push({ - type: 'progress', - card: card - })); + ["monopoly", "monopoly", "road-1", "road-2", "year-of-plenty", "year-of-plenty"].forEach((card) => + game.developmentCards.push({ + type: "progress", + card: card, + }) + ); - [ 'market', 'library', 'palace', 'university'] - .forEach(card => game.developmentCards.push({ - type: 'vp', - card: card - })); + ["market", "library", "palace", "university"].forEach((card) => + game.developmentCards.push({ + type: "vp", + card: card, + }) + ); shuffleArray(game.developmentCards); - + /* Reset all player data, and add in any missing colors */ - [ 'R', 'B', 'W', 'O' ].forEach(color => { + ["R", "B", "W", "O"].forEach((color) => { if (color in game.players) { clearPlayer(game.players[color]); } else { @@ -4979,7 +5112,7 @@ const resetGame = (game) => { if (session.color) { game.active++; session.player = game.players[session.color]; - session.player.status = 'Active'; + session.player.status = "Active"; session.player.lastActive = Date.now(); session.player.live = session.live; session.player.name = session.name; @@ -4988,19 +5121,19 @@ const resetGame = (game) => { } game.animationSeeds = []; - for (let i = 0, p = 0; i < game.tileOrder.length; i++) { + for (let i = 0; i < game.tileOrder.length; i++) { game.animationSeeds.push(Math.random()); } -} +}; -const createGame = async (id) => { +const createGame = async (id: any) => { /* Look for a new game with random words that does not already exist */ while (!id) { - id = randomWords(4).join('-'); + id = randomWords(4).join("-"); try { /* If a game with this id exists in the DB, look for a new name */ if (!gameDB || !gameDB.getGameById) { - throw new Error('Game DB not available for uniqueness check'); + throw new Error("Game DB not available for uniqueness check"); } let exists = false; try { @@ -5010,7 +5143,7 @@ const createGame = async (id) => { // if DB check fails treat as non-existent and continue searching } if (exists) { - id = ''; + id = ""; } } catch (error) { break; @@ -5022,24 +5155,23 @@ const createGame = async (id) => { id: id, developmentCards: [], players: { - O: newPlayer('O'), - R: newPlayer('R'), - B: newPlayer('B'), - W: newPlayer('W') + O: newPlayer("O"), + R: newPlayer("R"), + B: newPlayer("B"), + W: newPlayer("W"), }, sessions: {}, unselected: [], - active: 0, rules: { - 'victory-points': { - points: 10 - } + "victory-points": { + points: 10, + }, }, - step: 0 /* used for the suffix # in game backups */ - }; + step: 0 /* used for the suffix # in game backups */, + }; - [ "pips", "borders", "tiles" ].forEach((field) => { - game[field] = staticData[field] + ["pips", "borders", "tiles"].forEach((field) => { + (game as any)[field] = (staticData as any)[field]; }); setBeginnerGame(game); @@ -5052,36 +5184,24 @@ const createGame = async (id) => { return game; }; -const setBeginnerGame = (game) => { +const setBeginnerGame = (game: any): void => { pickRobber(game); shuffleArray(game.developmentCards); game.borderOrder = []; for (let i = 0; i < 6; i++) { game.borderOrder.push(i); } - game.tileOrder = [ - 9, 12, 1, - 5, 16, 13, 17, - 6, 2, 0, 3, 10, - 4, 11, 7, 14, - 18, 8, 15 - ]; + game.tileOrder = [9, 12, 1, 5, 16, 13, 17, 6, 2, 0, 3, 10, 4, 11, 7, 14, 18, 8, 15]; game.robber = 9; game.animationSeeds = []; - for (let i = 0, p = 0; i < game.tileOrder.length; i++) { + for (let i = 0; i < game.tileOrder.length; i++) { game.animationSeeds.push(Math.random()); } - game.pipOrder = [ - 5, 1, 6, - 7, 2, 9, 11, - 12, 8, 18, 3, 4, - 10, 16, 13, 0, - 14, 15, 17 - ]; + game.pipOrder = [5, 1, 6, 7, 2, 9, 11, 12, 8, 18, 3, 4, 10, 16, 13, 0, 14, 15, 17]; game.signature = gameSignature(game); -} +}; -const shuffleBoard = (game) => { +const shuffleBoard = (game: any): void => { pickRobber(game); const seq = []; @@ -5104,17 +5224,17 @@ const shuffleBoard = (game) => { * 0 1 2 * 3 4 5 6 * 7 8 9 10 11 - * 12 13 14 15 - * 16 17 18 + * 12 13 14 15 + * 16 17 18 */ const order = [ - [ 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9 ], - [ 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 5, 10, 14, 13, 8, 4, 9 ], - [ 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 10, 14, 13, 8, 4, 5, 9 ], - [ 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 14, 13, 8, 4, 5, 10, 9 ], - [ 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 13, 8, 4, 5, 10, 14, 9 ], - [ 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 8, 4, 5, 10, 14, 13, 9 ] - ] + [0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9], + [2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 5, 10, 14, 13, 8, 4, 9], + [11, 15, 18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 10, 14, 13, 8, 4, 5, 9], + [18, 17, 16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 14, 13, 8, 4, 5, 10, 9], + [16, 12, 7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 13, 8, 4, 5, 10, 14, 9], + [7, 3, 0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 8, 4, 5, 10, 14, 13, 9], + ]; const sequence = order[Math.floor(Math.random() * order.length)]; game.pipOrder = []; game.animationSeeds = []; @@ -5123,8 +5243,8 @@ const shuffleBoard = (game) => { /* If the target tile is the desert (18), then set the * pip value to the robber (18) otherwise set * the target pip value to the currently incremeneting - * pip value. */ - if (game.tiles[game.tileOrder[target]].type === 'desert') { + * pip value. */ + if (game.tiles[game.tileOrder[target]].type === "desert") { game.robber = target; game.pipOrder[target] = 18; } else { @@ -5136,29 +5256,32 @@ const shuffleBoard = (game) => { shuffleArray(game.developmentCards); game.signature = gameSignature(game); -} +}; -/* Simple NO-OP to set session cookie so player-id can use it as the +/* Simple NO-OP to set session cookie so player-id can use it as the * index */ -router.get("/", (req, res/*, next*/) => { +router.get("/", (req, res /*, next*/) => { let playerId; if (!req.cookies.player) { - playerId = crypto.randomBytes(16).toString('hex'); + playerId = crypto.randomBytes(16).toString("hex"); // Determine whether this request is secure so we can set cookie flags // appropriately. In production behind TLS we want SameSite=None and // Secure so the cookie is sent on cross-site websocket connects. - const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production'; - const cookieOpts = { + const secure = + req.secure || + (req.headers && req.headers["x-forwarded-proto"] === "https") || + process.env["NODE_ENV"] === "production"; + const cookieOpts: any = { httpOnly: false, - sameSite: secure ? 'none' : 'lax', - secure: !!secure + sameSite: secure ? "none" : "lax", + secure: !!secure, }; - // Ensure cookie is scoped to the application basePath so it will be - // included on requests under the same prefix (and on the websocket - // handshake which uses the same path prefix). - cookieOpts.path = basePath || '/'; - res.cookie('player', playerId, cookieOpts); - console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`); + // Ensure cookie is scoped to the application basePath so it will be + // included on requests under the same prefix (and on the websocket + // handshake which uses the same path prefix). + cookieOpts.path = basePath || "/"; + res.cookie("player", playerId, cookieOpts as any); + console.log(`[${playerId.substring(0, 8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`); } else { playerId = req.cookies.player; } @@ -5166,39 +5289,41 @@ router.get("/", (req, res/*, next*/) => { console.log(`[${playerId.substring(0, 8)}]: Browser hand-shake achieved.`); // Mark this response as coming from the backend API to aid debugging - res.setHeader('X-Backend', 'games'); + res.setHeader("X-Backend", "games"); return res.status(200).send({ player: playerId }); }); -router.post("/:id?", async (req, res/*, next*/) => { +router.post("/:id?", async (req, res /*, next*/) => { const { id } = req.params; let playerId; if (!req.cookies.player) { - playerId = crypto.randomBytes(16).toString('hex'); - const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production'; - const cookieOpts = { + playerId = crypto.randomBytes(16).toString("hex"); + const secure = + req.secure || + (req.headers && req.headers["x-forwarded-proto"] === "https") || + process.env["NODE_ENV"] === "production"; + const cookieOpts: any = { httpOnly: false, - sameSite: secure ? 'none' : 'lax', - secure: !!secure + sameSite: secure ? "none" : "lax", + secure: !!secure, }; - cookieOpts.path = basePath || '/'; - res.cookie('player', playerId, cookieOpts); - console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`); + cookieOpts.path = basePath || "/"; + res.cookie("player", playerId, cookieOpts as any); + console.log(`[${playerId.substring(0, 8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`); } else { playerId = req.cookies.player; } if (id) { - console.log(`[${playerId.substring(0,8)}]: Attempting load of ${id}`); + console.log(`[${playerId.substring(0, 8)}]: Attempting load of ${id}`); } else { - console.log(`[${playerId.substring(0,8)}]: Creating new game.`); + console.log(`[${playerId.substring(0, 8)}]: Creating new game.`); } const game = await loadGame(id); /* will create game if it doesn't exist */ - console.log(`[${playerId.substring(0,8)}]: ${game.id} loaded.`); + console.log(`[${playerId.substring(0, 8)}]: ${game.id} loaded.`); return res.status(200).send({ id: game.id }); }); - export default router; diff --git a/server/routes/games/constants.ts b/server/routes/games/constants.ts new file mode 100644 index 0000000..8b5e12c --- /dev/null +++ b/server/routes/games/constants.ts @@ -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; diff --git a/server/routes/games/types.ts b/server/routes/games/types.ts index b24ca3a..09ccd1d 100644 --- a/server/routes/games/types.ts +++ b/server/routes/games/types.ts @@ -1,25 +1,132 @@ +export type ResourceKey = "wood" | "brick" | "sheep" | "wheat" | "stone"; + +export type ResourceMap = Partial> & { [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 Game { - id?: string | number; - placements?: any; - rules?: any; - state?: string; - robber?: number; - players?: Player[]; +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; + 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 | number; + 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; 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; + sessions: Record; + 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; }