1
0

THings are improving

This commit is contained in:
James Ketr 2025-10-10 19:51:15 -07:00
parent b2e5fe4e03
commit 579632c293
87 changed files with 421 additions and 313 deletions

View File

@ -106,7 +106,7 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
const [signature, setSignature] = useState<string>("");
const [generated, setGenerated] = useState<string>("");
const [robber, setRobber] = useState<number>(-1);
const [robberName, setRobberName] = useState<string[]>([]);
const [robberName, setRobberName] = useState<string>("");
const [pips, setPips] = useState<any>(undefined); // Keep as any for now, complex structure
const [pipOrder, setPipOrder] = useState<any>(undefined);
const [borders, setBorders] = useState<any>(undefined);
@ -147,86 +147,123 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
return;
}
const data = lastJsonMessage;
switch (data.type) {
case "game-update":
console.log(`board - game update`, data.update);
if ("robber" in data.update && data.update.robber !== robber) {
setRobber(data.update.robber);
const handleUpdate = (update: any) => {
console.log(`board - game update`, update);
if ("robber" in update && update.robber !== robber) {
setRobber(update.robber);
}
if ("robberName" in data.update && data.update.robberName !== robberName) {
setRobberName(data.update.robberName);
if ("robberName" in update) {
const newName = Array.isArray(update.robberName) ? String(update.robberName[0] || "") : String(update.robberName || "");
if (newName !== robberName) setRobberName(newName);
}
if ("state" in data.update && data.update.state !== state) {
setState(data.update.state);
if ("state" in update && update.state !== state) {
setState(update.state);
}
if ("rules" in data.update && !equal(data.update.rules, rules)) {
setRules(data.update.rules);
if ("rules" in update && !equal(update.rules, rules)) {
setRules(update.rules);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
if ("color" in update && update.color !== color) {
setColor(update.color);
}
if ("longestRoadLength" in data.update && data.update.longestRoadLength !== longestRoadLength) {
setLongestRoadLength(data.update.longestRoadLength);
if ("longestRoadLength" in update && update.longestRoadLength !== longestRoadLength) {
setLongestRoadLength(update.longestRoadLength);
}
if ("turn" in data.update) {
if (!equal(data.update.turn, turn)) {
console.log(`board - turn`, data.update.turn);
setTurn(data.update.turn);
if ("turn" in update) {
if (!equal(update.turn, turn)) {
console.log(`board - turn`, update.turn);
setTurn(update.turn);
}
}
if ("placements" in data.update && !equal(data.update.placements, placements)) {
console.log(`board - placements`, data.update.placements);
setPlacements(data.update.placements);
if ("placements" in update && !equal(update.placements, placements)) {
console.log(`board - placements`, update.placements);
setPlacements(update.placements);
}
/* The following are only updated if there is a new game
* signature */
if ("pipOrder" in data.update && !equal(data.update.pipOrder, pipOrder)) {
* signature or changed ordering */
if ("pipOrder" in update && !equal(update.pipOrder, pipOrder)) {
console.log(`board - setting new pipOrder`);
setPipOrder(data.update.pipOrder);
setPipOrder(update.pipOrder);
}
if ("borderOrder" in data.update && !equal(data.update.borderOrder, borderOrder)) {
if ("borderOrder" in update && !equal(update.borderOrder, borderOrder)) {
console.log(`board - setting new borderOrder`);
setBorderOrder(data.update.borderOrder);
setBorderOrder(update.borderOrder);
}
if ("animationSeeds" in data.update && !equal(data.update.animationSeeds, animationSeeds)) {
if ("animationSeeds" in update && !equal(update.animationSeeds, animationSeeds)) {
console.log(`board - setting new animationSeeds`);
setAnimationSeeds(data.update.animationSeeds);
setAnimationSeeds(update.animationSeeds);
}
if ("tileOrder" in data.update && !equal(data.update.tileOrder, tileOrder)) {
if ("tileOrder" in update && !equal(update.tileOrder, tileOrder)) {
console.log(`board - setting new tileOrder`);
setTileOrder(data.update.tileOrder);
setTileOrder(update.tileOrder);
}
if (data.update.signature !== signature) {
if (update.signature !== undefined && update.signature !== signature) {
console.log(`board - setting new signature`);
setSignature(data.update.signature);
setSignature(update.signature);
}
/* This is permanent static data from the server -- do not update
* once set */
if ("pips" in data.update && !pips) {
/* Static data from the server (defensive): update when present and different */
if ("pips" in update && !equal(update.pips, pips)) {
console.log(`board - setting new static pips`);
setPips(data.update.pips);
setPips(update.pips);
}
if ("tiles" in data.update && !tiles) {
if ("tiles" in update && !equal(update.tiles, tiles)) {
console.log(`board - setting new static tiles`);
setTiles(data.update.tiles);
setTiles(update.tiles);
}
if ("borders" in data.update && !borders) {
if ("borders" in update && !equal(update.borders, borders)) {
console.log(`board - setting new static borders`);
setBorders(data.update.borders);
setBorders(update.borders);
}
};
switch (data.type) {
case "game-update":
handleUpdate(data.update || {});
break;
case "initial-game":
// initial snapshot contains the consolidated game in data.snapshot
console.log(`board - initial-game snapshot received`);
if (data.snapshot) {
const snap = data.snapshot;
// Normalize snapshot fields to same keys used for incremental updates
const initialUpdate: any = {};
// Pick expected fields from snapshot
[
"robber",
"robberName",
"state",
"rules",
"color",
"longestRoadLength",
"turn",
"placements",
"pipOrder",
"borderOrder",
"animationSeeds",
"tileOrder",
"signature",
].forEach((k) => {
if (k in snap) initialUpdate[k] = snap[k];
});
// static asset metadata
if ("tiles" in snap) initialUpdate.tiles = snap.tiles;
if ("pips" in snap) initialUpdate.pips = snap.pips;
if ("borders" in snap) initialUpdate.borders = snap.borders;
handleUpdate(initialUpdate);
}
break;
default:
@ -508,10 +545,6 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
console.log(`board - Generate pip, border, and tile elements`);
const Pip: React.FC<PipProps> = ({ pip, className }) => {
const onPipClicked = (pip) => {
if (!ws) {
console.error(`board - sendPlacement - ws is NULL`);
return;
}
sendJsonMessage({
type: "place-robber",
index: pip.index,
@ -928,7 +961,7 @@ const Board: React.FC<BoardProps> = ({ animations }) => {
const el = document.querySelector(`.Pip[data-index="${robber}"]`);
if (el) {
el.classList.add("Robber");
el.classList.add(robberName);
if (robberName) el.classList.add(robberName);
}
}
});

View File

@ -14,6 +14,7 @@ import { useContext } from "react";
import WebRTCStatus from "./WebRTCStatus";
import Moveable from "react-moveable";
import { flushSync } from "react-dom";
import { SxProps, Theme } from "@mui/material";
const debug = true;
// When true, do not send host candidates to the signaling server. Keeps TURN relays preferred.
@ -1304,6 +1305,7 @@ interface MediaControlProps {
sendJsonMessage?: (msg: any) => void;
remoteAudioMuted?: boolean;
remoteVideoOff?: boolean;
sx?: SxProps<Theme>;
}
const MediaControl: React.FC<MediaControlProps> = ({
@ -1313,6 +1315,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
sendJsonMessage,
remoteAudioMuted,
remoteVideoOff,
sx,
}) => {
const [muted, setMuted] = useState<boolean>(peer?.muted || false);
const [videoOn, setVideoOn] = useState<boolean>(peer?.video_on !== false);
@ -1591,6 +1594,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
alignItems: "center",
minWidth: "200px",
minHeight: "100px",
...sx,
}}
>
<div

View File

@ -64,6 +64,20 @@ const PlayerList: React.FC = () => {
[session]
);
useEffect(() => {
if (!players) {
return;
}
players.forEach((player) => {
console.log("rabbit - player:", {
name: player.name,
live: player.live,
in_peers: peers[player.session_id],
local_or_media: player.local || player.has_media !== false,
});
});
}, [players]);
// Use the WebSocket hook for room events with automatic reconnection
useEffect(() => {
if (!lastJsonMessage) {
@ -179,6 +193,7 @@ const PlayerList: React.FC = () => {
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? (
<>
<MediaControl
sx={{ border: "3px solid blue" }}
className="Medium"
key={player.session_id}
peer={peers[player.session_id]}

View File

@ -48,14 +48,12 @@ const audioEffects: Record<string, AudioEffect | undefined> = {};
const loadAudio = (src: string) => {
const audio = document.createElement("audio") as AudioEffect;
audio.src = audioFiles[src];
console.log("Loading audio:", audio.src);
audio.setAttribute("preload", "auto");
audio.setAttribute("controls", "none");
audio.style.display = "none";
document.body.appendChild(audio);
audio.load();
audio.addEventListener("error", (e) => console.error("Audio load error:", e, audio.src));
audio.addEventListener("canplay", () => console.log("Audio can play:", audio.src));
return audio;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

View File

@ -300,7 +300,7 @@ const bestRoadPlacement = (game) => {
return;
}
const placedRoad = game.placements.roads[roadIndex];
if (placedRoad.color) {
if (!placedRoad || placedRoad.color) {
return;
}
attempt = roadIndex;

View File

@ -16,6 +16,9 @@ const processCorner = (game: any, color: string, cornerIndex: number, placedCorn
let longest = 0;
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) {
return;
}
if (placedRoad.walking) {
return;
}
@ -47,6 +50,7 @@ const buildCornerGraph = (game: any, color: string, cornerIndex: number, placedC
/* Calculate the longest road branching from both corners */
layout.corners[cornerIndex].roads.forEach((roadIndex: number) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
buildRoadGraph(game, color, roadIndex, placedRoad, set);
});
};
@ -65,8 +69,9 @@ const processRoad = (game: any, color: string, roadIndex: number, placedRoad: an
placedRoad.walking = true;
/* Calculate the longest road branching from both corners */
let roadLength = 1;
layout.roads[roadIndex].corners.forEach(cornerIndex => {
layout.roads[roadIndex].corners.forEach((cornerIndex) => {
const placedCorner = game.placements.corners[cornerIndex];
if (!placedCorner) return;
if (placedCorner.walking) {
return;
}
@ -89,21 +94,26 @@ const buildRoadGraph = (game: any, color: string, roadIndex: number, placedRoad:
placedRoad.walking = true;
set.push(roadIndex);
/* Calculate the longest road branching from both corners */
layout.roads[roadIndex].corners.forEach(cornerIndex => {
layout.roads[roadIndex].corners.forEach((cornerIndex) => {
const placedCorner = game.placements.corners[cornerIndex];
buildCornerGraph(game, color, cornerIndex, placedCorner, set)
if (!placedCorner) return;
buildCornerGraph(game, color, cornerIndex, placedCorner, set);
});
};
const clearRoadWalking = (game: any) => {
/* Clear out walk markers on roads */
layout.roads.forEach((item, itemIndex) => {
if (game.placements && game.placements.roads && game.placements.roads[itemIndex]) {
delete game.placements.roads[itemIndex].walking;
}
});
/* Clear out walk markers on corners */
layout.corners.forEach((item, itemIndex) => {
if (game.placements && game.placements.corners && game.placements.corners[itemIndex]) {
delete game.placements.corners[itemIndex].walking;
}
});
}
@ -122,6 +132,7 @@ const calculateRoadLengths = (game: any) => {
let graphs = [];
layout.roads.forEach((_, roadIndex) => {
const placedRoad = game.placements.roads[roadIndex];
if (!placedRoad) return;
if (placedRoad.color === color) {
let set = [];
buildRoadGraph(game, color, roadIndex, placedRoad, set);
@ -133,13 +144,13 @@ const calculateRoadLengths = (game: any) => {
let final = {
segments: 0,
index: -1
index: -1,
};
clearRoadWalking(game);
graphs.forEach(graph => {
graphs.forEach((graph) => {
graph.longestRoad = 0;
graph.set.forEach(roadIndex => {
graph.set.forEach((roadIndex) => {
const placedRoad = game.placements.roads[roadIndex];
clearRoadWalking(game);
const length = processRoad(game, color, roadIndex, placedRoad);
@ -154,7 +165,9 @@ const calculateRoadLengths = (game: any) => {
});
});
game.placements.roads.forEach(road => delete road.walking);
game.placements.roads.forEach((road: any) => {
if (road) delete road.walking;
});
return final;
};

View File

@ -17,8 +17,8 @@ import {
PlayerColor,
PLAYER_COLORS,
RESOURCE_TYPES,
ResourceType,
} from "./games/types";
import { normalizeIncoming } from "./games/utils";
import {
audio,
join as webrtcJoin,
@ -54,6 +54,7 @@ import { transientState } from "./games/sessionState";
import { createGame, resetGame, setBeginnerGame } from "./games/gameFactory";
import { getVictoryPointRule, setRules, supportedRules } from "./games/rules";
import { pickRobber } from "./games/robber";
import { IncomingMessage } from "./games/types";
const processTies = (players: Player[]): boolean => {
/* Sort the players into buckets based on their
@ -256,6 +257,8 @@ const processVolcano = (game: Game, session: Session, dice: number[]) => {
dice: game.dice,
placements: game.placements,
players: getFilteredPlayers(game),
longestRoad: game.longestRoad,
longestRoadLength: game.longestRoadLength,
});
};
@ -327,6 +330,7 @@ interface ResourceCount {
wheat: number;
stone: number;
desert: number;
bank: number;
}
type Received = Record<PlayerColor | "robber", ResourceCount>;
@ -361,6 +365,13 @@ const distributeResources = (game: Game, roll: number): void => {
receives[color]![type] = 0;
});
});
/* Ensure robber entry exists */
if (!receives.robber) {
receives.robber = {} as ResourceCount;
RESOURCE_TYPES.forEach((type) => {
receives.robber[type] = 0;
});
}
/* Find which corners are on each tile */
matchedTiles.forEach((tile: Tile) => {
@ -379,18 +390,21 @@ const distributeResources = (game: Game, roll: number): void => {
return;
}
tileLayout.corners.forEach((cornerIndex: number) => {
// corners may refer to undefined slots in placements; guard against that
const active = game.placements.corners[cornerIndex];
if (!active) {
if (!active || !active.color) {
return;
}
const count = active.type === "settlement" ? 1 : 2;
if (!tile.robber) {
if (resource.type) {
try {
receives[active.color][resource.type]! += count;
} catch (e) {
console.error("Error incrementing resources", { receives, active, resource, count });
if (resource && resource.type) {
// ensure receives entry for this color exists
if (!receives[active.color]) {
receives[active.color] = {} as ResourceCount;
RESOURCE_TYPES.forEach((t) => (receives[active.color]![t] = 0));
}
receives[active.color]![resource.type] = (receives[active.color]![resource.type] || 0) + count;
}
} else {
const victim = game.players[active.color];
@ -400,10 +414,19 @@ const distributeResources = (game: Game, roll: number): void => {
null,
`Robber does not steal ${count} ${resource.type} from ${victim?.name} due to Robin Hood Robber house rule.`
);
if (resource && resource.type) receives[active.color]![resource.type]! += count;
if (resource && resource.type) {
if (!receives[active.color]) {
receives[active.color] = {} as ResourceCount;
RESOURCE_TYPES.forEach((t) => (receives[active.color]![t] = 0));
}
receives[active.color]![resource.type] = (receives[active.color]![resource.type] || 0) + count;
}
} else {
// If resource.type is falsy, skip.
if (resource && resource.type) {
trackTheft(game, active.color, "robber", resource.type, count);
if (resource.type) receives.robber[resource.type] += count;
receives.robber[resource.type] = (receives.robber[resource.type] || 0) + count;
}
}
}
});
@ -424,8 +447,19 @@ const distributeResources = (game: Game, roll: number): void => {
if (color !== "robber") {
s = sessionFromColor(game, color);
if (s && s.player) {
switch (type) {
case "wood":
case "brick":
case "sheep":
case "wheat":
case "stone":
s.player[type] += entry[type];
s.player.resources += entry[type];
break;
default:
console.error("Invalid resource type to distribute:", type);
return;
}
messageParts.push(`${entry[type]} ${type}`);
}
} else {
@ -1188,7 +1222,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
message = `${name} has rejoined the lobby.`;
}
session.name = name;
if (session.ws && game.id in audio && session.name in audio[game.id]) {
if (session.ws && game.id in audio && session.id in audio[game.id]) {
webrtcPart(audio[game.id], session);
}
} else {
@ -1211,10 +1245,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
}
if (session.ws && session.hasAudio) {
webrtcJoin(audio[game.id], session, {
hasVideo: session.video ? true : false,
hasAudio: session.audio ? true : false,
});
webrtcJoin(audio[game.id], session);
}
console.log(`${info}: ${message}`);
addChatMessage(game, null, message);
@ -1576,7 +1607,7 @@ const calculateRoadLengths = (game: Game, session: Session): void => {
if (debug.road)
console.log(
"Pre update:",
game.placements.roads.filter((road) => road.color)
game.placements.roads.filter((road) => road && (road as any).color)
);
for (let color in game.players) {
@ -1610,7 +1641,7 @@ const calculateRoadLengths = (game: Game, session: Session): void => {
if (debug.road)
console.log(
"Post update:",
game.placements.roads.filter((road: any) => road.color)
game.placements.roads.filter((road: any) => road && road.color)
);
let checkForTies = false;
@ -2277,6 +2308,8 @@ const placeRobber = (game: Game, session: Session, robber: number | string): str
robber: game.robber,
robberName: game.robberName,
activities: game.activities,
longestRoad: game.longestRoad,
longestRoadLength: game.longestRoadLength,
});
sendUpdateToPlayer(game, session, {
private: session.player,
@ -2572,8 +2605,7 @@ const playCard = (game: Game, session: Session, card: any): string | undefined =
const placeSettlement = (game: Game, session: Session, index: number | string): string | undefined => {
if (!session.player) return `You are not playing a player.`;
const player: any = session.player;
const anyGame: any = game as any;
const player: Player = session.player;
if (typeof index === "string") index = parseInt(index);
if (game.state !== "initial-placement" && game.state !== "normal") {
@ -2585,11 +2617,7 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
}
/* index out of range... */
if (
!anyGame.placements ||
anyGame.placements.corners === undefined ||
anyGame.placements.corners[index] === undefined
) {
if (!game.placements || game.placements.corners === undefined || game.placements.corners[index] === undefined) {
return `You have requested to place a settlement illegally!`;
}
@ -2597,8 +2625,8 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
if (!game.turn || !game.turn.limits || !game.turn.limits.corners || game.turn.limits.corners.indexOf(index) === -1) {
return `You tried to cheat! You should not try to break the rules.`;
}
const corner = anyGame.placements.corners[index];
if (corner.color) {
const corner = game.placements.corners[index]!;
if (corner.color && corner.color !== "unassigned") {
const owner = game.players && game.players[corner.color];
const ownerName = owner ? owner.name : "unknown";
return `This location already has a settlement belonging to ${ownerName}!`;
@ -2627,10 +2655,7 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
player.wood = (player.wood || 0) - 1;
player.wheat = (player.wheat || 0) - 1;
player.sheep = (player.sheep || 0) - 1;
player.resources = 0;
["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => {
player.resources += player[resource] || 0;
});
player.resources -= 4;
}
delete game.turn.free;
@ -2641,8 +2666,8 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
const banks = layout.corners?.[index]?.banks;
if (banks && banks.length) {
banks.forEach((bank: any) => {
const border = anyGame.borderOrder[Math.floor(bank / 3)],
type = anyGame.borders?.[border]?.[bank % 3];
const border = game.borderOrder[Math.floor(bank / 3)]!,
type: ResourceType = staticData.borders?.[border]?.[bank % 3]!;
console.log(`${session.short}: Bank ${bank} = ${type}`);
if (!type) {
console.log(`${session.short}: Bank ${bank}`);
@ -2656,11 +2681,11 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
player.ports++;
if (isRuleEnabled(game, "port-of-call")) {
console.log(`Checking port-of-call`, player.ports, anyGame.mostPorts);
if (player.ports >= 3 && (!anyGame.mostPorts || player.ports > anyGame.mostPortCount)) {
if (anyGame.mostPorts !== session.color) {
anyGame.mostPorts = session.color;
anyGame.mostPortCount = player.ports;
console.log(`Checking port-of-call`, player.ports, game.mostPorts);
if (player.ports >= 3 && (!game.mostPorts || player.ports > game.mostPortCount)) {
if (game.mostPorts !== session.color) {
game.mostPorts = session.color;
game.mostPortCount = player.ports;
addChatMessage(game, session, `${session.name} now has the most ports (${player.ports})!`);
}
}
@ -2676,7 +2701,7 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
}
calculateRoadLengths(game, session);
} else if (game.state === "initial-placement") {
if (anyGame.direction && anyGame.direction === "backward") {
if (game.direction && game.direction === "backward") {
(session as any).initialSettlement = index;
}
corner.color = session.color || "";
@ -2685,8 +2710,8 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
const banks2 = layout.corners?.[index]?.banks;
if (banks2 && banks2.length) {
banks2.forEach((bank: any) => {
const border = anyGame.borderOrder[Math.floor(bank / 3)],
type = anyGame.borders?.[border]?.[bank % 3];
const border = game.borderOrder[Math.floor(bank / 3)]!,
type = staticData.borders?.[border]?.[bank % 3];
console.log(`${session.short}: Bank ${bank} = ${type}`);
if (!type) {
return;
@ -2770,10 +2795,7 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi
addChatMessage(game, session, `${session.name} spent 1 brick and 1 wood to build a road.`);
player.brick--;
player.wood--;
player.resources = 0;
["wheat", "brick", "sheep", "stone", "wood"].forEach((resource) => {
player.resources += player[resource];
});
player.resources -= 2;
}
delete game.turn.free;
}
@ -2890,6 +2912,8 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi
players: getFilteredPlayers(game),
state: game.state,
direction: (game as any)["direction"],
longestRoad: game.longestRoad,
longestRoadLength: game.longestRoadLength,
});
return undefined;
};
@ -3279,6 +3303,8 @@ const placeCity = (game: any, session: any, index: any): string | undefined => {
chat: game.chat,
activities: game.activities,
players: getFilteredPlayers(game),
longestRoad: game.longestRoad,
longestRoadLength: game.longestRoadLength,
});
return undefined;
};
@ -3623,6 +3649,23 @@ const gotoLobby = (game: any, session: any): string | undefined => {
return undefined;
};
const normalizeIncoming = (msg: unknown): IncomingMessage | null => {
let parsed: IncomingMessage | null = null;
try {
if (typeof msg === "string") {
parsed = JSON.parse(msg);
} else {
parsed = msg as IncomingMessage;
}
} catch (e) {
return null;
}
if (!parsed) return null;
const type = parsed.type || (parsed as any).action || null;
const data = parsed.data || (Object.keys(parsed).length ? Object.assign({}, parsed) : null);
return { type, data };
};
router.ws("/ws/:id", async (ws, req) => {
console.log("New WebSocket connection");
if (!req.cookies || !(req.cookies as any)["player"]) {
@ -3683,7 +3726,7 @@ router.ws("/ws/:id", async (ws, req) => {
/* ignore logging errors */
}
if (!(gameId in audio)) {
audio[gameId] = {}; /* List of peer sockets using session.name as index. */
audio[gameId] = {}; /* List of peer sockets using session.id as index. */
console.log(`${short}: Game ${gameId} - New Game Audio`);
} else {
console.log(`${short}: Game ${gameId} - Already has Audio`);
@ -3846,7 +3889,7 @@ router.ws("/ws/:id", async (ws, req) => {
// Normalize the incoming message to { type, data } so handlers can
// reliably access the payload without repeated defensive checks.
const incoming = normalizeIncoming(message);
if (!incoming.type) {
if (!incoming || !incoming.type) {
// If we couldn't parse or determine the type, log and ignore the
// message to preserve previous behavior.
try {
@ -3856,7 +3899,7 @@ router.ws("/ws/:id", async (ws, req) => {
}
return;
}
const data = (incoming.data as any) || {};
const data = incoming.data;
const game = await loadGame(gameId);
const _session = getSession(
game,
@ -3925,7 +3968,7 @@ router.ws("/ws/:id", async (ws, req) => {
// Accept either legacy `config`, newer `data`, or flat payloads where
// the client sent fields at the top level (normalizeIncoming will
// populate `data` with the parsed object in that case).
webrtcJoin(audio[gameId], session, data.config || data.data || data || {});
webrtcJoin(audio[gameId], session);
break;
case "part":
@ -3937,7 +3980,7 @@ router.ws("/ws/:id", async (ws, req) => {
// Delegate to the webrtc signaling helper (it performs its own checks)
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
handleRelayICECandidate(gameId, cfg, session, undefined, debug);
handleRelayICECandidate(gameId, cfg, session, debug);
}
break;
@ -3945,7 +3988,7 @@ router.ws("/ws/:id", async (ws, req) => {
{
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
handleRelaySessionDescription(gameId, cfg, session, undefined, debug);
handleRelaySessionDescription(gameId, cfg, session, debug);
}
break;
@ -3977,7 +4020,7 @@ router.ws("/ws/:id", async (ws, req) => {
{
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
broadcastPeerStateUpdate(gameId, cfg, session, undefined);
broadcastPeerStateUpdate(gameId, cfg, session);
}
break;
@ -4069,7 +4112,6 @@ router.ws("/ws/:id", async (ws, req) => {
case "longestRoadLength":
case "robber":
case "robberName":
case "pips":
case "tileOrder":
case "active":
case "largestArmy":
@ -4086,6 +4128,14 @@ router.ws("/ws/:id", async (ws, req) => {
case "tiles":
batchedUpdate.tiles = staticData.tiles;
break;
case "pips":
// pips are static data (number/roll mapping). Return from staticData
batchedUpdate.pips = staticData.pips;
break;
case "borders":
// borders are static data describing ports/banks
batchedUpdate.borders = staticData.borders;
break;
case "rules":
batchedUpdate[field] = game.rules ? game.rules : {};
break;
@ -4519,6 +4569,18 @@ const getFilteredGameForPlayer = (game: any, session: any) => {
sessions: reducedSessions,
layout: layout,
players: getFilteredPlayers(game),
// Include static asset metadata so clients can render the board immediately
tiles: staticData.tiles,
pips: staticData.pips,
borders: staticData.borders,
// Include board order/state so clients can render without waiting for extra GETs
pipOrder: game.pipOrder,
tileOrder: game.tileOrder,
borderOrder: game.borderOrder,
signature: game.signature,
animationSeeds: game.animationSeeds,
robber: game.robber,
robberName: game.robberName,
});
};

View File

@ -250,6 +250,8 @@ export const createGame = async (id: string | null = null) => {
B: newPlayer("B"),
W: newPlayer("W"),
},
mostPorts: null,
mostPortCount: 0,
sessions: {},
unselected: [],
placements: {

View File

@ -535,8 +535,16 @@ export const getFilteredPlayers = (game: Game): Record<string, Player> => {
}
player.resources = 0;
RESOURCE_TYPES.forEach((resource) => {
switch (resource) {
case "wood":
case "brick":
case "sheep":
case "wheat":
case "stone":
player.resources += player[resource];
delete player[resource];
player[resource] = 0;
break;
}
});
player.development = [];
}

View File

@ -32,6 +32,7 @@ export const newPlayer = (color: PlayerColor): Player => {
live: true,
turnNotice: "",
longestRoad: 0,
banks: [],
};
};

View File

@ -52,7 +52,7 @@ export const gameDB: GameDB = {
type: db.Sequelize.QueryTypes.SELECT,
});
if (rows && rows.length) {
const r = rows[0] as any;
const r = rows[0];
// state may be stored as text or JSON
if (typeof r.state === "string") {
try {

View File

@ -25,7 +25,9 @@ export interface Player {
stone: number;
brick: number;
wood: number;
army: number;
points: number;
ports: number;
resources: number;
lastActive: number;
live: boolean;
@ -35,7 +37,7 @@ export interface Player {
turnNotice: string;
turnStart: number;
totalTime: number;
[key: string]: any; // allow incremental fields until fully typed
banks: ResourceType[];
}
export type CornerType = "settlement" | "city" | "none";
@ -46,7 +48,6 @@ export interface CornerPlacement {
type: "settlement" | "city" | "none";
walking?: boolean;
longestRoad?: number;
[key: string]: any;
}
export type RoadType = "road" | "ship";
@ -60,8 +61,8 @@ export interface RoadPlacement {
}
export interface Placements {
corners: CornerPlacement[];
roads: RoadPlacement[];
corners: Array<CornerPlacement | undefined>;
roads: Array<RoadPlacement | undefined>;
}
export interface Turn {
@ -124,8 +125,8 @@ export interface Offer {
[key: string]: any;
}
export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert";
export const RESOURCE_TYPES: ResourceType[] = ["wood", "brick", "sheep", "wheat", "stone", "desert"];
export type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone" | "desert" | "bank";
export const RESOURCE_TYPES: ResourceType[] = ["wood", "brick", "sheep", "wheat", "stone", "desert", "bank"];
export interface Tile {
robber: boolean;
@ -166,10 +167,11 @@ export interface Game {
turns: number;
longestRoad?: string | false;
longestRoadLength?: number;
borderOrder?: number[];
borderOrder: number[];
largestArmy?: string | false;
largestArmySize?: number;
mostPorts?: string | false;
mostPorts: PlayerColor | null;
mostPortCount: number;
mostDeveloped?: string | false;
private?: boolean;
created?: number;

View File

@ -1,23 +1,7 @@
export function normalizeIncoming(msg: unknown): { type: string | null, data: unknown } {
if (!msg) return { type: null, data: null };
let parsed: unknown = null;
try {
if (typeof msg === 'string') {
parsed = JSON.parse(msg);
} else {
parsed = msg;
}
} catch (e) {
return { type: null, data: null };
}
if (!parsed) return { type: null, data: null };
const type = (parsed as any).type || (parsed as any).action || null;
const data = (parsed as any).data || (Object.keys(parsed as any).length ? Object.assign({}, parsed as any) : null);
return { type, data };
}
export function shuffleArray<T>(array: T[]): T[] {
let currentIndex = array.length, temporaryValue: T | undefined, randomIndex: number;
let currentIndex = array.length,
temporaryValue: T | undefined,
randomIndex: number;
while (0 !== currentIndex) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;

View File

@ -1,19 +1,26 @@
/* WebRTC signaling helpers extracted from games.ts
* Exports:
* - audio: map of gameId -> peers
* - join(peers, session, config, safeSend)
* - part(peers, session, safeSend)
* - handleRelayICECandidate(gameId, cfg, session, safeSend, debug)
* - handleRelaySessionDescription(gameId, cfg, session, safeSend, debug)
* - broadcastPeerStateUpdate(gameId, cfg, session, safeSend)
* - join(peers, session, config)
* - part(peers, session)
* - handleRelayICECandidate(gameId, cfg, session, debug)
* - handleRelaySessionDescription(gameId, cfg, session, debug)
* - broadcastPeerStateUpdate(gameId, cfg, session)
*/
import { Session } from "./games/types";
export const audio: Record<string, any> = {};
// Default send helper used when caller doesn't provide a safeSend implementation.
const defaultSend = (targetOrSession: any, message: any): boolean => {
try {
const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null;
const target =
targetOrSession && typeof targetOrSession.send === "function"
? targetOrSession
: targetOrSession && targetOrSession.ws
? targetOrSession.ws
: null;
if (!target) return false;
target.send(typeof message === "string" ? message : JSON.stringify(message));
return true;
@ -22,13 +29,8 @@ const defaultSend = (targetOrSession: any, message: any): boolean => {
}
};
export const join = (
peers: any,
session: any,
{ hasVideo, hasAudio, has_media }: { hasVideo?: boolean; hasAudio?: boolean; has_media?: boolean },
safeSend?: (targetOrSession: any, message: any) => boolean
): void => {
const send = safeSend ? safeSend : defaultSend;
export const join = (peers: any, session: any): void => {
const send = defaultSend;
const ws = session.ws;
if (!session.name) {
@ -43,23 +45,18 @@ export const join = (
console.log(`${session.short}: <- join - ${session.name}`);
// Determine media capability - prefer has_media if provided
const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio;
if (session.name in peers) {
console.log(`${session.short}:${session.name} - Already joined to Audio, updating WebSocket reference.`);
// Use session.id as the canonical peer key
if (session.id in peers) {
console.log(`${session.short}:${session.id} - Already joined to Audio, updating WebSocket reference.`);
try {
const prev = peers[session.name] && peers[session.name].ws;
const prev = peers[session.id] && peers[session.id].ws;
if (prev && prev._pingInterval) {
clearInterval(prev._pingInterval);
}
} catch (e) {
/* ignore */
}
peers[session.name].ws = ws;
peers[session.name].has_media = peerHasMedia;
peers[session.name].hasAudio = hasAudio;
peers[session.name].hasVideo = hasVideo;
peers[session.id].ws = ws;
send(ws, {
type: "join_status",
@ -67,34 +64,30 @@ export const join = (
message: "Reconnected",
});
for (const peer in peers) {
if (peer === session.name) continue;
// Tell the reconnecting client about existing peers
for (const peerId in peers) {
if (peerId === session.id) continue;
send(ws, {
type: "addPeer",
data: {
peer_id: peer,
peer_name: peer,
has_media: peers[peer].has_media,
peer_id: peerId,
peer_name: peers[peerId].name || peerId,
should_create_offer: true,
hasAudio: peers[peer].hasAudio,
hasVideo: peers[peer].hasVideo,
},
});
}
for (const peer in peers) {
if (peer === session.name) continue;
// Tell existing peers about the reconnecting client
for (const peerId in peers) {
if (peerId === session.id) continue;
send(peers[peer].ws, {
send(peers[peerId].ws, {
type: "addPeer",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
has_media: peerHasMedia,
should_create_offer: false,
hasAudio,
hasVideo,
},
});
}
@ -102,37 +95,32 @@ export const join = (
return;
}
for (let peer in peers) {
send(peers[peer].ws, {
for (let peerId in peers) {
// notify existing peers about the new client
send(peers[peerId].ws, {
type: "addPeer",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
has_media: peers[session.name]?.has_media ?? peerHasMedia,
should_create_offer: false,
hasAudio,
hasVideo,
},
});
// tell the new client about existing peers
send(ws, {
type: "addPeer",
data: {
peer_id: peer,
peer_name: peer,
has_media: peers[peer].has_media,
peer_id: peerId,
peer_name: peers[peerId].name || peerId,
should_create_offer: true,
hasAudio: peers[peer].hasAudio,
hasVideo: peers[peer].hasVideo,
},
});
}
peers[session.name] = {
// Store peer keyed by session.id and keep the display name
peers[session.id] = {
ws,
hasAudio,
hasVideo,
has_media: peerHasMedia,
name: session.name,
};
send(ws, {
@ -142,16 +130,16 @@ export const join = (
});
};
export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => {
export const part = (peers: any, session: any): void => {
const ws = session.ws;
const send = safeSend ? safeSend : defaultSend;
const send = defaultSend;
if (!session.name) {
console.error(`${session.id}: <- part - No name set yet. Audio not available.`);
return;
}
if (!(session.name in peers)) {
if (!(session.id in peers)) {
console.log(`${session.short}: <- ${session.name} - Does not exist in game audio.`);
return;
}
@ -159,34 +147,29 @@ export const part = (peers: any, session: any, safeSend?: (targetOrSession: any,
console.log(`${session.short}: <- ${session.name} - Audio part.`);
console.log(`${session.short}: -> removePeer - ${session.name}`);
delete peers[session.name];
// Remove this peer
delete peers[session.id];
for (let peer in peers) {
send(peers[peer].ws, {
for (let peerId in peers) {
send(peers[peerId].ws, {
type: "removePeer",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
},
});
send(ws, {
type: "removePeer",
data: {
peer_id: peer,
peer_name: peer,
peer_id: peerId,
peer_name: peers[peerId].name || peerId,
},
});
}
};
export const handleRelayICECandidate = (
gameId: string,
cfg: any,
session: any,
safeSend?: (targetOrSession: any, message: any) => boolean,
debug?: any
) => {
const send = safeSend ? safeSend : defaultSend;
export const handleRelayICECandidate = (gameId: string, cfg: any, session: Session, debug?: any) => {
const send = defaultSend;
const ws = session && session.ws;
if (!cfg) {
@ -200,12 +183,13 @@ export const handleRelayICECandidate = (
return;
}
const { peer_id, candidate } = cfg;
if (debug && debug.audio) console.log(`${session.id}:${gameId} <- relayICECandidate ${session.name} to ${peer_id}`, candidate);
if (debug && debug.audio)
console.log(`${session.id}:${gameId} <- relayICECandidate ${session.name} to ${peer_id}`, candidate);
const message = JSON.stringify({
type: "iceCandidate",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
candidate,
},
@ -221,14 +205,8 @@ export const handleRelayICECandidate = (
}
};
export const handleRelaySessionDescription = (
gameId: string,
cfg: any,
session: any,
safeSend?: (targetOrSession: any, message: any) => boolean,
debug?: any
) => {
const send = safeSend ? safeSend : defaultSend;
export const handleRelaySessionDescription = (gameId: string, cfg: any, session: any, debug?: any) => {
const send = defaultSend;
const ws = session && session.ws;
if (!cfg) {
@ -253,7 +231,7 @@ export const handleRelaySessionDescription = (
const message = JSON.stringify({
type: "sessionDescription",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
session_description,
},
@ -268,12 +246,15 @@ export const handleRelaySessionDescription = (
}
};
export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean) => {
const send = safeSend
? safeSend
: (targetOrSession: any, message: any) => {
export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any) => {
const send = (targetOrSession: any, message: any) => {
try {
const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null;
const target =
targetOrSession && typeof targetOrSession.send === "function"
? targetOrSession
: targetOrSession && targetOrSession.ws
? targetOrSession.ws
: null;
if (!target) return false;
target.send(typeof message === "string" ? message : JSON.stringify(message));
return true;
@ -296,24 +277,24 @@ export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any,
const messagePayload = JSON.stringify({
type: "peer_state_update",
data: {
peer_id: session.name,
peer_id: session.id,
peer_name: session.name,
muted,
video_on,
},
});
for (const other in audio[gameId]) {
if (other === session.name) continue;
for (const otherId in audio[gameId]) {
if (otherId === session.id) continue;
try {
const tgt = audio[gameId][other] as any;
const tgt = audio[gameId][otherId] as any;
if (!tgt || !tgt.ws) {
console.warn(`${session.id}:${gameId} peer_state_update - target ${other} has no ws`);
console.warn(`${session.id}:${gameId} peer_state_update - target ${otherId} has no ws`);
} else if (!send(tgt.ws, messagePayload)) {
console.warn(`${session.id}:${gameId} peer_state_update - send failed to ${other}`);
console.warn(`${session.id}:${gameId} peer_state_update - send failed to ${otherId}`);
}
} catch (e) {
console.warn(`Failed sending peer_state_update to ${other}:`, e);
console.warn(`Failed sending peer_state_update to ${otherId}:`, e);
}
}
};

View File

@ -317,13 +317,13 @@ const staticData = {
{ roll: 7, pips: 0 } /* Robber is at the end or indexing gets off */,
],
borders: [
["bank", undefined, "sheep"],
[undefined, "bank", undefined],
["bank", undefined, "brick"],
[undefined, "wood", undefined],
["bank", undefined, "wheat"],
[undefined, "stone", undefined],
],
["bank", "none", "sheep"],
["none", "bank", "none"],
["bank", "none", "brick"],
["none", "wood", "none"],
["bank", "none", "wheat"],
["none", "stone", "none"],
] as ResourceType[][],
};
export {

View File

@ -85,9 +85,13 @@ const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: C
* Volcano is enabled, verify the tile is not the Volcano.
*/
layout.corners.forEach((corner, cornerIndex) => {
const placement = game.placements.corners[cornerIndex]!;
const placement = game.placements && game.placements.corners ? game.placements.corners[cornerIndex] : undefined;
if (!placement) {
// Treat a missing placement as unassigned (no owner)
// Continue processing using a falsy placement where appropriate
}
if (type) {
if (placement.color === color && placement.type === type) {
if (placement && placement.color === color && placement.type === type) {
limits.push(cornerIndex);
}
return;
@ -97,7 +101,7 @@ const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: C
// "unassigned" then it's occupied and should be skipped.
// Note: placement.color may be undefined (initial state), treat that
// the same as unassigned.
if (placement.color && placement.color !== "unassigned") {
if (placement && placement.color && placement.color !== "unassigned") {
return;
}
@ -136,7 +140,8 @@ const getValidCorners = (game: Game, color: PlayerColor = "unassigned", type?: C
/* There is a settlement within one segment from this
* corner, so it is invalid for settlement placement */
const cc = road.corners[c] as number;
const ccColor = game.placements.corners[cc]!.color;
const ccPlacement = game.placements && game.placements.corners ? game.placements.corners[cc] : undefined;
const ccColor = ccPlacement ? ccPlacement.color : undefined;
if (ccColor && ccColor !== "unassigned") {
valid = false;
}