1
0

Full refactor to TypeScript

This commit is contained in:
James Ketr 2025-09-23 12:30:33 -07:00
parent b553cdc656
commit b87d400bf7
42 changed files with 6249 additions and 104 deletions

View File

@ -1,4 +0,0 @@
{
"presets": [ "@babel/env", "@babel/preset-react" ],
"plugins": [ "@babel/plugin-proposal-class-properties" ]
}

52
client/.eslintrc.js Normal file
View File

@ -0,0 +1,52 @@
module.exports = {
parser: '@typescript-eslint/parser',
env: {
browser: true,
node: true,
es2021: true,
jest: true
},
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
settings: {
react: {
version: 'detect',
},
},
plugins: ['@typescript-eslint', 'react'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended'
],
rules: {
'react/prop-types': 'off',
// During incremental migration we relax some rules so legacy JS/TS files
// don't block CI. We'll re-enable stricter rules as files are converted.
'no-undef': 'off',
'no-console': 'off',
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-extra-semi': 'off',
'@typescript-eslint/no-empty-function': 'off'
}
};
// During incremental migration we allow legacy .js files more leeway.
// Disable some TypeScript-specific and strict React rules for .js files
// so the production build isn't blocked while we convert sources.
module.exports.overrides = [
{
files: ["**/*.js"],
rules: {
"@typescript-eslint/no-extra-semi": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-loss-of-precision": "off",
"react/no-unescaped-entities": "off"
}
}
];

View File

@ -1,38 +1,51 @@
{
"name": "peddlers-of-ketran",
"version": "0.1.0",
"name": "peddlers-client",
"version": "1.0.0",
"private": true,
"proxy": "http://localhost:8930",
"proxy": "http://peddlers-of-ketran:8930",
"dependencies": {
"@emotion/react": "^11.8.1",
"@emotion/styled": "^11.8.1",
"@material-ui/core": "^4.12.3",
"@material-ui/lab": "^4.0.0-alpha.60",
"@mui/icons-material": "^5.4.4",
"@mui/material": "^5.4.4",
"@mui/utils": "^5.4.4",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.13.7",
"@mui/material": "^5.13.7",
"@mui/styles": "^5.13.3",
"@mui/utils": "^5.13.7",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"ajv": "^8.12.0",
"fast-deep-equal": "^3.1.3",
"http-proxy-middleware": "^2.0.3",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-moment": "^1.1.1",
"react-movable": "^3.0.4",
"react-moveable": "^0.31.1",
"react-router-dom": "^6.2.1",
"react-scripts": "5.0.0",
"react-router-dom": "^6.14.1",
"react-scripts": "5.0.1",
"socket.io-client": "^4.4.1",
"web-vitals": "^2.1.2"
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "HTTPS=true react-scripts start",
"build": "export $(cat ../.env | xargs) && react-scripts build",
"test": "export $(cat ../.env | xargs) && react-scripts test",
"eject": "export $(cat ../.env | xargs) && react-scripts eject"
"eject": "export $(cat ../.env | xargs) && react-scripts eject",
"type-check": "tsc --project tsconfig.json --noEmit",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}' --max-warnings=0",
"lint:fix": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix"
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": [
"npm run lint:fix"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"eslintConfig": {
"extends": [
@ -51,5 +64,10 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react": "^18.2.22",
"@types/react-dom": "^18.2.7",
"typescript": "^5.3.3"
}
}

337
client/src/Actions.tsx Normal file
View File

@ -0,0 +1,337 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import equal from "fast-deep-equal";
import "./Actions.css";
import { PlayerName } from "./PlayerName";
import { GlobalContext } from "./GlobalContext";
type LocalGlobalContext = {
ws?: WebSocket | null;
gameId?: string | null;
name?: string | undefined;
};
type PrivateData = {
orderRoll?: boolean;
resources?: number;
mustDiscard?: number;
};
type TurnData = {
roll?: number;
color?: string;
active?: string;
actions?: string[];
select?: unknown;
robberInAction?: boolean;
};
type PlayerData = {
live?: boolean;
};
type ActionsProps = {
tradeActive: boolean;
setTradeActive: (b: boolean) => void;
buildActive: boolean;
setBuildActive: (b: boolean) => void;
houseRulesActive: boolean;
setHouseRulesActive: (b: boolean) => void;
};
const Actions: React.FC<ActionsProps> = ({
tradeActive,
setTradeActive,
buildActive,
setBuildActive,
houseRulesActive,
setHouseRulesActive,
}) => {
const ctx = useContext(GlobalContext) as LocalGlobalContext;
const ws = ctx.ws ?? null;
const gameId = ctx.gameId ?? null;
const name = ctx.name ?? undefined;
const [state, setState] = useState<string>("lobby");
const [color, setColor] = useState<string | undefined>(undefined);
const [priv, setPriv] = useState<PrivateData | undefined>(undefined);
const [turn, setTurn] = useState<TurnData>({});
const [edit, setEdit] = useState<string | undefined>(name);
const [active, setActive] = useState<number>(0);
const [players, setPlayers] = useState<Record<string, PlayerData>>({});
const [alive, setAlive] = useState<number>(0);
const fields = useMemo(() => ["state", "turn", "private", "active", "color", "players"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`actions - game update`, data.update);
if ("private" in data.update && !equal(data.update.private, priv)) {
setPriv(data.update.private);
}
if ("state" in data.update && data.update.state !== state) {
setState(data.update.state);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
if ("name" in data.update && data.update.name !== edit) {
setEdit(data.update.name);
}
if ("turn" in data.update && !equal(data.update.turn, turn)) {
setTurn(data.update.turn);
}
if ("active" in data.update && data.update.active !== active) {
setActive(data.update.active);
}
if ("players" in data.update && !equal(data.update.players, players)) {
setPlayers(data.update.players);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage as EventListener);
return () => {
ws.removeEventListener("message", cbMessage as EventListener);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(JSON.stringify({ type: "get", fields }));
}, [ws, fields]);
const sendMessage = useCallback(
(data: Record<string, unknown>) => {
if (!ws) {
console.warn(`No socket`);
} else {
ws.send(JSON.stringify(data));
}
},
[ws]
);
const buildClicked = () => {
setBuildActive(!buildActive);
};
useEffect(() => {
let count = 0;
for (const key in players) {
// players entries are dynamic; guard access
const p = players[key];
if (p && p.live) {
count++;
}
}
setAlive(count);
}, [players, setAlive]);
const setName = (update: string) => {
if (update !== name) {
sendMessage({ type: "player-name", name: update });
}
setEdit(name);
if (buildActive) setBuildActive(false);
};
const changeNameClick = () => {
setEdit("");
if (buildActive) setBuildActive(false);
};
const discardClick = () => {
const nodes = document.querySelectorAll(".Hand .Resource.Selected");
const discards: Record<string, number> = { wheat: 0, brick: 0, sheep: 0, stone: 0, wood: 0 };
for (let i = 0; i < nodes.length; i++) {
const t = nodes[i].getAttribute("data-type") || "";
discards[t] = (discards[t] || 0) + 1;
nodes[i].classList.remove("Selected");
}
sendMessage({ type: "discard", discards });
if (buildActive) setBuildActive(false);
};
const newTableClick = () => {
sendMessage({ type: "shuffle" });
if (buildActive) setBuildActive(false);
};
const tradeClick = () => {
if (!tradeActive) {
setTradeActive(true);
sendMessage({ type: "trade" });
} else {
setTradeActive(false);
sendMessage({ type: "trade", action: "cancel", offer: undefined });
}
if (buildActive) setBuildActive(false);
};
const rollClick = () => {
sendMessage({ type: "roll" });
if (buildActive) setBuildActive(false);
};
const passClick = () => {
sendMessage({ type: "pass" });
if (buildActive) setBuildActive(false);
};
const houseRulesClick = () => {
setHouseRulesActive(!houseRulesActive);
};
const startClick = () => {
sendMessage({ type: "set", field: "state", value: "game-order" });
if (buildActive) setBuildActive(false);
};
const resetGame = () => {
sendMessage({ type: "clear-game" });
if (buildActive) setBuildActive(false);
};
if (!gameId) {
return <Paper className="Actions" />;
}
const inLobby = state === "lobby",
inGame = state === "normal",
inGameOrder = state === "game-order",
hasGameOrderRolled = priv && priv.orderRoll ? true : false,
hasRolled = turn && turn.roll ? true : false,
isTurn = turn && turn.color === color ? true : false,
robberActions = turn && turn.robberInAction,
haveResources = priv ? priv.resources !== 0 : false,
volcanoActive = state === "volcano",
placement = state === "initial-placement" || (turn && turn.active === "road-building"),
placeRoad =
placement &&
turn &&
turn.actions &&
(turn.actions.indexOf("place-road") !== -1 ||
turn.actions.indexOf("place-city") !== -1 ||
turn.actions.indexOf("place-settlement") !== -1);
if (tradeActive && (!turn || !turn.actions || turn.actions.indexOf("trade"))) {
setTradeActive(false);
} else if (!tradeActive && turn && turn.actions && turn.actions.indexOf("trade") !== -1) {
setTradeActive(true);
}
let disableRoll = false;
if (robberActions) {
disableRoll = true;
}
if (turn && turn.select) {
disableRoll = true;
}
if (inGame && !isTurn) {
disableRoll = true;
}
if (inGame && hasRolled) {
disableRoll = true;
}
if (volcanoActive && (!isTurn || hasRolled)) {
disableRoll = true;
}
if (volcanoActive && isTurn && turn && !turn.select) {
disableRoll = false;
}
if (inGameOrder && hasGameOrderRolled) {
disableRoll = true;
}
if (placement) {
disableRoll = true;
}
console.log("actions - ", {
disableRoll,
robberActions,
turn,
inGame,
isTurn,
hasRolled,
volcanoActive,
inGameOrder,
hasGameOrderRolled,
});
const disableDone = volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled;
return (
<Paper className="Actions">
{edit === "" && <PlayerName name={name} setName={setName} />}
<div className="Buttons">
{name && alive === 1 && <Button onClick={resetGame}>Reset game</Button>}
{name && inLobby && (
<>
<Button disabled={color && active >= 2 ? false : true} onClick={startClick}>
Start game
</Button>
<Button disabled={color ? false : true} onClick={newTableClick}>
New table
</Button>
</>
)}
{name && !color && (
<Button disabled={color ? true : false} onClick={changeNameClick}>
Change name
</Button>
)}
{name && color && inLobby && (
<Button disabled={color ? false : true} onClick={houseRulesClick}>
House Rules
</Button>
)}
{name && !inLobby && (
<>
<Button disabled={disableRoll} onClick={rollClick}>
Roll Dice
</Button>
<Button
disabled={volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled || !haveResources}
onClick={tradeClick}
>
Trade
</Button>
<Button
disabled={volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled || !haveResources}
onClick={buildClicked}
>
Build
</Button>
<Button disabled={!(turn && turn.roll === 7 && priv && priv.mustDiscard > 0)} onClick={discardClick}>
Discard
</Button>
{name && color && (
<Button disabled={color ? false : true} onClick={houseRulesClick}>
House Rules
</Button>
)}
<Button disabled={disableDone} onClick={passClick}>
Done
</Button>
</>
)}
</div>
</Paper>
);
};
export { Actions };

295
client/src/Activities.tsx Normal file
View File

@ -0,0 +1,295 @@
import React, { useState, useContext, useMemo, useEffect, useRef } from "react";
import equal from "fast-deep-equal";
import "./Activities.css";
import { PlayerColor } from "./PlayerColor";
import { Dice } from "./Dice";
import { GlobalContext } from "./GlobalContext";
type ActivityData = {
message: string;
color: string;
date: number;
};
type PlayerData = {
name: string;
mustDiscard?: number;
};
type TurnData = {
color?: string;
name?: string;
placedRobber?: boolean;
robberInAction?: boolean;
active?: string;
actions?: string[];
select?: Record<string, unknown>;
};
type ActivityProps = {
keep: boolean;
activity: ActivityData;
};
const Activity: React.FC<ActivityProps> = ({ keep, activity }) => {
const [animation, setAnimation] = useState("open");
const [display, setDisplay] = useState(true);
const hide = async (ms) => {
await new Promise((r) => setTimeout(r, ms));
setAnimation("close");
await new Promise((r) => setTimeout(r, 1000));
setDisplay(false);
};
if (display && !keep) {
setTimeout(() => {
hide(10000);
}, 0);
}
let message;
/* If the date is in the future, set it to now */
const dice = activity.message.match(/^(.*rolled )([1-6])(, ([1-6]))?(.*)$/);
if (dice) {
if (dice[4]) {
const sum = parseInt(dice[2]) + parseInt(dice[4]);
message = (
<>
{dice[1]}
<b>{sum}</b>: <Dice pips={dice[2]} />, <Dice pips={dice[4]} />
{dice[5]}
</>
);
} else {
message = (
<>
{dice[1]}
<Dice pips={dice[2]} />
{dice[5]}
</>
);
}
} else {
message = activity.message;
}
return (
<>
{display && (
<div className={`Activity ${animation}`}>
<PlayerColor color={activity.color} />
{message}
</div>
)}
</>
);
};
const Activities: React.FC = () => {
const { ws } = useContext(GlobalContext);
const [activities, setActivities] = useState<ActivityData[]>([]);
const [turn, setTurn] = useState<TurnData | undefined>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const [players, setPlayers] = useState<Record<string, PlayerData>>({});
const [timestamp, setTimestamp] = useState<number>(0);
const [state, setState] = useState<string>("");
const fields = useMemo(() => ["activities", "turn", "players", "timestamp", "color", "state"], []);
const requestUpdate = (fields: string | string[]) => {
let request: string[];
if (!Array.isArray(fields)) {
request = [fields];
} else {
request = fields;
}
ws?.send(
JSON.stringify({
type: "get",
fields: request,
})
);
};
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data) as { type: string; update?: Record<string, unknown> };
switch (data.type) {
case "game-update": {
const ignoring: string[] = [],
processing: string[] = [];
if (data.update) {
for (const field in data.update) {
if (fields.indexOf(field) === -1) {
ignoring.push(field);
} else {
processing.push(field);
}
}
}
console.log(`activities - game update`, data.update);
console.log(`activities - ignoring ${ignoring.join(",")}`);
console.log(`activities - processing ${processing.join(",")}`);
if (data.update && "state" in data.update && data.update.state !== state) {
requestUpdate("turn");
setState(data.update.state as string);
}
if (data.update && "activities" in data.update && !equal(data.update.activities, activities)) {
setActivities(data.update.activities as ActivityData[]);
}
if (data.update && "turn" in data.update && !equal(data.update.turn, turn)) {
setTurn(data.update.turn as TurnData);
}
if (data.update && "players" in data.update && !equal(data.update.players, players)) {
setPlayers(data.update.players as Record<string, PlayerData>);
}
if (data.update && "timestamp" in data.update && data.update.timestamp !== timestamp) {
setTimestamp(data.update.timestamp as number);
}
if (data.update && "color" in data.update && data.update.color !== color) {
setColor(data.update.color as string);
}
break;
}
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage as EventListener);
return () => {
ws.removeEventListener("message", cbMessage as EventListener);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
if (!timestamp) {
return <></>;
}
const isTurn = turn && turn.color === color ? true : false,
normalPlay = ["initial-placement", "normal", "volcano"].indexOf(state) !== -1,
mustPlaceRobber = turn && !turn.placedRobber && turn.robberInAction,
placement = state === "initial-placement" || (turn && turn.active === "road-building"),
placeRoad = placement && turn && turn.actions && turn.actions.indexOf("place-road") !== -1,
mustStealResource = turn && turn.actions && turn.actions.indexOf("steal-resource") !== -1,
rollForVolcano = state === "volcano" && turn && !turn.select,
rollForOrder = state === "game-order",
selectResources = turn && turn.actions && turn.actions.indexOf("select-resources") !== -1;
console.log(`activities - `, state, turn, activities);
const discarders: React.ReactElement[] = [];
let mustDiscard = false;
for (const key in players) {
const player = players[key];
if (!player.mustDiscard) {
continue;
}
mustDiscard = true;
const name = color === key ? "You" : player.name;
discarders.push(
<div key={name} className="Requirement">
{name} must discard <b>{player.mustDiscard}</b> cards.
</div>
);
}
const list: React.ReactElement[] = activities
.filter((activity, index) => activities.length - 1 === index || timestamp - activity.date < 11000)
.map((activity, index, filtered) => {
return <Activity keep={filtered.length - 1 === index} key={activity.date} activity={activity} />;
});
let who: string | React.ReactElement;
if (turn && turn.select) {
const selecting: { color: string; name: string }[] = [];
for (const key in turn.select) {
selecting.push({
color: key,
name: color === key ? "You" : players[key]?.name || "",
});
}
who = (
<>
{selecting.map((player, index) => (
<div className="Who" key={index}>
<PlayerColor color={player.color} />
{player.name}
{index !== selecting.length - 1 ? ", " : ""}
</div>
))}
</>
);
} else {
if (isTurn) {
who = "You";
} else {
if (!turn || !turn.name) {
who = "Everyone";
} else {
who = (
<>
<PlayerColor color={turn.color} />
{turn.name}
</>
);
}
}
}
return (
<div className="Activities">
{list}
{normalPlay && !mustDiscard && mustPlaceRobber && <div className="Requirement">{who} must move the Robber.</div>}
{placement && (
<div className="Requirement">
{who} must place a {placeRoad ? "road" : "settlement"}.
</div>
)}
{mustStealResource && <div className="Requirement">{who} must select a player to steal from.</div>}
{rollForOrder && <div className="Requirement">{who} must roll for game order.</div>}
{rollForVolcano && <div className="Requirement">{who} must roll for Volcano devastation!</div>}
{selectResources && <div className="Requirement">{who} must select resources!</div>}
{normalPlay && mustDiscard && <> {discarders} </>}
{!isTurn && normalPlay && turn && (
<div>
It is <PlayerColor color={turn.color} /> {turn.name}
{"'"}s turn.
</div>
)}
{isTurn && normalPlay && turn && (
<div className="Go">
<PlayerColor color={turn.color} /> It is your turn.
</div>
)}
</div>
);
};
export { Activities };

579
client/src/App.tsx Executable file
View File

@ -0,0 +1,579 @@
import React, { useState, useCallback, useEffect, useRef } from "react";
import { BrowserRouter as Router, Route, Routes, useParams, useNavigate } from "react-router-dom";
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, gamesPath } from "./Common";
import { GameOrder } from "./GameOrder";
import { Activities } from "./Activities";
import { SelectPlayer } from "./SelectPlayer";
import { PlayersStatus } from "./PlayersStatus";
import { ViewCard } from "./ViewCard";
import { ChooseCard } from "./ChooseCard";
import { Hand } from "./Hand";
import { Trade } from "./Trade";
import { Winner } from "./Winner";
import { HouseRules } from "./HouseRules";
import { Dice } from "./Dice";
import { assetsPath } from "./Common";
// history replaced by react-router's useNavigate
import "./App.css";
import equal from "fast-deep-equal";
type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean };
const audioEffects: Record<string, AudioEffect | undefined> = {};
const loadAudio = (src: string) => {
const audio = document.createElement("audio") as AudioEffect;
audio.src = `${assetsPath}/${src}`;
audio.setAttribute("preload", "auto");
audio.setAttribute("controls", "none");
audio.style.display = "none";
document.body.appendChild(audio);
void audio.play();
audio.hasPlayed = true;
return audio;
};
const Table: React.FC = () => {
const params = useParams();
const navigate = useNavigate();
const [gameId, setGameId] = useState<string | undefined>(params.gameId ? (params.gameId as string) : undefined);
const [ws, setWs] = useState<WebSocket | undefined>(undefined); /* tracks full websocket lifetime */
const [connection, setConnection] = useState<WebSocket | undefined>(undefined); /* set after ws is in OPEN */
const [retryConnection, setRetryConnection] =
useState<boolean>(true); /* set when connection should be re-established */
const [name, setName] = useState<string>("");
const [error, setError] = useState<string | undefined>(undefined);
const [warning, setWarning] = useState<string | undefined>(undefined);
const [loaded, setLoaded] = useState<boolean>(false);
type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record<string, number> };
type PrivateType = { name?: string; color?: string; turnNotice?: string };
const [dice, setDice] = useState<number[] | undefined>(undefined);
const [state, setState] = useState<string | undefined>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const [priv, setPriv] = useState<PrivateType | undefined>(undefined);
const [turn, setTurn] = useState<Turn | undefined>(undefined);
const [buildActive, setBuildActive] = useState<boolean>(false);
const [tradeActive, setTradeActive] = useState<boolean>(false);
const [cardActive, setCardActive] = useState<unknown>(undefined);
const [houseRulesActive, setHouseRulesActive] = useState<boolean>(false);
const [winnerDismissed, setWinnerDismissed] = useState<boolean>(false);
const [global, setGlobal] = useState<Record<string, unknown>>({});
const [count, setCount] = useState<number>(0);
const [audio, setAudio] = useState<boolean>(
localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false
);
const [animations, setAnimations] = useState<boolean>(
localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false
);
const [volume, setVolume] = useState<number>(
localStorage.getItem("volume") ? parseFloat(localStorage.getItem("volume") as string) : 0.5
);
const fields = ["id", "state", "color", "name", "private", "dice", "turn"];
const onWsOpen = (event: Event) => {
console.log(`ws: open`);
setError("");
setConnection(ws);
const sock = event.target as WebSocket;
sock.send(JSON.stringify({ type: "game-update" }));
sock.send(JSON.stringify({ type: "get", fields }));
};
const onWsMessage = (event: MessageEvent) => {
const 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);
}
timer = window.setTimeout(reset, 5000);
};
}, [setRetryConnection]);
const resetConnection = cbResetConnection();
if (global.ws !== connection || global.name !== name || global.gameId !== gameId) {
setGlobal({
ws: connection,
name,
gameId,
});
}
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 }));
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 }));
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.hasPlayed = true;
audioEffects.volcano.play();
}
}
} 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.hasPlayed = true;
audioEffects.yourTurn.play();
}
}
} 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.hasPlayed = true;
audioEffects.robber.play();
}
}
} 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.hasPlayed = true;
audioEffects.knights.play();
}
}
} 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 (
<GlobalContext.Provider value={global}>
{/* <PingPong/> */}
<div className="Table">
<div className="ActivitiesBox">
<Activities />
{dice && dice.length && (
<div className="DiceRoll">
{dice.length === 1 && <div>Volcano roll!</div>}
{dice.length === 2 && <div>Current roll</div>}
<div>
<Dice pips={dice[0]} />
{dice.length === 2 && <Dice pips={dice[1]} />}
</div>
</div>
)}
</div>
<div className="Game">
<div className="Dialogs">
{error && (
<div className="Dialog ErrorDialog">
<Paper className="Error">
<div>{error}</div>
<Button
onClick={() => {
setError("");
}}
>
dismiss
</Button>
</Paper>
</div>
)}
{priv && priv.turnNotice && (
<div className="Dialog TurnNoticeDialog">
<Paper className="TurnNotice">
<div>{priv.turnNotice}</div>
<Button
onClick={() => {
sendUpdate({ type: "turn-notice" });
}}
>
dismiss
</Button>
</Paper>
</div>
)}
{warning && (
<div className="Dialog WarningDialog">
<Paper className="Warning">
<div>{warning}</div>
<Button
onClick={() => {
setWarning("");
}}
>
dismiss
</Button>
</Paper>
</div>
)}
{state === "normal" && <SelectPlayer />}
{color && state === "game-order" && <GameOrder />}
{!winnerDismissed && <Winner {...{ winnerDismissed, setWinnerDismissed }} />}
{houseRulesActive && <HouseRules {...{ houseRulesActive, setHouseRulesActive }} />}
<ViewCard {...{ cardActive, setCardActive }} />
<ChooseCard />
</div>
<Board animations={animations} />
<PlayersStatus active={false} />
<PlayersStatus active={true} />
<Hand {...{ buildActive, setBuildActive, setCardActive }} />
</div>
<div className="Sidebar">
{name !== "" && volume !== undefined && (
<Paper className="Volume">
<div>Audio effects</div>{" "}
<input
type="checkbox"
id="audio"
name="audio"
defaultChecked={audio ? true : false}
onInput={() => {
const value = !audio;
localStorage.setItem("audio", JSON.stringify(value));
setAudio(value);
}}
/>
<div>Sound effects volume</div>{" "}
<input
type="range"
id="volume"
name="volume"
value={volume * 100}
min="0"
max="100"
onInput={(e) => {
const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100;
localStorage.setItem("volume", alpha.toString());
setVolume(alpha);
}}
/>
<div>Animations</div>{" "}
<input
type="checkbox"
id="animations"
name="animations"
defaultChecked={animations ? true : false}
onInput={() => {
const value = !animations;
localStorage.setItem("animations", JSON.stringify(value));
setAnimations(value);
}}
/>
</Paper>
)}
{name !== "" && <PlayerList />}
{/* Trade is an untyped JS component; assert its type to avoid `any` */}
{(() => {
const TradeComponent = Trade as unknown as React.ComponentType<{
tradeActive: boolean;
setTradeActive: (v: boolean) => void;
}>;
return <TradeComponent tradeActive={tradeActive} setTradeActive={setTradeActive} />;
})()}
{name !== "" && <Chat />}
{/* name !== "" && <VideoFeeds/> */}
{loaded && (
<Actions
{...{ buildActive, setBuildActive, tradeActive, setTradeActive, houseRulesActive, setHouseRulesActive }}
/>
)}
</div>
</div>
</GlobalContext.Provider>
);
};
const App: React.FC = () => {
const [playerId, setPlayerId] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
if (playerId) {
return;
}
window
.fetch(`${base}/api/v1/games/`, {
method: "GET",
cache: "no-cache",
credentials: "same-origin" /* include cookies */,
headers: {
"Content-Type": "application/json",
},
})
.then((res) => {
if (res.status >= 400) {
const error =
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
setError(error);
}
return res.json();
})
.then((data) => {
setPlayerId(data.player);
})
.catch(() => {});
}, [playerId, setPlayerId]);
if (!playerId) {
return <>{error}</>;
}
return (
<Router basename={base}>
<Routes>
<Route element={<Table />} path="/:gameId" />
<Route element={<Table />} path="/" />
</Routes>
</Router>
);
};
export default App;

91
client/src/Bird.tsx Normal file
View File

@ -0,0 +1,91 @@
import React, { useEffect, useState, useRef } from "react";
import { assetsPath } from "./Common";
import "./Bird.css";
const birdAngles = 12;
const frames = [0, 0, 1, 2, 3, 3, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const useAnimationFrame = (callback: (t: number) => void) => {
const requestRef = useRef<number | null>(null);
const animate = (time: number) => {
callback(time);
requestRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
};
}, []);
};
const Bird: React.FC<{ radius: number; speed: number; size: number; style?: React.CSSProperties }> = ({
radius,
speed,
size,
style,
}) => {
const [time, setTime] = useState(0);
const [angle, setAngle] = useState(Math.random() * 360);
const [rotation] = useState((Math.PI * 2 * radius) / 5);
const [direction, setDirection] = useState(Math.floor((birdAngles * (angle ? angle : 0)) / 360));
const [cell, setCell] = useState(0);
const previousTimeRef = useRef<number | undefined>();
useAnimationFrame((t) => {
if (previousTimeRef.current !== undefined) {
const deltaTime = t - previousTimeRef.current;
setTime(deltaTime);
} else {
previousTimeRef.current = t;
}
});
useEffect(() => {
const alpha = (time % speed) / speed;
const frame = Math.floor(frames.length * alpha);
const newAngle = (angle + rotation) % 360;
setAngle(newAngle);
setCell(frames[Math.floor(frame)]);
setDirection(Math.floor((birdAngles * newAngle) / 360));
}, [time, speed, rotation]);
return (
<div
className={`Bird`}
style={{
top: `${50 + 100 * radius * Math.sin((2 * Math.PI * (180 + angle)) / 360)}%`,
left: `${50 + 100 * radius * Math.cos((2 * Math.PI * (180 + angle)) / 360)}%`,
width: `${size * 64}px`,
height: `${size * 64}px`,
backgroundImage: `url(${assetsPath}/gfx/birds.png)`,
backgroundPositionX: `${(100 * direction) / 11}%`,
backgroundPositionY: `${(100 * cell) / 3}%`,
transformOrigin: `50% 50%`,
transform: `translate(-50%, -50%) rotate(${angle % 30}deg)`,
...style,
}}
/>
);
};
const Flock: React.FC<{ count: number; style?: React.CSSProperties }> = ({ count, style }) => {
const [birds, setBirds] = useState<React.ReactNode[]>([]);
useEffect(() => {
const tmp: React.ReactNode[] = [];
for (let i = 0; i < count; i++) {
const scalar = Math.random();
tmp.push(<Bird speed={2000 + 250 * scalar} size={0.2 + scalar * 0.25} radius={0.1 + scalar * 0.35} key={i} />);
}
setBirds(tmp);
}, [count]);
return (
<div className="Flock" style={style}>
{birds}
</div>
);
};
export { Bird, Flock };

1018
client/src/Board.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
import React from "react";
import "./BoardPieces.css";
import { useStyles } from "./Styles";
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
interface RoadProps {
color: string;
onClick: (type: string) => void;
}
const Road: React.FC<RoadProps> = ({ color, onClick }) => {
const classes = useStyles();
return (
<div className="Road" onClick={() => onClick("road")}>
<div className={["Shape", classes[color]].join(" ")} />
</div>
);
};
interface SettlementProps {
color: string;
onClick: (type: string) => void;
}
const Settlement: React.FC<SettlementProps> = ({ color, onClick }) => {
const classes = useStyles();
return (
<div className="Settlement" onClick={() => onClick("settlement")}>
<div className={["Shape", classes[color]].join(" ")} />
</div>
);
};
interface CityProps {
color: string;
onClick: (type: string) => void;
}
const City: React.FC<CityProps> = ({ color, onClick }) => {
const classes = useStyles();
return (
<div className="City" onClick={() => onClick("city")}>
<div className={["Shape", classes[color]].join(" ")} />
</div>
);
};
interface BoardPiecesProps {
player: any;
onClick?: (type: string) => void;
}
const BoardPieces: React.FC<BoardPiecesProps> = ({ player, onClick }) => {
if (!player) {
return <></>;
}
const color = player.color;
const roads: React.ReactElement[] = [];
for (let i = 0; i < player.roads; i++) {
roads.push(<Road onClick={onClick!} key={`road-${i}`} color={color} />);
}
const settlements: React.ReactElement[] = [];
for (let i = 0; i < player.settlements; i++) {
settlements.push(<Settlement onClick={onClick!} key={`settlement-${i}`} color={color} />);
}
const cities: React.ReactElement[] = [];
for (let i = 0; i < player.cities; i++) {
cities.push(<City onClick={onClick!} key={`city-${i}`} color={color} />);
}
return (
<div className="BoardPieces" data-active={onClick !== undefined}>
<div className="Cities">{cities}</div>
<div className="Settlements">{settlements}</div>
<div className="Roads">{roads}</div>
</div>
);
};
export { BoardPieces };

253
client/src/Chat.tsx Normal file
View File

@ -0,0 +1,253 @@
import React, { useState, useEffect, useContext, useRef, useCallback, useMemo } from "react";
import Paper from "@mui/material/Paper";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import Moment from "react-moment";
import TextField from "@mui/material/TextField";
import "moment-timezone";
import equal from "fast-deep-equal";
import "./Chat.css";
import { PlayerColor } from "./PlayerColor";
import { Resource } from "./Resource";
import { Dice } from "./Dice";
import { GlobalContext } from "./GlobalContext";
interface ChatMessage {
message: string;
date: number;
color?: string;
normalChat?: boolean;
}
const Chat: React.FC = () => {
const [lastTop, setLastTop] = useState<number>(0);
const [autoScroll, setAutoScroll] = useState<boolean>(true);
const [latest, setLatest] = useState<number>(0);
const [scrollTime, setScrollTime] = useState<number>(0);
const [chat, setChat] = useState<ChatMessage[]>([]);
const [startTime, setStartTime] = useState<number>(0);
const { ws, name } = useContext(GlobalContext);
const fields = useMemo(() => ["chat", "startTime"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`chat - game update`);
if (data.update.chat && !equal(data.update.chat, chat)) {
console.log(`chat - game update - ${data.update.chat.length} lines`);
setChat(data.update.chat);
}
if (data.update.startTime && data.update.startTime !== startTime) {
setStartTime(data.update.startTime);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const chatKeyPress = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
if (!autoScroll) {
setAutoScroll(true);
}
if (ws) {
ws.send(JSON.stringify({ type: "chat", message: (event.target as HTMLInputElement).value }));
(event.target as HTMLInputElement).value = "";
}
}
},
[ws, setAutoScroll, autoScroll]
);
const chatScroll = (event: React.UIEvent<HTMLUListElement>) => {
const chatList = event.target as HTMLUListElement,
fromBottom = Math.round(Math.abs(chatList.scrollHeight - chatList.offsetHeight - chatList.scrollTop));
/* If scroll is within 20 pixels of the bottom, turn on auto-scroll */
const shouldAutoscroll = fromBottom < 20;
if (shouldAutoscroll !== autoScroll) {
setAutoScroll(shouldAutoscroll);
}
/* If the list should not auto scroll, then cache the current
* top of the list and record when we did this so we honor
* the auto-scroll for at least 500ms */
if (!shouldAutoscroll) {
const target = Math.round(chatList.scrollTop);
if (target !== lastTop) {
setLastTop(target);
setScrollTime(Date.now());
}
}
};
useEffect(() => {
const chatList = document.getElementById("ChatList") as HTMLUListElement,
currentTop = Math.round(chatList.scrollTop);
if (autoScroll) {
/* Auto-scroll to the bottom of the chat window */
const target = Math.round(chatList.scrollHeight - chatList.offsetHeight);
if (currentTop !== target) {
chatList.scrollTop = target;
}
return;
}
/* Maintain current position in scrolled view if the user hasn't
* been scrolling in the past 0.5s */
if (Date.now() - scrollTime > 500 && currentTop !== lastTop) {
chatList.scrollTop = lastTop;
}
});
const messages = chat.map((item, index) => {
let message;
/* Do not perform extra parsing on player-generated
* messages */
if (item.normalChat) {
message = <div key={`line-${index}`}>{item.message}</div>;
} else {
const punctuation = item.message.match(/(\.+$)/);
let period;
if (punctuation) {
period = punctuation[1];
} else {
period = "";
}
const lines = item.message.split(".");
message = lines
.filter((line) => line.trim() !== "")
.map((line, index) => {
/* If the date is in the future, set it to now */
const dice = line.match(/^(.*rolled )([1-6])(, ([1-6]))?(.*)$/);
if (dice) {
if (dice[4]) {
return (
<div key={`line-${index}`}>
{dice[1]}
<Dice pips={dice[2]} />,
<Dice pips={dice[4]} />
{dice[5]}
{period}
</div>
);
} else {
return (
<div key={`line-${index}`}>
{dice[1]}
<Dice pips={dice[2]} />
{dice[5]}
{period}
</div>
);
}
}
let start = line,
message;
while (start) {
const resource = start.match(/^(.*)(([0-9]+) (wood|sheep|wheat|stone|brick),?)(.*)$/);
if (resource) {
const count = resource[3] ? parseInt(resource[3]) : 1;
message = (
<>
<Resource label={true} count={count} type={resource[4]} disabled />
{resource[5]}
{message}
</>
);
start = resource[1];
} else {
message = (
<>
{start}
{message}
</>
);
start = "";
}
}
return (
<div key={`line-${index}`}>
{message}
{period}
</div>
);
});
}
return (
<ListItem key={`msg-${item.date}-${index}`} className={item.color ? "" : "System"}>
{item.color && <PlayerColor color={item.color} />}
<ListItemText
primary={message}
secondary={
item.color && <Moment fromNow trim date={item.date > Date.now() ? Date.now() : item.date} interval={1000} />
}
/>
</ListItem>
);
});
if (chat.length && chat[chat.length - 1].date !== latest) {
setLatest(chat[chat.length - 1].date);
setAutoScroll(true);
}
return (
<Paper className="Chat">
<List className="ChatList" id="ChatList" onScroll={chatScroll}>
{messages}
</List>
<TextField
className="ChatInput"
disabled={!name}
onKeyPress={chatKeyPress}
label={
startTime !== 0 && (
<>
Game duration:{" "}
<Moment tz={"Etc/GMT"} format="h:mm:ss" trim durationFromNow interval={1000} date={startTime} />
</>
)
}
variant="outlined"
/>
</Paper>
);
};
export { Chat };

165
client/src/ChooseCard.tsx Normal file
View File

@ -0,0 +1,165 @@
import React, { useState, useCallback, useEffect, useMemo, useRef, useContext } from "react";
import equal from "fast-deep-equal";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import "./ChooseCard.css";
import { Resource } from "./Resource";
import { GlobalContext } from "./GlobalContext";
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any */
const ChooseCard: React.FC = () => {
const { ws } = useContext(GlobalContext);
const [turn, setTurn] = useState<any>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const [state, setState] = useState<string | undefined>(undefined);
const [cards, setCards] = useState<string[]>([]);
const fields = useMemo(() => ["turn", "color", "state"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`choose-card - game-update: `, data.update);
if ("turn" in data.update && !equal(turn, data.update.turn)) {
setTurn(data.update.turn);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
if ("state" in data.update && data.update.state !== state) {
setState(data.update.state);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const selectResources = useCallback(() => {
if (!ws) return;
ws.send(
JSON.stringify({
type: "select-resources",
cards,
})
);
}, [ws, cards]);
let count = 0;
if (turn && turn.actions && turn.actions.indexOf("select-resources") !== -1) {
if (turn.active) {
if (turn.color === color) {
count = turn.active === "monopoly" ? 1 : 2;
}
}
if (state === "volcano") {
if (!turn.select) {
count = 0;
} else if (color && color in turn.select) {
count = turn.select[color];
} else {
count = 0;
}
}
}
const selectCard = useCallback(() => {
const selected = document.querySelectorAll(".ChooseCard .Selected");
if (selected.length > count) {
for (let i = 0; i < selected.length; i++) {
selected[i].classList.remove("Selected");
}
setCards([]);
return;
}
const tmp: string[] = [];
for (let i = 0; i < selected.length; i++) {
const type = selected[i].getAttribute("data-type");
if (type) tmp.push(type);
}
setCards(tmp);
}, [setCards, count]);
if (count === 0) {
return <></>;
}
const resources = ["wheat", "brick", "stone", "sheep", "wood"].map((type) => {
return <Resource key={type} type={type} count={count} onClick={selectCard} />;
});
let title: React.ReactElement;
switch (turn.active) {
case "monopoly":
title = (
<>
<b>Monopoly</b>! Tap the resource type you want everyone to give you!
</>
);
break;
case "year-of-plenty":
title = (
<>
<b>Year of Plenty</b>! Tap the two resources you want to receive from the bank!
</>
);
break;
case "volcano":
title = (
<>
<b>Volcano has minerals</b>! Tap the {count} resources you want to receive from the bank!
</>
);
break;
default:
title = <>Unknown card type {turn.active}.</>;
break;
}
return (
<div className="ChooseCard">
<Paper>
<div className="Title">{title}</div>
<div style={{ display: "flex", flexDirection: "row", justifyContent: "center" }}>{resources}</div>
<div className="Actions">
<Button disabled={cards.length !== count} onClick={selectResources}>
submit
</Button>
</div>
</Paper>
</div>
);
};
export { ChooseCard };

View File

@ -1,18 +0,0 @@
function debounce(fn, ms) {
let timer;
return _ => {
clearTimeout(timer)
timer = setTimeout(_ => {
timer = null
fn.apply(this, arguments)
}, ms)
};
};
const base = process.env.PUBLIC_URL;
const assetsPath = `${base}/assets`;
const gamesPath = `${base}`;
export { base, debounce, assetsPath, gamesPath };

25
client/src/Common.ts Normal file
View File

@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let timer: any = null;
return function(...args: Parameters<T>) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, ms);
} as T;
};
// Prefer an explicit API base provided via environment variable. This allows
// the client running in a container to talk to the server by docker service
// name (e.g. http://peddlers-of-ketran:8930) while still working when run on
// the host where PUBLIC_URL may be appropriate.
const envApiBase = process.env.REACT_APP_API_BASE;
const publicBase = process.env.PUBLIC_URL || '';
const base = envApiBase || publicBase;
const assetsPath = `${publicBase}/assets`;
const gamesPath = `${base}`;
export { base, debounce, assetsPath, gamesPath };

35
client/src/Dice.tsx Normal file
View File

@ -0,0 +1,35 @@
import React from "react";
import "./Dice.css";
import { assetsPath } from "./Common";
type DiceProps = {
pips: number | string;
};
const Dice: React.FC<DiceProps> = ({ pips }) => {
let name: string;
switch (pips.toString()) {
case "1":
name = "one";
break;
case "2":
name = "two";
break;
case "3":
name = "three";
break;
case "4":
name = "four";
break;
case "5":
name = "five";
break;
default:
case "6":
name = "six";
break;
}
return <img alt={name} className="Dice" src={`${assetsPath}/dice-six-faces-${name}.svg`} />;
};
export { Dice };

132
client/src/GameOrder.tsx Normal file
View File

@ -0,0 +1,132 @@
import React, { useState, useEffect, useContext, useRef, useMemo } from "react";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import equal from "fast-deep-equal";
import { Dice } from "./Dice";
import { PlayerColor } from "./PlayerColor";
import "./GameOrder.css";
import { GlobalContext } from "./GlobalContext";
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
interface PlayerItem {
name: string;
color: string;
order: number;
orderRoll: number;
orderStatus: string;
}
const GameOrder: React.FC = () => {
const { ws } = useContext(GlobalContext);
const [players, setPlayers] = useState<{ [key: string]: any }>({});
const [color, setColor] = useState<string | undefined>(undefined);
const fields = useMemo(() => ["players", "color"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`GameOrder game-update: `, data.update);
if ("players" in data.update && !equal(players, data.update.players)) {
setPlayers(data.update.players);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const sendMessage = (data: any) => {
ws!.send(JSON.stringify(data));
};
const rollClick = () => {
sendMessage({ type: "roll" });
};
let hasRolled = true;
const playerElements: PlayerItem[] = [];
for (const key in players) {
const item = players[key],
name = item.name;
if (!name) {
continue;
}
if (!item.orderRoll) {
item.orderRoll = 0;
}
if (key === color) {
hasRolled = item.orderRoll !== 0;
}
playerElements.push({ name, color: key, ...item });
}
playerElements.sort((A, B) => {
if (A.order === B.order) {
if (A.orderRoll === B.orderRoll) {
return A.name.localeCompare(B.name);
}
return B.orderRoll - A.orderRoll;
}
return B.order - A.order;
});
const playerJSX = playerElements.map((item) => (
<div className="GameOrderPlayer" key={`player-${item.color}`}>
<PlayerColor color={item.color} />
<div>{item.name}</div>
{item.orderRoll !== 0 && (
<>
rolled <Dice pips={item.orderRoll} />. {item.orderStatus}
</>
)}
{item.orderRoll === 0 && <>has not rolled yet. {item.orderStatus}</>}
</div>
));
return (
<div className="GameOrder">
<Paper>
<div className="Title">Game Order</div>
<div className="PlayerList">{playerJSX}</div>
<Button disabled={hasRolled} onClick={rollClick}>
Roll Dice
</Button>
</Paper>
</div>
);
};
export { GameOrder };

View File

@ -1,12 +0,0 @@
import { createContext } from "react";
const global = {
gameId: undefined,
ws: undefined,
name: "",
chat: []
};
const GlobalContext = createContext(global);
export { GlobalContext, global };

View File

@ -0,0 +1,19 @@
import { createContext } from 'react';
export type GlobalContextType = {
gameId?: string | undefined;
ws?: WebSocket | undefined;
name?: string;
chat?: Array<unknown>;
};
const global: GlobalContextType = {
gameId: undefined,
ws: undefined,
name: "",
chat: []
};
const GlobalContext = createContext<GlobalContextType>(global);
export { GlobalContext, global };

192
client/src/Hand.tsx Normal file
View File

@ -0,0 +1,192 @@
import React, { useContext, useState, useMemo, useRef, useEffect } from "react";
import equal from "fast-deep-equal";
import { Resource } from "./Resource";
import { Placard } from "./Placard";
import { GlobalContext } from "./GlobalContext";
import { assetsPath } from "./Common";
import "./Hand.css";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface DevelopmentProps {
type: string;
card: any;
onClick: () => void;
}
const Development: React.FC<DevelopmentProps> = ({ type, card, onClick }) => {
return (
<div
className={`Development ${card.played ? "Selected" : ""}`}
onClick={onClick}
style={{
backgroundImage: `url(${assetsPath}/gfx/card-${type}.png)`,
}}
/>
);
};
interface HandProps {
buildActive: boolean;
setBuildActive: (active: boolean) => void;
setCardActive: (card: any) => void;
}
const Hand: React.FC<HandProps> = ({ buildActive, setBuildActive, setCardActive }) => {
const { ws } = useContext(GlobalContext);
const [priv, setPriv] = useState<any>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const [turn, setTurn] = useState<any>(undefined);
const [longestRoad, setLongestRoad] = useState<string | undefined>(undefined);
const [largestArmy, setLargestArmy] = useState<string | undefined>(undefined);
const [development, setDevelopment] = useState<React.ReactElement[]>([]);
const [mostPorts, setMostPorts] = useState<string | undefined>(undefined);
const [mostDeveloped, setMostDeveloped] = useState<string | undefined>(undefined);
const [selected, setSelected] = useState<number>(0);
const fields = useMemo(
() => ["private", "turn", "color", "longestRoad", "largestArmy", "mostPorts", "mostDeveloped"],
[]
);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`hand - game-update: `, data.update);
if ("private" in data.update && !equal(priv, data.update.private)) {
setPriv(data.update.private);
}
if ("turn" in data.update && !equal(turn, data.update.turn)) {
setTurn(data.update.turn);
}
if ("color" in data.update && color !== data.update.color) {
setColor(data.update.color);
}
if ("longestRoad" in data.update && longestRoad !== data.update.longestRoad) {
setLongestRoad(data.update.longestRoad);
}
if ("largestArmy" in data.update && largestArmy !== data.update.largestArmy) {
setLargestArmy(data.update.largestArmy);
}
if ("mostDeveloped" in data.update && data.update.mostDeveloped !== mostDeveloped) {
setMostDeveloped(data.update.mostDeveloped);
}
if ("mostPorts" in data.update && data.update.mostPorts !== mostPorts) {
setMostPorts(data.update.mostPorts);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
useEffect(() => {
if (!priv) {
return;
}
const cardClicked = (card: any) => {
setCardActive(card);
};
const stacks: { [key: string]: any[] } = {};
priv.development.forEach((card: any) =>
card.type in stacks ? stacks[card.type].push(card) : (stacks[card.type] = [card])
);
const development: React.ReactElement[] = [];
for (const type in stacks) {
const cards = stacks[type]
.sort((A: any, B: any) => {
if (A.played) {
return -1;
}
if (B.played) {
return +1;
}
return B.turn - A.turn; /* Put playable cards on top */
})
.map((card: any) => (
<Development
onClick={() => cardClicked(card)}
card={card}
key={`${type}-${card.card}`}
type={`${type}-${card.card}`}
/>
));
development.push(
<div key={type} className="Stack">
{cards}
</div>
);
}
setDevelopment(development);
}, [priv, setDevelopment, setCardActive]);
useEffect(() => {
const count = document.querySelectorAll(".Hand .CardGroup .Resource.Selected");
if (count.length !== selected) {
setSelected(count.length);
}
}, [setSelected, selected, turn]);
if (!priv) {
return <></>;
}
const cardSelected = () => {
const count = document.querySelectorAll(".Hand .CardGroup .Resource.Selected");
setSelected(count.length);
};
return (
<div className="Hand">
{
<div className="CardsSelected" style={selected === 0 ? { display: "none" } : {}}>
{selected} cards selected
</div>
}
<div className="CardGroup">
<Resource type="wood" count={priv.wood} onClick={cardSelected} />
<Resource type="wheat" count={priv.wheat} onClick={cardSelected} />
<Resource type="stone" count={priv.stone} onClick={cardSelected} />
<Resource type="brick" count={priv.brick} onClick={cardSelected} />
<Resource type="sheep" count={priv.sheep} onClick={cardSelected} />
</div>
<div className="CardGroup">{development}</div>
{mostDeveloped && mostDeveloped === color && <Placard type="most-developed" />}
{mostPorts && mostPorts === color && <Placard type="port-of-call" />}
{longestRoad && longestRoad === color && <Placard type="longest-road" />}
{largestArmy && largestArmy === color && <Placard type="largest-army" />}
<Placard className="BuildCard" {...{ buildActive, setBuildActive }} disabled={!turn || !turn.roll} type={color} />
</div>
);
};
export { Hand };

378
client/src/HouseRules.tsx Normal file
View File

@ -0,0 +1,378 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import equal from "fast-deep-equal";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import Switch from "@mui/material/Switch";
import "./HouseRules.css";
import { GlobalContext } from "./GlobalContext";
import { Placard } from "./Placard";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface VolcanoProps {
ws: WebSocket | null;
rules: any;
field: string;
disabled: boolean;
}
/* Volcano based on https://www.ultraboardgames.com/catan/the-volcano.php */
const Volcano: React.FC<VolcanoProps> = ({ ws, rules, field, disabled }) => {
const init =
Math.random() > 0.5
? Math.floor(8 + Math.random() * 5) /* Do not include 7 */
: Math.floor(2 + Math.random() * 5); /* Do not include 7 */
const [number, setNumber] = useState<number>(field in rules && "number" in rules[field] ? rules[field].number : init);
const [gold, setGold] = useState<boolean>(field in rules && "gold" in rules[field] ? rules[field].gold : false);
console.log(`house-rules - ${field} - `, rules[field]);
useEffect(() => {
if (field in rules) {
setGold("gold" in rules[field] ? rules[field].gold : true);
setNumber("number" in rules[field] ? rules[field].number : init);
let update = false;
if (!("gold" in rules[field])) {
rules[field].gold = true;
update = true;
}
if (!("number" in rules[field])) {
rules[field].number = init;
update = true;
}
if (update && ws) {
ws.send(
JSON.stringify({
type: "rules",
rules: rules,
})
);
}
}
}, [rules, field, init, ws]);
const toggleGold = () => {
if (!ws) return;
rules[field].gold = !gold;
rules[field].number = number;
setGold(rules[field].gold);
ws.send(
JSON.stringify({
type: "rules",
rules: rules,
})
);
};
const update = (delta: number) => {
if (!ws) return;
let value = number + delta;
if (value < 2 || value > 12) {
return;
}
/* Number to trigger Volcano cannot be 7 */
if (value === 7) {
value = delta > 0 ? 8 : 6;
}
setNumber(value);
rules[field].gold = gold;
rules[field].number = value;
ws.send(
JSON.stringify({
type: "rules",
rules: rules,
})
);
};
return (
<div className="Volcano">
<div>
The Volcano replaces the Desert. When the Volcano erupts, roll a die to determine the direction the lava will
flow. One of the six intersections on the Volcano tile will be affected. If there is a settlement on the selected
intersection, it is destroyed!
</div>
<div>
Remove it from the board (its owner may rebuild it later). If a city is located there, it is reduced to a
settlement! Replace the city with a settlement of its owner&apos;s color. If he has no settlements remaining, the
city is destroyed instead.
</div>
<div>The presence of the Robber on the Volcano does not prevent the Volcano from erupting.</div>
<div>
Roll {number} and the Volcano erupts!
<button onClick={() => update(+1)}>up</button>&nbsp;/&nbsp;
<button onClick={() => update(-1)}> down</button>
</div>
<div className="HouseSelector">
<div>
<b>Volcanoes have gold!</b>: Volcano can produce resources when its number is rolled.
</div>
<div>
<Switch size={"small"} className="RuleSwitch" checked={gold} onChange={() => toggleGold()} {...{ disabled }} />
</div>
</div>
<div>
Volcanoes tend to be rich in valuable minerals such as gold or gems. Each settlement that is adjacent to the
Volcano when it erupts may produce any one of the five resources it&apos;s owner desires.
</div>
<div>
Each city adjacent to the Volcano may produce any two resources. This resource production is taken before the
results of the volcano eruption are resolved. Note that while the Robber can not prevent the Volcano from
erupting, he does prevent any player from producing resources from the Volcano hex if he has been placed there.
</div>
</div>
);
};
interface VictoryPointsProps {
ws: WebSocket | null;
rules: any;
field: string;
}
const VictoryPoints: React.FC<VictoryPointsProps> = ({ ws, rules, field }) => {
const minVP = 10;
const [points, setPoints] = useState<number>(rules[field].points || minVP);
console.log(`house-rules - ${field} - `, rules[field]);
if (!(field in rules)) {
rules[field] = {
points: minVP,
};
}
if (rules[field].points && rules[field].points !== points) {
setPoints(rules[field].points);
}
const update = (value: number) => {
if (!ws) return;
const points = (rules[field].points || minVP) + value;
if (points < minVP) {
return;
}
if (points !== rules[field].points) {
setPoints(points);
rules[field].points = points;
ws.send(
JSON.stringify({
type: "rules",
rules: rules,
})
);
}
};
return (
<div className="VictoryPoints">
{points} points.
<button onClick={() => update(+1)}>up</button>&nbsp;/&nbsp;
<button onClick={() => update(-1)}> down</button>
</div>
);
};
interface HouseRulesProps {
houseRulesActive: boolean;
setHouseRulesActive: React.Dispatch<React.SetStateAction<boolean>>;
}
const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive }) => {
const { ws, name } = useContext(GlobalContext);
const [rules, setRules] = useState<any>({});
const [state, setState] = useState<any>({});
const [ruleElements, setRuleElements] = useState<React.ReactElement[]>([]);
const fields = useMemo(() => ["rules"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`house-rules - game-update: `, data.update);
if ("rules" in data.update && !equal(rules, data.update.rules)) {
setRules(data.update.rules);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const dismissClicked = useCallback(() => {
if (!ws) return;
ws.send(
JSON.stringify({
type: "house-rules",
active: false,
})
);
}, [ws]);
const setRule = useCallback(
(event: React.ChangeEvent<HTMLInputElement>, key: string) => {
if (!ws) return;
const checked = event.target.checked;
console.log(`house-rules - set rule ${key} to ${checked}`);
rules[key].enabled = checked;
setRules({ ...rules });
ws.send(
JSON.stringify({
type: "rules",
rules: rules,
})
);
},
[rules, ws]
);
useEffect(() => {
const ruleList = [
{
key: "volcano",
label: "Volcano",
defaultChecked: false,
element: (
<Volcano ws={ws} rules={rules} field={"volcano"} disabled={!state["volcano"] || !state["volcano"].enabled} />
),
},
{
key: "victory-points",
label: "Victory Points",
defaultChecked: false,
element: <VictoryPoints ws={ws} rules={rules} field={"victory-points"} />,
},
{
key: "tiles-start-facing-down",
label: "Tiles Start Facing Down",
defaultChecked: false,
element: <div>Once all players have placed their initial settlements and roads, the tiles are flipped and you discover what the resources are.</div>,
},
{
key: "most-developed",
label: "Most Developed",
defaultChecked: false,
element: <Placard type="most-developed" />,
},
{
key: "most-ports",
label: "Most Ports",
defaultChecked: false,
element: <Placard type="port-of-call" />,
},
{
key: "longest-road",
label: "Longest Road",
defaultChecked: true,
element: <Placard type="longest-road" />,
},
{
key: "largest-army",
label: "Largest Army",
defaultChecked: true,
element: <Placard type="largest-army" />,
},
{
key: "slowest-turn",
label: "Why you play so slowf",
defaultChecked: false,
element: <Placard type="longest-turn" />,
},
{
key: "roll-double-roll-again",
label: "Roll double, roll again",
defaultChecked: false,
element: <div>If you roll doubles, players get those resources and then you must roll again.</div>,
},
{
key: "twelve-and-two-are-synonyms",
label: "Twelve and Two are synonyms",
defaultChecked: false,
element: <div>If you roll a twelve or two, resources are triggered for both.</div>,
},
{
key: "robin-hood-robber",
label: "Robin Hood robber",
defaultChecked: false,
element: <></>,
},
];
setRuleElements(
ruleList.map((item) => {
const defaultChecked = item.defaultChecked;
if (!(item.key in rules)) {
rules[item.key] = {
enabled: defaultChecked,
};
}
const checked = rules[item.key].enabled;
if (checked !== state[item.key]) {
setState({ ...state, [item.key]: checked });
}
return (
<div key={item.key} className="HouseSelector">
<div>
<Switch
size={"small"}
className="RuleSwitch"
checked={checked}
id={item.key}
onChange={(e) => setRule(e, item.key)}
{...{ disabled: !name }}
/>
<label htmlFor={item.key}>{item.label}</label>
</div>
{checked && item.element}
</div>
);
})
);
}, [rules, setRules, setRuleElements, state, ws, setRule, name]);
if (!houseRulesActive) {
return <></>;
}
return (
<div className="HouseRules">
<Paper>
<div className="Title">House Rules</div>
<div style={{ display: "flex", flexDirection: "column" }}>{ruleElements}</div>
<Button onClick={dismissClicked}>Close</Button>
</Paper>
</div>
);
};
export { HouseRules };

455
client/src/MediaControl.tsx Normal file
View File

@ -0,0 +1,455 @@
import React, { useState, useEffect, useRef, useCallback, useContext } from "react";
import Moveable from "react-moveable";
import "./MediaControl.css";
import VolumeOff from "@mui/icons-material/VolumeOff";
import VolumeUp from "@mui/icons-material/VolumeUp";
import MicOff from "@mui/icons-material/MicOff";
import Mic from "@mui/icons-material/Mic";
import VideocamOff from "@mui/icons-material/VideocamOff";
import Videocam from "@mui/icons-material/Videocam";
import { GlobalContext } from "./GlobalContext";
const debug = true;
/* eslint-disable */
interface VideoProps {
srcObject: MediaStream | undefined;
local?: boolean;
[key: string]: any;
}
/* Proxy object so we can pass in srcObject to <audio> */
const Video: React.FC<VideoProps> = ({ srcObject, local, ...props }) => {
const refVideo = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (!refVideo.current) {
return;
}
const ref = refVideo.current;
if (debug) console.log("media-control - video <video> bind");
ref.srcObject = srcObject;
if (local) {
ref.muted = true;
}
return () => {
if (debug) console.log("media-control - <video> unbind");
if (ref) {
ref.srcObject = undefined;
}
};
}, [srcObject, local]);
return <video ref={refVideo} {...props} />;
};
interface MediaAgentProps {
setPeers: (peers: any) => void;
}
const MediaAgent: React.FC<MediaAgentProps> = ({ setPeers }) => {
const { name, ws } = useContext(GlobalContext);
const [peers] = useState<any>({});
const [stream, setStream] = useState<MediaStream | undefined>(undefined);
const onTrack = useCallback(
(event: any) => {
const connection = event.target;
console.log("media-agent - ontrack", event);
for (let peer in peers) {
if (peers[peer].connection === connection) {
console.log(`media-agent - ontrack - remote ${peer} stream assigned.`);
/* Revive the stream for this peer */
const obj = {
...peers[peer],
dead: false,
attributes: {
...peers[peer].attributes,
srcObject: connection.streams[0],
},
};
peers[peer] = obj;
setPeers(Object.assign({}, peers));
}
}
},
[peers, setPeers]
);
const refOnTrack = useRef(onTrack);
const sendMessage = useCallback(
(data: any) => {
if (ws) {
ws.send(JSON.stringify(data));
}
},
[ws]
);
const onWsMessage = useCallback(
(event: MessageEvent) => {
const addPeer = (config: any) => {
console.log("media-agent - Signaling server said to add peer:", config);
if (!stream) {
console.log(`media-agent - No local media stream`);
return;
}
const peer_id = config.peer_id;
if (peer_id in peers) {
if (!peers[peer_id].dead) {
/* This is normal when peers are added by other connecting
* peers through the signaling server */
console.log(`media-agent - addPeer - ${peer_id} already in peers`);
return;
}
}
/* Even if reviving, allocate a new Object so <MediaControl> will
* have its peer state change and trigger an update from
* <PlayerList> */
const peer: any = {
name: peer_id,
hasAudio: config.hasAudio,
hasVideo: config.hasVideo,
attributes: {},
};
if (peer_id in peers) {
peer.muted = peers[peer_id].muted;
peer.videoOn = peers[peer_id].videoOn;
console.log(`media-agent - addPeer - reviving dead peer ${peer_id}`, peer);
} else {
peer.muted = false;
peer.videoOn = true;
}
peers[peer_id] = peer;
console.log(`media-agent - addPeer - remote`, peers);
setPeers(Object.assign({}, peers));
const connection = new RTCPeerConnection({
iceServers: [
{
urls: "turns:ketrenos.com:5349",
username: "ketra",
credential: "ketran",
},
/*
{
urls: "turn:numb.viagenie.ca",
username: "james_viagenie@ketrenos.com",
credential: "1!viagenie"
}
*/
],
});
peer.connection = connection;
connection.addEventListener("connectionstatechange", (event) => {
console.log(`media-agent - connectionstatechange - `, connection.connectionState, event);
});
connection.addEventListener("negotiationneeded", (event) => {
console.log(`media-agent - negotiationneeded - `, connection.connectionState, event);
});
connection.addEventListener("icecandidateerror", (event) => {
if (event.errorCode === 701) {
if (connection.iceGatheringState === "gathering") {
console.log(`media-agent - Unable to reach host: ${event.url}`);
} else {
console.error(`media-agent - icecandidateerror - `, event.errorCode, event.url, event.errorText);
}
}
});
connection.onicecandidate = (event) => {
if (!event.candidate) {
console.log(`media-agent - icecanditate - gathering is complete: ${connection.connectionState}`);
return;
}
/* If a srflx candidate was found, notify that the STUN server works! */
if (event.candidate.type === "srflx") {
console.log("media-agent - The STUN server is reachable!");
console.log(`media-agent - Your Public IP Address is: ${event.candidate.address}`);
}
/* If a relay candidate was found, notify that the TURN server works! */
if (event.candidate.type === "relay") {
console.log("media-agent - The TURN server is reachable !");
}
console.log(`media-agent - onicecandidate - `, event.candidate);
sendMessage({
type: "relayICECandidate",
config: {
peer_id,
candidate: event.candidate,
},
});
};
connection.ontrack = (e) => refOnTrack.current(e);
/* Add our local stream */
stream.getTracks().forEach((track) => connection.addTrack(track, stream));
/* Only one side of the peer connection should create the
* offer, the signaling server picks one to be the offerer.
* The other user will get a 'sessionDescription' event and will
* create an offer, then send back an answer 'sessionDescription'
* to us
*/
if (config.should_create_offer) {
if (debug) console.log(`media-agent - Creating RTC offer to ` + `${peer_id}`);
return connection
.createOffer()
.then((local_description) => {
if (debug) console.log(`media-agent - Local offer ` + `description is: `, local_description);
return connection
.setLocalDescription(local_description)
.then(() => {
sendMessage({
type: "relaySessionDescription",
config: {
peer_id,
session_description: local_description,
},
});
})
.catch((error) => {
console.error(`media-agent - Error creating offer: `, error);
});
})
.catch((error) => {
console.error(`media-agent - Error creating offer: `, error);
});
}
};
const removePeer = (config: any) => {
console.log("media-agent - Signaling server said to remove peer:", config);
const peer_id = config.peer_id;
if (peer_id in peers) {
peers[peer_id].dead = true;
setPeers(Object.assign({}, peers));
}
};
const sessionDescription = (config: any) => {
console.log("media-agent - Remote description received: ", config);
const peer_id = config.peer_id;
const peer = peers[peer_id];
const remote_description = config.session_description;
if (debug) console.log(`media-agent - Remote description ` + `is: `, remote_description);
return peer.connection
.setRemoteDescription(remote_description)
.then(() => {
if (remote_description.type === "offer") {
if (debug) console.log(`media-agent - Creating answer to ` + `${peer_id}`);
return peer.connection
.createAnswer()
.then((local_description) => {
if (debug) console.log(`media-agent - Local answer ` + `description is: `, local_description);
return peer.connection
.setLocalDescription(local_description)
.then(() => {
sendMessage({
type: "relaySessionDescription",
config: {
peer_id,
session_description: local_description,
},
});
})
.catch((error) => {
console.error(`media-agent - Error creating answer: `, error);
});
})
.catch((error) => {
console.error(`media-agent - Error creating answer: `, error);
});
}
})
.catch((error) => {
console.error(`media-agent - Error setting remote description: `, error);
});
};
const iceCandidate = (config: any) => {
const peer = peers[config.peer_id];
if (peer) {
peer.connection.addIceCandidate(new RTCIceCandidate(config.candidate)).catch((error) => {
console.error(`media-agent - Error adding ICE candidate: `, error);
});
}
};
const data = JSON.parse(event.data);
switch (data.type) {
case "addPeer":
addPeer(data.config);
break;
case "removePeer":
removePeer(data.config);
break;
case "sessionDescription":
sessionDescription(data.config);
break;
case "iceCandidate":
iceCandidate(data.config);
break;
default:
break;
}
},
[peers, setPeers, stream, sendMessage, refOnTrack]
);
const refOnWsMessage = useRef(onWsMessage);
useEffect(() => {
refOnWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refOnWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refOnWsMessage]);
useEffect(() => {
if (!name) {
return;
}
navigator.mediaDevices
.getUserMedia({
audio: true,
video: true,
})
.then((mediaStream) => {
console.log("media-agent - Local media stream obtained");
setStream(mediaStream);
sendMessage({
type: "join",
config: {
name,
hasAudio: true,
hasVideo: true,
},
});
})
.catch((error) => {
console.error("media-agent - Error accessing media devices.", error);
});
}, [name, sendMessage]);
return <></>;
};
interface MediaControlProps {
isSelf: boolean;
peer: any;
className?: string;
}
const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className }) => {
const [media, setMedia] = useState<any>(undefined);
const [muted, setMuted] = useState<boolean | undefined>(undefined);
const [videoOn, setVideoOn] = useState<boolean | undefined>(undefined);
const [target, setTarget] = useState<any>();
const [frame, setFrame] = useState<any>({
translate: [0, 0],
});
useEffect(() => {
if (peer && peer.name) {
setTarget(document.querySelector(`.MediaControl[data-peer="${peer.name}"]`));
}
}, [setTarget, peer]);
/* local state is used to trigger re-renders, and the global
* state is kept up to date in the peers object so re-assignment
* of sessions doesn't kill the peer or change the mute/video states */
useEffect(() => {
if (!peer) {
setMedia(undefined);
return;
}
setMuted(peer.muted);
setVideoOn(peer.videoOn);
setMedia(peer);
}, [peer, setMedia, setMuted, setVideoOn]);
console.log(`media-control - render`);
const toggleMute = (event: React.MouseEvent) => {
if (debug) console.log(`media-control - toggleMute - ${peer.name}`, !muted);
peer.muted = !muted;
setMuted(peer.muted);
event.stopPropagation();
};
const toggleVideo = (event: React.MouseEvent) => {
if (debug) console.log(`media-control - toggleVideo - ${peer.name}`, !videoOn);
peer.videoOn = !videoOn;
setVideoOn(peer.videoOn);
event.stopPropagation();
};
const onDrag = (e: any) => {
frame.translate = e.translate;
setFrame({ ...frame });
};
const onDragEnd = (e: any) => {
console.log(e);
};
if (!peer) {
return <></>;
}
const hasAudio = peer.hasAudio;
const hasVideo = peer.hasVideo;
return (
<div className={`MediaControl ${className || ""}`} data-peer={peer.name}>
<Moveable target={target} draggable={true} onDrag={onDrag} onDragEnd={onDragEnd} />
<div className="MediaControlInner">
{hasVideo && videoOn && (
<Video srcObject={peer.attributes.srcObject} autoPlay muted={isSelf} className="Video" />
)}
{hasVideo && !videoOn && (
<div className="VideoOff">
<VideocamOff />
</div>
)}
{hasAudio && muted && (
<div className="AudioOff">
<VolumeOff />
</div>
)}
{hasAudio && !muted && (
<div className="AudioOn">
<VolumeUp />
</div>
)}
<div className="Controls">
{hasAudio && <button onClick={toggleMute}>{muted ? <MicOff /> : <Mic />}</button>}
{hasVideo && <button onClick={toggleVideo}>{videoOn ? <Videocam /> : <VideocamOff />}</button>}
</div>
</div>
</div>
);
};
export { MediaControl, MediaAgent };

46
client/src/PingPong.tsx Normal file
View File

@ -0,0 +1,46 @@
import React, { useState, useContext, useEffect, useRef } from "react";
import { GlobalContext } from "./GlobalContext";
import "./PingPong.css";
const PingPong: React.FC = () => {
const [count, setCount] = useState<number>(0);
const global = useContext(GlobalContext);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data as string);
switch (data.type) {
case "ping":
if (global.ws) {
global.ws.send(JSON.stringify({ type: "pong", timestamp: data.ping }));
}
setCount(count + 1);
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!global.ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
global.ws.addEventListener("message", cbMessage);
return () => {
global.ws.removeEventListener("message", cbMessage);
};
}, [global.ws, refWsMessage]);
return (
<div className="PingPong">
Game {global.gameId}: {global.name} {global.ws ? "has socket" : "no socket"} {count} pings
</div>
);
};
export { PingPong };

105
client/src/Placard.tsx Normal file
View File

@ -0,0 +1,105 @@
import React, { useContext, useCallback } from "react";
import "./Placard.css";
import { assetsPath } from "./Common";
import { GlobalContext } from "./GlobalContext";
type PlacardProps = {
type?: string;
disabled?: boolean;
count?: number;
buildActive?: boolean;
setBuildActive?: (b: boolean) => void;
className?: string;
};
const Placard: React.FC<PlacardProps> = ({ type, disabled, count, buildActive, setBuildActive, className }) => {
const { ws } = useContext(GlobalContext);
const sendMessage = useCallback(
(data: Record<string, unknown>) => {
ws.send(JSON.stringify(data));
},
[ws]
);
const dismissClicked = () => {
setBuildActive && setBuildActive(false);
};
const buildClicked = () => {
if (!type || !type.match(/^l.*/)) {
if (!buildActive) {
setBuildActive && setBuildActive(true);
}
}
};
const roadClicked = () => {
sendMessage({ type: "buy-road" });
setBuildActive && setBuildActive(false);
};
const settlementClicked = () => {
sendMessage({ type: "buy-settlement" });
setBuildActive && setBuildActive(false);
};
const cityClicked = () => {
sendMessage({ type: "buy-city" });
setBuildActive && setBuildActive(false);
};
const developmentClicked = () => {
sendMessage({ type: "buy-development" });
setBuildActive && setBuildActive(false);
};
if (!type) return <></>;
let t = type;
if (type === "B") t = "blue";
else if (type === "O") t = "orange";
else if (type === "R") t = "red";
else if (type === "W") t = "white";
let buttons: React.ReactNode = <></>;
if (!disabled && buildActive) {
switch (t) {
case "orange":
case "red":
case "white":
case "blue":
buttons = (
<>
<div onClick={dismissClicked} />
<div onClick={roadClicked} />
<div onClick={settlementClicked} />
<div onClick={cityClicked} />
<div onClick={developmentClicked} />
<div onClick={dismissClicked} />
</>
);
break;
default:
buttons = <></>;
}
}
const style = { backgroundImage: `url(${assetsPath}/gfx/placard-${t}.png)` };
if (!disabled) {
return (
<div
className={`Placard${buildActive ? " Selected" : ""} ${className || ""}`}
onClick={buildClicked}
data-type={t}
style={style}
>
{buttons}
</div>
);
}
return (
<div className={`Placard${buildActive ? " Selected" : ""} ${className || ""}`} data-type={t} style={style}>
{count && <div className="Right">{count}</div>}
</div>
);
};
export { Placard };

View File

@ -0,0 +1,13 @@
import React from "react";
import Avatar from "@mui/material/Avatar";
import "./PlayerColor.css";
import { useStyles } from "./Styles";
type PlayerColorProps = { color?: string };
const PlayerColor: React.FC<PlayerColorProps> = ({ color }) => {
const classes = useStyles();
return <Avatar className={["PlayerColor", color ? classes[color] : ""].join(" ")} />;
};
export { PlayerColor };

189
client/src/PlayerList.tsx Normal file
View File

@ -0,0 +1,189 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import Paper from "@mui/material/Paper";
import List from "@mui/material/List";
import "./PlayerList.css";
import { PlayerColor } from "./PlayerColor";
import { MediaAgent, MediaControl } from "./MediaControl";
import { GlobalContext } from "./GlobalContext";
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
const PlayerList: React.FC = () => {
const { ws, name } = useContext(GlobalContext);
const [players, setPlayers] = useState<{ [key: string]: any }>({});
const [unselected, setUneslected] = useState<string[]>([]);
const [state, setState] = useState<string>("lobby");
const [color, setColor] = useState<string | undefined>(undefined);
const [peers, setPeers] = useState<{ [key: string]: any }>({});
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`player-list - game update`, data.update);
if ("unselected" in data.update) {
setUneslected(data.update.unselected);
}
if ("players" in data.update) {
let found = false;
for (const key in data.update.players) {
if (data.update.players[key].name === name) {
found = true;
setColor(key);
break;
}
}
if (!found) {
setColor(undefined);
}
setPlayers(data.update.players);
}
if ("state" in data.update && data.update.state !== state) {
setState(data.update.state);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields: ["state", "players", "unselected"],
})
);
}, [ws]);
const toggleSelected = (key: string) => {
ws!.send(
JSON.stringify({
type: "set",
field: "color",
value: color === key ? "" : key,
})
);
};
const playerElements: React.ReactElement[] = [];
const 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(
<div
data-selectable={selectable}
data-selected={player.color === color}
className="PlayerEntry"
onClick={() => {
inLobby && selectable && toggleSelected(player.color);
}}
key={`player-${player.color}`}
>
<div>
<PlayerColor color={player.color} />
<div className="Name">{playerName ? playerName : "Available"}</div>
{playerName && !player.live && <div className="NoNetwork"></div>}
</div>
{playerName && player.live && (
<MediaControl className={videoClass} peer={peers[playerName]} isSelf={player.color === color} />
)}
{!playerName && <div></div>}
</div>
);
});
const waiting = unselected.map((player) => {
return (
<div className={player === name ? "Self" : ""} key={player}>
<div>{player}</div>
<MediaControl className={"Small"} peer={peers[player]} isSelf={name === player} />
</div>
);
});
return (
<Paper className={`PlayerList ${videoClass}`}>
<MediaAgent setPeers={setPeers} />
<List className="PlayerSelector">{playerElements}</List>
{unselected && unselected.length !== 0 && (
<div className="Unselected">
<div>In lobby</div>
<div>{waiting}</div>
</div>
)}
</Paper>
);
};
export { PlayerList };

43
client/src/PlayerName.tsx Normal file
View File

@ -0,0 +1,43 @@
import React, { useState } from "react";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import "./PlayerName.css";
type PlayerNameProps = {
name?: string;
setName: (s: string) => void;
};
const PlayerName: React.FC<PlayerNameProps> = ({ name, setName }) => {
const [edit, setEdit] = useState<string | undefined>(name);
const sendName = () => {
setName(edit ?? "");
};
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEdit(event.target.value);
};
const nameKeyPress = (event: React.KeyboardEvent) => {
if ((event as React.KeyboardEvent<HTMLInputElement>).key === "Enter") {
setName(edit ? edit : name ?? "");
}
};
return (
<div className="PlayerName">
<TextField
className="nameInput"
onChange={nameChange}
onKeyPress={nameKeyPress}
label="Enter your name"
variant="outlined"
value={edit}
/>
<Button onClick={sendName}>Set</Button>
</div>
);
};
export { PlayerName };

View File

@ -0,0 +1,247 @@
import React, { useContext, useState, useMemo, useRef, useEffect } from "react";
import equal from "fast-deep-equal";
import "./PlayersStatus.css";
import { BoardPieces } from "./BoardPieces";
import { Resource } from "./Resource";
import { PlayerColor } from "./PlayerColor";
import { Placard } from "./Placard";
import { GlobalContext } from "./GlobalContext";
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
interface PlayerProps {
player: any;
onClick?: () => void;
reverse?: boolean;
color: string;
largestArmy: string | undefined;
isSelf?: boolean;
longestRoad: string | undefined;
mostPorts: string | undefined;
mostDeveloped: string | undefined;
}
const Player: React.FC<PlayerProps> = ({
player,
onClick,
reverse,
color,
largestArmy,
isSelf,
longestRoad,
mostPorts,
mostDeveloped,
}) => {
if (!player) {
return <>You are an observer.</>;
}
const developmentCards = player.unplayed ? (
<Resource label={true} type={"progress-back"} count={player.unplayed} disabled />
) : undefined;
const resourceCards = player.resources ? (
<Resource label={true} type={"resource-back"} count={player.resources} disabled />
) : undefined;
const armyCards = player.army ? <Resource label={true} type={"army-1"} count={player.army} disabled /> : undefined;
let points: React.ReactElement = <></>;
if (player.points && reverse) {
points = (
<>
<b>{player.points}</b>
<Resource type={"progress-back"} count={player.points} disabled />
</>
);
} else if (player.points) {
points = (
<>
<Resource type={"progress-back"} count={player.points} disabled />
<b>{player.points}</b>
</>
);
}
const mostPortsPlacard =
mostPorts && mostPorts === color ? <Placard disabled type="port-of-call" count={player.ports} /> : undefined;
const mostDevelopedPlacard =
mostDeveloped && mostDeveloped === color ? (
<Placard disabled type="most-developed" count={player.developmentCards} />
) : undefined;
const longestRoadPlacard =
longestRoad && longestRoad === color ? (
<Placard disabled type="longest-road" count={player.longestRoad} />
) : undefined;
const largestArmyPlacard =
largestArmy && largestArmy === color ? <Placard disabled type="largest-army" count={player.army} /> : undefined;
return (
<div className="Player">
<div className="Who">
<PlayerColor color={color} />
{player.name}
</div>
<div className="What">
{isSelf && <div className="LongestRoad">Longest road: {player.longestRoad ? player.longestRoad : 0}</div>}
<div className="Points">{points}</div>
{(largestArmy ||
longestRoad ||
armyCards ||
resourceCards ||
developmentCards ||
mostPorts ||
mostDeveloped) && (
<>
<div className="Has">
{!reverse && (
<>
{mostDevelopedPlacard}
{mostPortsPlacard}
{largestArmyPlacard}
{longestRoadPlacard}
{!largestArmyPlacard && armyCards}
{developmentCards}
{resourceCards}
</>
)}
{reverse && (
<>
{resourceCards}
{developmentCards}
{!largestArmyPlacard && armyCards}
{longestRoadPlacard}
{largestArmyPlacard}
{mostPortsPlacard}
{mostDevelopedPlacard}
</>
)}
</div>
</>
)}
</div>
<div className={`${onClick ? "Normal" : "Shrunken"}`}>
<BoardPieces onClick={onClick} player={player} />
</div>
</div>
);
};
interface PlayersStatusProps {
active: boolean;
}
const PlayersStatus: React.FC<PlayersStatusProps> = ({ active }) => {
const { ws } = useContext(GlobalContext);
const [players, setPlayers] = useState<any>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const [largestArmy, setLargestArmy] = useState<string | undefined>(undefined);
const [longestRoad, setLongestRoad] = useState<string | undefined>(undefined);
const [mostPorts, setMostPorts] = useState<string | undefined>(undefined);
const [mostDeveloped, setMostDeveloped] = useState<string | undefined>(undefined);
const fields = useMemo(() => ["players", "color", "longestRoad", "largestArmy", "mostPorts", "mostDeveloped"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`players-status - game-update: `, data.update);
if ("players" in data.update && !equal(players, data.update.players)) {
setPlayers(data.update.players);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
if ("longestRoad" in data.update && data.update.longestRoad !== longestRoad) {
setLongestRoad(data.update.longestRoad);
}
if ("largestArmy" in data.update && data.update.largestArmy !== largestArmy) {
setLargestArmy(data.update.largestArmy);
}
if ("mostDeveloped" in data.update && data.update.mostDeveloped !== mostDeveloped) {
setMostDeveloped(data.update.mostDeveloped);
}
if ("mostPorts" in data.update && data.update.mostPorts !== mostPorts) {
setMostPorts(data.update.mostPorts);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
if (!players) {
return <></>;
}
const buildItem = () => {
console.log(`player-status - build-item`);
};
let elements: React.ReactElement;
if (active) {
elements = (
<Player
player={players[color!]}
onClick={buildItem}
reverse
largestArmy={largestArmy}
longestRoad={longestRoad}
mostPorts={mostPorts}
mostDeveloped={mostDeveloped}
isSelf={active}
key={`PlayerStatus-${color}`}
color={color!}
/>
);
} else {
elements = (
<>
{Object.getOwnPropertyNames(players)
.filter((key) => color !== key)
.map((key) => {
return (
<Player
player={players[key]}
largestArmy={largestArmy}
longestRoad={longestRoad}
mostPorts={mostPorts}
mostDeveloped={mostDeveloped}
key={`PlayerStatus-${key}}`}
color={key}
/>
);
})}
</>
);
}
return <div className={`PlayersStatus ${active ? "ActivePlayer" : ""}`}>{elements}</div>;
};
export { PlayersStatus };

55
client/src/Resource.tsx Normal file
View File

@ -0,0 +1,55 @@
import React from "react";
import "./Resource.css";
import { assetsPath } from "./Common";
type ResourceProps = {
type: string;
disabled?: boolean;
available?: number;
count?: number;
label?: boolean;
onClick?: (e: React.MouseEvent) => void;
};
const Resource: React.FC<ResourceProps> = ({ type, disabled, available, count, label, onClick }) => {
const array = new Array(Number(count ? count : 0));
const click = (event: React.MouseEvent) => {
if (!disabled) {
(event.target as HTMLElement).classList.toggle("Selected");
}
if (onClick) onClick(event);
};
if (label) {
return (
<div
className={`Resource ${count === 0 ? "None" : ""}`}
data-type={type}
onClick={click}
style={{ backgroundImage: `url(${assetsPath}/gfx/card-${type}.png)` }}
>
{available !== undefined && <div className="Left">{available}</div>}
<div className="Right">{count}</div>
</div>
);
}
return (
<>
{array.length > 0 && (
<div className="Stack">
{React.Children.map(array, () => (
<div
className="Resource"
data-type={type}
onClick={click}
style={{ backgroundImage: `url(${assetsPath}/gfx/card-${type}.png)` }}
/>
))}
</div>
)}
</>
);
};
export { Resource };

View File

@ -0,0 +1,94 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import Paper from "@mui/material/Paper";
import equal from "fast-deep-equal";
import { PlayerColor } from "./PlayerColor";
import "./SelectPlayer.css";
import { GlobalContext } from "./GlobalContext";
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
const SelectPlayer: React.FC = () => {
const { ws } = useContext(GlobalContext);
const [turn, setTurn] = useState<any>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const fields = useMemo(() => ["turn", "color"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`select-players - game-update: `, data.update);
if ("turn" in data.update && !equal(turn, data.update.turn)) {
setTurn(data.update.turn);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const playerClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
ws!.send(
JSON.stringify({
type: "steal-resource",
color: event.currentTarget.getAttribute("data-color"),
})
);
},
[ws]
);
if (!color || !turn || turn.color !== color || !turn.limits || !turn.limits.players) {
return <></>;
}
const list = turn.limits.players.map((item: any) => (
<div className="SelectPlayerItem" onClick={playerClick} data-color={item.color} key={`player-${item.color}`}>
<PlayerColor color={item.color} />
<div>{item.name}</div>
</div>
));
return (
<div className="SelectPlayer">
<Paper>
<div className="Title">Select Player to Steal From</div>
<div className="SelectPlayerList">{list}</div>
</Paper>
</div>
);
};
export { SelectPlayer };

105
client/src/Sheep.tsx Normal file
View File

@ -0,0 +1,105 @@
import React, { useEffect, useState, useRef } from "react";
import { assetsPath } from "./Common";
import "./Sheep.css";
const sheepSteps = 12;
const useAnimationFrame = (callback: (t: number) => void) => {
const requestRef = useRef<number | null>(null);
const animate = (time: number) => {
callback(time);
requestRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
};
}, []);
};
const Sheep: React.FC<{ radius: number; speed: number; size: number; style?: React.CSSProperties }> = ({
radius,
speed,
size,
style,
}) => {
const [time, setTime] = useState(0);
const [direction, setDirection] = useState(Math.random() * 2 * Math.PI);
const [y, setY] = useState((Math.random() - 0.5) * radius);
const [frame, setFrame] = useState(0);
const [x, setX] = useState((Math.random() - 0.5) * radius);
const previousTimeRef = useRef<number | undefined>();
useAnimationFrame((t) => {
if (previousTimeRef.current !== undefined) {
const deltaTime = t - previousTimeRef.current;
previousTimeRef.current = t;
setTime(deltaTime);
} else {
previousTimeRef.current = t;
}
});
useEffect(() => {
let alpha = time / speed;
const sheepSpeed = 0.05;
if (alpha > 1.0) alpha = 0.1;
let newX = x + sheepSpeed * Math.sin(direction) * alpha;
let newY = y + sheepSpeed * Math.cos(direction) * alpha;
if (Math.sqrt(newX * newX + newY * newY) > Math.sqrt(radius * radius)) {
let newDirection = direction + Math.PI + 0.5 * (Math.random() - 0.5) * Math.PI;
while (newDirection >= 2 * Math.PI) newDirection -= 2 * Math.PI;
while (newDirection <= -2 * Math.PI) newDirection += 2 * Math.PI;
setDirection(newDirection);
newX += sheepSpeed * Math.sin(newDirection) * alpha;
newY += sheepSpeed * Math.cos(newDirection) * alpha;
}
setX(newX);
setY(newY);
setFrame(frame + sheepSteps * alpha);
}, [time, speed]);
const cell = Math.floor(frame) % sheepSteps;
return (
<div
className={`Sheep`}
style={{
zIndex: `${Math.ceil(50 * y)}`,
top: `${Math.floor(50 + 50 * y)}%`,
left: `${Math.floor(50 + 50 * x)}%`,
width: `${size * 60}px`,
height: `${size * 52}px`,
backgroundRepeat: "no-repeat",
backgroundImage: `url(${assetsPath}/gfx/sheep.png)`,
backgroundPositionX: `${(100.0 * cell) / (sheepSteps - 1)}%`,
transformOrigin: `50% 50%`,
transform: `translate(-50%, -50%) scale(${Math.sin(direction) > 0 ? +1 : -1}, 1)`,
...style,
}}
/>
);
};
const Herd: React.FC<{ count: number; style?: React.CSSProperties }> = ({ count, style }) => {
const [sheep, setSheep] = useState<React.ReactNode[]>([]);
useEffect(() => {
const tmp: React.ReactNode[] = [];
for (let i = 0; i < count; i++) {
const scalar = Math.random();
tmp.push(<Sheep speed={1000 + 500 * scalar} size={0.25} radius={0.8} key={i} />);
}
setSheep(tmp);
}, [count]);
return (
<div className="Herd" style={style}>
{sheep}
</div>
);
};
export { Sheep, Herd };

View File

@ -1,7 +1,9 @@
import { makeStyles } from '@material-ui/core/styles';
import { orange,lightBlue, red, grey } from '@material-ui/core/colors';
import { makeStyles } from '@mui/styles';
import { orange, lightBlue, red, grey } from '@mui/material/colors';
const useStyles = makeStyles((theme) => ({
/* eslint-disable @typescript-eslint/no-explicit-any */
const useStyles = makeStyles((theme: any) => ({
root: {
display: 'flex',
'& > *': {
@ -26,5 +28,4 @@ const useStyles = makeStyles((theme) => ({
},
}));
export { useStyles };

640
client/src/Trade.tsx Normal file
View File

@ -0,0 +1,640 @@
import React, { useState, useCallback, useEffect, useContext, useMemo, useRef } from "react";
import equal from "fast-deep-equal";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import { Resource } from "./Resource";
import { PlayerColor } from "./PlayerColor";
import { GlobalContext } from "./GlobalContext";
import "./Trade.css";
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
interface Resources {
wheat: number;
brick: number;
wood: number;
stone: number;
sheep: number;
}
interface TradeItem {
negotiator: boolean;
self: boolean;
name: string;
color: string | undefined;
valid: boolean;
gets: any[];
gives: any[];
offerRejected: any;
canSubmit?: boolean;
}
const empty: Resources = {
wheat: 0,
brick: 0,
wood: 0,
stone: 0,
sheep: 0,
};
const Trade: React.FC = () => {
const { ws } = useContext(GlobalContext);
const [gives, setGives] = useState<Resources>(Object.assign({}, empty));
const [gets, setGets] = useState<Resources>(Object.assign({}, empty));
const [turn, setTurn] = useState<any>(undefined);
const [priv, setPriv] = useState<any>(undefined);
const [players, setPlayers] = useState<any>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const fields = useMemo(() => ["turn", "players", "private", "color"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`trade - game-update: `, data.update);
if ("turn" in data.update && !equal(turn, data.update.turn)) {
setTurn(data.update.turn);
}
if ("players" in data.update && !equal(players, data.update.players)) {
setPlayers(data.update.players);
}
if ("private" in data.update && !equal(priv, data.update.private)) {
setPriv(data.update.private);
}
if ("color" in data.update && color !== data.update.color) {
setColor(data.update.color);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const transfer = useCallback(
(type: string, direction: string) => {
if (direction === "give") {
/* give clicked */
if (gets[type as keyof Resources]) {
gets[type as keyof Resources]--;
gives[type as keyof Resources] = 0;
} else {
if (gives[type as keyof Resources] < priv[type]) {
gives[type as keyof Resources]++;
}
gets[type as keyof Resources] = 0;
}
} else if (direction === "get") {
/* get clicked */
if (gives[type as keyof Resources]) {
gives[type as keyof Resources]--;
gets[type as keyof Resources] = 0;
} else {
if (gets[type as keyof Resources] < 15) {
gets[type as keyof Resources]++;
}
gives[type as keyof Resources] = 0;
}
}
setGets({ ...gets });
setGives({ ...gives });
},
[setGets, setGives, gets, gives, priv]
);
const createTransfer = useCallback(
(resource: string) => {
return (
<div key={resource} className="Transfer">
<Resource
onClick={() => transfer(resource, "get")}
label={true}
type={resource}
disabled
count={gets[resource as keyof Resources]}
/>
<div className="Direction">
{gets[resource as keyof Resources] === gives[resource as keyof Resources] ? (
""
) : gets[resource as keyof Resources] > gives[resource as keyof Resources] ? (
<ArrowDownwardIcon />
) : (
<ArrowUpwardIcon />
)}
</div>
<Resource
onClick={() => transfer(resource, "give")}
label={true}
type={resource}
disabled
available={priv ? priv[resource] - gives[resource as keyof Resources] : undefined}
count={gives[resource as keyof Resources]}
/>
</div>
);
},
[gives, gets, transfer, priv]
);
const sendTrade = useCallback(
(action: string, offer: any) => {
if (ws) {
ws.send(
JSON.stringify({
type: "trade",
action,
offer,
})
);
}
},
[ws]
);
useEffect(() => {
if (priv && priv.gives) {
const _gives: Partial<Resources> = {};
priv.gives.forEach((give: any) => (_gives[give.type as keyof Resources] = give.count));
setGives(Object.assign({}, empty, _gives));
}
if (priv && priv.gets) {
const _gets: Partial<Resources> = {};
priv.gets.forEach((get: any) => (_gets[get.type as keyof Resources] = get.count));
setGets(Object.assign({}, empty, _gets));
}
}, [setGets, setGives, priv]);
const agreeClicked = useCallback(
(offer: any) => {
const trade = {
gives: offer.gets.slice(),
gets: offer.gives.slice(),
};
const _gives: Partial<Resources> = {},
_gets: Partial<Resources> = {};
console.log(gives, gets);
trade.gives.forEach((give: any) => (_gives[give.type as keyof Resources] = give.count));
trade.gets.forEach((get: any) => (_gets[get.type as keyof Resources] = get.count));
sendTrade("offer", trade);
console.log(_gives, _gets);
setGives(Object.assign({}, empty, _gives));
setGets(Object.assign({}, empty, _gets));
},
[setGives, setGets, gives, gets, sendTrade]
);
if (!priv || !turn || !turn.actions || turn.actions.indexOf("trade") === -1) {
return <></>;
}
const transfers = ["brick", "wood", "wheat", "sheep", "stone"].map((resource) => {
return createTransfer(resource);
});
priv.offerRejected = priv.offerRejected ? priv.offerRejected : {};
const canMeetOffer = (player: any, offer: any) => {
if (offer.gets.length === 0 || offer.gives.length === 0) {
return false;
}
for (let i = 0; i < offer.gets.length; i++) {
const get = offer.gets[i];
if (offer.name === "The bank") {
const _gives: any[] = [],
_gets: any[] = [];
for (const type in gives) {
if (gives[type as keyof Resources] > 0) {
_gives.push({ type, count: gives[type as keyof Resources] });
}
}
for (const type in gets) {
if (gets[type as keyof Resources] > 0) {
_gets.push({ type, count: gets[type as keyof Resources] });
}
}
if (_gives.length !== 1 || _gets.length !== 1) {
return false;
}
if (_gives[0].count < get.count) {
return false;
}
if (get.type !== "bank") {
if (gives[get.type as keyof Resources] < get.count) {
return false;
}
}
if (_gets[0].count !== 1) {
return false;
}
} else if (player[get.type] < get.count) {
console.log(`cannot meet count`);
return false;
}
}
return true;
};
const isCompatibleOffer = (player: any, offer: any) => {
let valid =
player.gets &&
player.gives &&
offer.gets &&
offer.gives &&
player.gets.length === offer.gives.length &&
player.gives.length === offer.gets.length;
if (!valid) {
return false;
}
player.gets.forEach((get: any) => {
if (!valid) {
return;
}
valid =
offer.gives.find((item: any) => (item.type === get.type || item.type === "*") && item.count === get.count) !==
undefined;
});
if (valid)
player.gives.forEach((give: any) => {
if (!valid) {
return;
}
valid =
offer.gets.find(
(item: any) => (item.type === give.type || item.type === "bank") && item.count === give.count
) !== undefined;
});
return valid;
};
const isTurn = turn && turn.color === color ? true : false;
const offerClicked = () => {
const trade = {
gives: [] as any[],
gets: [] as any[],
};
for (const key in gives) {
if (gives[key as keyof Resources] !== 0) {
trade.gives.push({ type: key, count: gives[key as keyof Resources] });
}
}
for (const key in gets) {
if (gets[key as keyof Resources] !== 0) {
trade.gets.push({ type: key, count: gets[key as keyof Resources] });
}
}
sendTrade("offer", trade);
};
const cancelOffer = (offer: any) => {
sendTrade("cancel", offer);
};
const acceptClicked = (offer: any) => {
if (offer.name === "The bank") {
sendTrade("accept", Object.assign({}, { name: offer.name, gives: trade.gets, gets: trade.gives }));
} else if (offer.self) {
sendTrade("accept", offer);
} else {
sendTrade("accept", Object.assign({}, offer, { gives: offer.gets, gets: offer.gives }));
}
};
// cancelClicked was unused; use cancelOffer when needed. Keep function removed.
/* Player has rejected the active player's bid or active player rejected
* the other player's bid */
const rejectClicked = (trade: any) => {
sendTrade("reject", trade);
};
/* Create list of active trades */
const activeTrades: any[] = [];
for (const colorKey in players) {
const item = players[colorKey],
name = item.name;
item.offerRejected = item.offerRejected ? item.offerRejected : {};
if (item.status !== "Active") {
continue;
}
/* Only list players with an offer, unless it is the active player (see
* that you haven't submitted an offer) or the current turn player,
* or the player explicitly rejected the player's offer */
if (
turn.name !== name &&
priv.name !== name &&
!(colorKey in priv.offerRejected) &&
(!item.gets || item.gets.length === 0 || !item.gives || item.gives.length === 0)
) {
continue;
}
const tmp: TradeItem = {
negotiator: turn.name === name,
self: priv.name === name,
name: name,
color: colorKey,
valid: false,
gets: item.gets ? item.gets : [],
gives: item.gives ? item.gives : [],
offerRejected: item.offerRejected,
};
tmp.canSubmit = !!(tmp.gets.length && tmp.gives.length);
activeTrades.push(tmp);
}
activeTrades.sort((A: any, B: any) => {
if (A.negotiator) {
return -1;
}
if (B.negotiator) {
return +1;
}
if (A.self) {
return -1;
}
if (B.self) {
return +1;
}
return A.name.localeCompare(B.name);
});
const trade = { gives: [] as any[], gets: [] as any[] };
for (const type in gives) {
if (gives[type as keyof Resources]) {
trade.gets.push({ type, count: gives[type as keyof Resources] });
}
}
for (const type in gets) {
if (gets[type as keyof Resources]) {
trade.gives.push({ type, count: gets[type as keyof Resources] });
}
}
const isOfferSubmitted = isCompatibleOffer(priv, trade),
isNegiatorSubmitted = turn && turn.offer && isCompatibleOffer(priv, turn.offer),
isOfferValid = trade.gives.length && trade.gets.length ? true : false;
if (isTurn && priv && priv.banks) {
priv.banks.forEach((bank: string) => {
const count = bank === "bank" ? 3 : 2;
activeTrades.push({
name: `The bank`,
color: undefined,
gives: [{ count: 1, type: "*" }],
gets: [{ count: count, type: bank }],
valid: false,
offerRejected: {},
});
});
activeTrades.push({
name: `The bank`,
color: undefined,
gives: [{ count: 1, type: "*" }],
gets: [{ count: 4, type: "bank" }],
valid: false,
offerRejected: {},
});
}
if (isTurn) {
activeTrades.forEach((offer: any) => {
if (offer.name === "The bank") {
/* offer has to be the second parameter for the bank to match */
offer.valid = isCompatibleOffer({ gives: trade.gets, gets: trade.gives }, offer);
} else {
offer.valid = !(turn.color in offer.offerRejected) && canMeetOffer(priv, offer);
}
});
} else {
const found = activeTrades.find((item: any) => item.name === turn.name);
if (found) {
found.valid = !(color! in found.offerRejected) && canMeetOffer(priv, found);
}
}
const tradeElements = activeTrades.map((item: any, index: number) => {
const youRejectedOffer = color! in item.offerRejected;
let youWereRejected;
if (isTurn) {
youWereRejected = item.color && item.color in priv.offerRejected;
} else {
youWereRejected = Object.getOwnPropertyNames(priv.offerRejected).length !== 0;
}
const isNewOffer = item.self && !isOfferSubmitted;
let isSameOffer;
const isBank = item.name === "The bank";
if (isTurn) {
isSameOffer = isCompatibleOffer(trade, { gets: item.gives, gives: item.gets });
} else {
isSameOffer = turn.offer && isCompatibleOffer(priv, turn.offer);
}
let source;
if (item.self) {
/* Order direction is reversed for self */
source = {
name: item.name,
color: item.color,
gets: trade.gives,
gives: trade.gets,
};
} else {
source = item;
}
const _gets = source.gets.length
? source.gets.map((get: any, index: number) => {
if (get.type === "bank") {
return (
<span key={`get-bank-${index}`}>
<b>{get.count}</b> of any resource{" "}
</span>
);
}
return <Resource key={`get-${get.type}-${index}`} disabled label type={get.type} count={get.count} />;
})
: "nothing";
const _gives = source.gives.length
? source.gives.map((give: any, index: number) => {
if (give.type === "*") {
return (
<span key={`give-bank-${index}`}>
<b>1</b> of any resource{" "}
</span>
);
}
return <Resource key={`give-${give.type}-${index}`} disabled label type={give.type} count={give.count} />;
})
: "nothing";
return (
<div className="TradeLine" key={`player-${item.name}-${index}`}>
<PlayerColor color={item.color} />
<div className="TradeText">
{item.self && (
<>
{(_gets !== "nothing" || _gives !== "nothing") && (
<span>
You want {_gets} and will give {_gives}.
</span>
)}
{youWereRejected && !isNewOffer && <span>{turn.name} rejected your offer.</span>}
{!youWereRejected && _gets === "nothing" && _gives === "nothing" && (
<span>You have not made a trade offer.</span>
)}
{!isTurn &&
isSameOffer &&
!youWereRejected &&
isOfferValid &&
_gets !== "nothing" &&
_gives !== "nothing" && (
<span style={{ fontWeight: "bold" }}>Your submitted offer agrees with {turn.name}&apos;s terms.</span>
)}
</>
)}
{!item.self && (
<>
{(!isTurn || !isSameOffer || isBank) &&
!youRejectedOffer &&
_gets !== "nothing" &&
_gives !== "nothing" && (
<span>
{item.name} wants {_gets} and will give {_gives}.
</span>
)}
{!isBank && (
<>
{isTurn &&
!isSameOffer &&
isOfferValid &&
!youRejectedOffer &&
_gets !== "nothing" &&
_gives !== "nothing" && <span style={{ fontWeight: "bold" }}>This is a counter offer.</span>}
{isTurn && isSameOffer && !youRejectedOffer && _gets !== "nothing" && _gives !== "nothing" && (
<span>{item.name} will meet your terms.</span>
)}
{(!isTurn || !youWereRejected) && (_gets === "nothing" || _gives === "nothing") && (
<span>{item.name} has not submitted a trade offer.</span>
)}
{youRejectedOffer && <span>You rejected {item.name}&apos;s offer.</span>}
{isTurn && youWereRejected && <span>{item.name} rejected your offer.</span>}
</>
)}
</>
)}
</div>
<div className="TradeActions">
{!item.self && isTurn && (
<Button disabled={!item.valid} onClick={() => acceptClicked(item)}>
accept
</Button>
)}
{!isTurn && item.color === turn.color && (
<Button disabled={!item.valid || isNegiatorSubmitted} onClick={() => agreeClicked(item)}>
agree
</Button>
)}
{item.name !== "The bank" && !item.self && (isTurn || item.name === turn.name) && (
<Button
disabled={!item.gets.length || !item.gives.length || youRejectedOffer}
onClick={() => rejectClicked(item)}
>
reject
</Button>
)}
{item.self && (
<Button disabled={isOfferSubmitted || !isOfferValid} onClick={offerClicked}>
Offer
</Button>
)}
{item.self && (
<Button disabled onClick={() => cancelOffer(item)}>
cancel
</Button>
)}
</div>
</div>
);
});
return (
<div className="Trade">
<Paper>
<div className="PlayerList">{tradeElements}</div>
{priv.resources === 0 && (
<div>
<b>You have no resources to participate in this trade.</b>
</div>
)}
{priv.resources !== 0 && (
<div className="Transfers">
<div className="GiveGet">
<div>Get</div>
<div>Give</div>
<div>Have</div>
</div>
{transfers}
</div>
)}
</Paper>
</div>
);
};
export { Trade };

255
client/src/ViewCard.tsx Normal file
View File

@ -0,0 +1,255 @@
import React, { useEffect, useContext, useMemo, useRef, useState, useCallback } from "react";
import equal from "fast-deep-equal";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import "./ViewCard.css";
import { Resource } from "./Resource";
import { GlobalContext } from "./GlobalContext";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface ViewCardProps {
cardActive: any;
setCardActive: (card: any) => void;
}
const ViewCard: React.FC<ViewCardProps> = ({ cardActive, setCardActive }) => {
const { ws } = useContext(GlobalContext);
const [priv, setPriv] = useState<any>(undefined);
const [turns, setTurns] = useState<number>(0);
const [rules, setRules] = useState<any>({});
const fields = useMemo(() => ["private", "turns", "rules"], []);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data as string);
switch (data.type) {
case "game-update":
console.log(`view-card - game update`);
if ("private" in data.update && !equal(data.update.private, priv)) {
setPriv(data.update.private);
}
if ("turns" in data.update && data.update.turns !== turns) {
setTurns(data.update.turns);
}
if ("rules" in data.update && !equal(data.update.rules, rules)) {
setRules(data.update.rules);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const playCard = useCallback(() => {
if (ws) {
ws.send(
JSON.stringify({
type: "play-card",
card: cardActive,
})
);
}
setCardActive(undefined);
}, [ws, cardActive, setCardActive]);
const close = () => {
setCardActive(undefined);
};
if (!cardActive) {
return <></>;
}
const capitalize = (string: string) => {
if (string === "vp") {
return "Victory Point";
}
if (string === "army") {
return "Knight";
}
return string.charAt(0).toUpperCase() + string.slice(1);
};
let description: React.ReactElement, lookup: string;
if (cardActive.type === "progress") {
lookup = `${cardActive.type}-${cardActive.card}`;
} else {
lookup = cardActive.type;
}
const points = "victory-points" in rules && rules["victory-points"].enabled ? rules["victory-points"].points : 0;
let cardName = "";
switch (lookup) {
case "army":
cardName = "Knight";
description = (
<>
<div>
When played, you <b>must</b> move the robber.
</div>
<div>
Steal <b>1</b> resource card from the owner of an adjacent settlement or city.
</div>
<div>You may only play one development card during your turn -- either one knight or one progress card.</div>
</>
);
break;
case "vp":
cardName = `Victory Point: ${capitalize(cardActive.card)}`;
description = (
<>
<div>
<b>1</b> victory point.
</div>
<div>
You only reveal your victory point cards when the game is over, either when you or an opponent reaches{" "}
<b>{points}+</b> victory points on their turn and declares victory!
</div>
</>
);
break;
case "progress-road-1":
case "progress-road-2":
cardName = "Road Building";
description = (
<>
<div>
Play <b>2</b> new roads as if you had just built them.
</div>
<div>
This is still limited by the number of roads you have. If you do not have enough roads remaining, or if there
are no valid road building locations, the number of roads you can place will be reduced.
</div>
<div>
You currently have <b>{priv?.roads}</b> roads remaining.
</div>
</>
);
break;
case "progress-monopoly":
cardName = "Monopoly";
description = (
<>
<div>
When you play this card, you will select <b>1</b> type of resource. All other players must give you all their
resource cards of that type.
</div>
</>
);
break;
case "progress-year-of-plenty":
cardName = "Year of Plenty";
description = (
<>
<div>
Take any <b>2</b> resources from the bank. Add them to your hand. They can be
<b>2</b> of the same resource or <b>1</b> of two differ resources.
</div>
</>
);
break;
default:
description = <>Unknown card type {lookup}</>;
break;
}
let canPlay = false;
if (cardActive.type === "vp") {
let points = priv?.points || 0;
priv?.development?.forEach((item: any) => {
if (item.type === "vp") {
points++;
}
});
canPlay = points >= points;
if (!canPlay && !cardActive.played) {
description = (
<>
{description}
<div>
You do not have enough victory points to play this card yet. You can currently reach <b>{points}</b> points.
</div>
</>
);
}
} else {
canPlay = cardActive.turn < turns;
if (!canPlay) {
description = (
<>
{description}
<div>You can not play this card until your next turn.</div>
</>
);
}
if (canPlay) {
canPlay = priv?.playedCard !== turns;
if (!canPlay) {
description = (
<>
{description}
<div>You have already played a development card this turn.</div>
</>
);
}
}
}
if (cardActive.played) {
description = (
<>
{description}
<div>You have already played this card.</div>
</>
);
canPlay = false;
}
return (
<div className="ViewCard">
<Paper>
<div className="Title">{cardName}</div>
<div style={{ display: "flex", flexDirection: "row" }}>
<Resource type={`${cardActive.type}-${cardActive.card}`} disabled count={1} />
<div className="Description">{description}</div>
</div>
{!cardActive.played && (
<Button disabled={!canPlay} onClick={playCard}>
play
</Button>
)}
<Button onClick={close}>close</Button>
</Paper>
</div>
);
};
export { ViewCard };

238
client/src/Winner.tsx Normal file
View File

@ -0,0 +1,238 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import equal from "fast-deep-equal";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import "./Winner.css";
import { Resource } from "./Resource";
import { PlayerColor } from "./PlayerColor";
import { GlobalContext } from "./GlobalContext";
interface WinnerProps {
winnerDismissed: boolean;
setWinnerDismissed: (dismissed: boolean) => void;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const Winner: React.FC<WinnerProps> = ({ winnerDismissed, setWinnerDismissed }) => {
const { ws } = useContext(GlobalContext);
const [winner, setWinner] = useState<any>(undefined);
const [state, setState] = useState<string | undefined>(undefined);
const fields = useMemo(() => ["winner", "state"], []);
const onWsMessage = (event: MessageEvent) => {
const data: { type: string; update: any } = JSON.parse(event.data);
switch (data.type) {
case "game-update":
console.log(`winner - game update`, data.update);
if ("winner" in data.update && !equal(data.update.winner, winner)) {
setWinner(data.update.winner);
}
if ("state" in data.update && data.update.state !== state) {
if (data.update.state !== "winner") {
setWinner(undefined);
}
setWinnerDismissed(false);
setState(data.update.state);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(
JSON.stringify({
type: "get",
fields,
})
);
}, [ws, fields]);
const quitClicked = useCallback(() => {
if (!winnerDismissed) {
setWinnerDismissed(true);
ws.send(
JSON.stringify({
type: "goto-lobby",
})
);
}
}, [ws, winnerDismissed, setWinnerDismissed]);
if (!winner || winnerDismissed) {
return <></>;
}
let losers = [];
for (const key in winner.players) {
if (key === winner.color || winner.players[key].status === "Not active") {
continue;
}
losers.push(winner.players[key]);
}
const turnCount = Math.floor(winner.turns / (losers.length + 1));
losers = losers.map((player: any) => {
const averageSeconds = Math.floor(player.totalTime / turnCount / 1000),
average = `${Math.floor(averageSeconds / 60)}m:${averageSeconds % 60}s`;
return (
<div key={player.color}>
<PlayerColor color={player.color} /> {player.name} finished with {player.points} victory points.
{Number(player.potential) !== 0 && (
<>
They had <b>{player.potential}</b> unplayed Victory Point card(s).
</>
)}
Their average turn time was {average}.
</div>
);
});
let robber;
let max = 0;
let playerStolen: any = {};
const stats = winner.stolen;
for (const player in stats) {
if (player === "total" || player === "player") {
continue;
}
if (player === "robber") {
robber = <></>;
for (const type in stats.robber.stole) {
if (type === "total") {
continue;
}
const count = stats.robber.stole[type];
robber = (
<>
{robber}
<Resource label={true} type={type} count={count} disabled />
</>
);
}
robber = (
<div>
Throughout the game, the robber blocked <b>{stats.robber.stole.total}</b> resources:
<div className="ThiefStole">{robber}</div>
</div>
);
continue;
}
if (stats[player].stolen.total < max) {
continue;
}
if (stats[player].stolen.total > max) {
max = stats[player].stolen.total;
playerStolen = {
robber: stats[player].stolen.robber,
player: stats[player].stolen.player,
element: <></>,
};
}
let stolen;
for (const type in stats[player].stolen) {
if (["total", "robber", "player"].indexOf(type) !== -1) {
continue;
}
if (!stolen) {
stolen = <></>;
}
const count = stats[player].stolen[type];
stolen = (
<>
{stolen}
<Resource label={true} type={type} count={count} disabled />
</>
);
}
if (stolen) {
playerStolen.element = (
<div key={player}>
<PlayerColor color={player} /> {winner.players[player].name}
<div className="PlayerStolen">{stolen}</div>
</div>
);
}
}
if (!robber) {
robber = <div>The robber never blocked any resources from anyone!</div>;
}
const averageSeconds = Math.floor(winner.totalTime / turnCount / 1000),
average = `${Math.floor(averageSeconds / 60)}m:${averageSeconds % 60}s`;
const seconds = winner.elapsedTime / 1000,
h = Math.floor(seconds / (60 * 60)),
m = Math.floor((seconds % (60 * 60)) / 60),
s = Math.floor((seconds % (60 * 60)) % 60);
const totalTime = `${h}h:${m}m:${s}s`;
const vpType: string[] = ["market", "university", "library", "palace"];
const selectedVpType = vpType[Math.floor(vpType.length * Math.random())];
return (
<div className="Winner">
<Paper>
<div className="Title">
{winner.name} has won with {winner.points} victory points!
</div>
<div style={{ display: "flex", flexDirection: "row" }}>
<Resource type={`vp-${selectedVpType}`} disabled count={1} />
<div className="Description">
<div>
Congratulations, <b>{winner.name}</b>!
</div>
<div>
<PlayerColor color={winner.color} /> {winner.name} won the game with <b>{winner.points}</b> Victory Points
after {turnCount} game turns.
{Number(winner.potential) !== 0 && (
<>
They had <b>{winner.potential}</b> unplayed Victory Point card(s).
</>
)}
Their average turn time was {average}.
</div>
{losers}
<div>The game took {totalTime}.</div>
{robber}
{max !== 0 && (
<>
<div>
The robber stole {playerStolen.robber} and other players stole {playerStolen.player} resources from:
</div>
<div className="PlayerStolenList">{playerStolen.element}</div>
</>
)}
</div>
</div>
<Button onClick={quitClicked}>Go back to Lobby</Button>
</Paper>
</div>
);
};
export { Winner };

13
client/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
declare module '*.css';
declare module 'Trade' {
const Trade: unknown;
export default Trade;
}
declare module '*.svg';
declare module "./Trade" {
const Trade: unknown;
export default Trade;
}

View File

@ -1,14 +0,0 @@
import { createBrowserHistory } from 'history';
// Run our app under the /base URL.
const history = createBrowserHistory({
// basename: process.env.PUBLIC_URL
});/*,
push = history.push;
history.push = (path) => {
const base = new URL(document.querySelector("base") ? document.querySelector("base").href : "");
push(base.pathname + path);
};*/
export default history;

21
client/src/index.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
if (process.env.NODE_ENV !== "production") {
console.log("DEVELOPMENT mode!");
}
const rootEl = document.getElementById("root");
if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
reportWebVitals();

View File

@ -1,27 +0,0 @@
Game Order
---
If game-order active, show Game Order dialog.
Game Order dialog:
* List all active players
* List each active player's roll
* Sort order as player roll comes in
* Message indicates what is going on
Roll dice
R 6
O 5
W 5
B 3
O 6
W 6
O 3
W 5
Final order: R W O B

View File

@ -1,5 +1,6 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
/* eslint-disable @typescript-eslint/no-explicit-any */
const reportWebVitals = (onPerfEntry?: (metric: any) => void) => {
if (onPerfEntry && typeof onPerfEntry === 'function') {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
@ -10,4 +11,4 @@ const reportWebVitals = onPerfEntry => {
}
};
export default reportWebVitals;
export default reportWebVitals;

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-var-requires, no-undef, @typescript-eslint/no-explicit-any */
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
module.exports = function(app: any) {
const base = process.env.PUBLIC_URL;
console.log(`http-proxy-middleware ${base}`);
app.use(createProxyMiddleware(
@ -9,4 +10,4 @@ module.exports = function(app) {
target: 'http://localhost:8930',
changeOrigin: true,
}));
};
};

View File

@ -2,4 +2,4 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
import '@testing-library/jest-dom';

8
client/tsconfig.app.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src/**/*"]
}

17
client/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": true,
"checkJs": false,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules", "build"]
}