1
0

Lots of type edits

This commit is contained in:
James Ketr 2025-10-09 15:40:39 -07:00
parent d12d87a796
commit 2dae5b7b17
9 changed files with 576 additions and 202 deletions

View File

@ -50,7 +50,10 @@ const App = () => {
useEffect(() => {
if (error) {
setTimeout(() => setError(null), 5000);
setTimeout(() => {
setError(null);
}, 5000);
console.error(`App - error`, error);
}
}, [error]);
@ -121,7 +124,19 @@ const App = () => {
</Router>
)}
{error && (
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
<Paper
className="Error"
sx={{
position: "absolute",
top: 0,
left: 0,
zIndex: 32767,
p: 2,
m: 2,
width: "fit-content",
backgroundColor: "#ffdddd",
}}
>
<Typography color="red">{error}</Typography>
</Paper>
)}

View File

@ -42,8 +42,8 @@
left: 0; /* Start at left of container */
width: 5rem;
height: 3.75rem;
min-width: 1.25rem;
min-height: 0.9375rem;
min-width: 3.5rem;
min-height: 1.8725rem;
z-index: 1200;
border-radius: 0.25rem;
}
@ -68,30 +68,81 @@
}
.MediaControl .Controls {
display: flex;
display: none; /* Hidden by default, shown on hover */
position: absolute;
gap: 0;
left: 0;
bottom: 0;
flex-direction: column;
z-index: 1;
align-items: flex-start;
justify-content: center
right: 0;
min-width: fit-content;
min-height: fit-content;
z-index: 1251; /* Above the Indicators but below active move handles */
background-color: rgba(64, 64, 64, 64);
backdrop-filter: blur(5px);
}
.MediaControl.Small .Controls {
.MediaControl:hover .Controls {
display: flex; /* Show controls on hover */
flex-direction: row;
}
/* Indicators: visual, non-interactive icons anchored lower-left of the container.
They are independent from the interactive Controls and scale responsively. */
.Indicators {
position: absolute;
z-index: 1250; /* Above the video but below active move handles */
/* Use percentage offsets so the indicators scale and stick to lower-left of the target */
left: 4%;
bottom: 4%;
display: flex;
flex-direction: column;
pointer-events: none; /* non-interactive */
align-items: flex-start;
}
.Indicators .IndicatorRow {
display: flex;
gap: 0.12rem;
align-items: center;
}
.Indicators .IndicatorItem {
background: rgba(0, 0, 0, 0.45);
padding: 0.12rem;
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
}
.Indicators .IndicatorItem svg {
/* make svg scale relative to parent (which is sized by the Moveable target) */
width: 1.6rem;
height: 1.6rem;
}
/* Make indicator items size proportionally to the target using relative units */
.Indicators .IndicatorItem {
padding: 0.35rem;
border-radius: 999px;
}
/* Reduce absolute pixel values that may prevent scaling; use clamp for min/max */
.Indicators .IndicatorItem svg {
width: clamp(0.6rem, 6%, 1.2rem);
height: clamp(0.6rem, 6%, 1.2rem);
}
/* Ensure interactive Controls are reachable even when target is small: allow Controls to overflow
the moveable target visually (they are positioned absolute inside the target). */
.MediaControl .Controls {
overflow: visible;
}
.MediaControl .Controls > div {
border-radius: 0.25em;
cursor: pointer;
}
.MediaControl .Controls > div:hover {
background-color: #d0d0d0;
}
.moveable-control-box {
border: none;
--moveable-color: unset !important;

View File

@ -1333,8 +1333,13 @@ const MediaControl: React.FC<MediaControlProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const targetRef = useRef<HTMLDivElement>(null);
const spacerRef = useRef<HTMLDivElement>(null);
const controlsRef = useRef<HTMLDivElement>(null);
const indicatorsRef = useRef<HTMLDivElement>(null);
const moveableRef = useRef<any>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
// Controls expansion state for hover/tap compact mode
const [controlsExpanded, setControlsExpanded] = useState<boolean>(false);
const touchCollapseTimeoutRef = useRef<number | null>(null);
// Get sendJsonMessage from props
// Reset drag state on pointerup/touchend/mouseup anywhere in the document
useEffect(() => {
@ -1644,7 +1649,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
{/* Moveable element - positioned absolute relative to container */}
<div
ref={targetRef}
className={`MediaControl ${className}`}
className={`MediaControl ${className} ${controlsExpanded ? "Expanded" : "Small"}`}
data-peer={peer.session_id}
onDoubleClick={handleDoubleClick}
style={{
@ -1655,8 +1660,67 @@ const MediaControl: React.FC<MediaControlProps> = ({
height: frame.height ? `${frame.height}px` : undefined,
transform: `translate(${frame.translate[0]}px, ${frame.translate[1]}px)`,
}}
onMouseEnter={() => {
// Expand controls for mouse
setControlsExpanded(true);
}}
onMouseLeave={(e) => {
// Collapse when leaving with mouse, but keep expanded if the pointer
// moved into the interactive controls or indicators (which are rendered
// outside the target box to avoid disappearing when target is small).
const related = (e as React.MouseEvent).relatedTarget as Node | null;
try {
if (related && controlsRef.current && controlsRef.current.contains(related)) {
// Pointer moved into the controls; do not collapse
return;
}
if (related && indicatorsRef.current && indicatorsRef.current.contains(related)) {
// Pointer moved into the indicators; keep expanded
return;
}
} catch (err) {
// In some browsers relatedTarget may be null or inaccessible; fall back to collapsing
}
setControlsExpanded(false);
}}
onTouchStart={(e) => {
// Expand on touch; stop propagation so Moveable doesn't interpret as drag start
setControlsExpanded(true);
// Prevent immediate drag when the user intends to tap the controls
e.stopPropagation();
// Start a collapse timeout for touch devices
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
touchCollapseTimeoutRef.current = window.setTimeout(() => setControlsExpanded(false), 4000);
}}
onClick={(e) => {
// Keep controls expanded while user interacts inside
setControlsExpanded(true);
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
}}
>
<Box className="Controls">
{/* Visual indicators: placed inside a clipped container that matches the
Moveable target size so indicators scale with and are clipped by the target. */}
<Box
className="Indicators"
sx={{ display: "flex", flexDirection: "row", color: "grey", pointerEvents: "none" }}
ref={indicatorsRef}
>
{isSelf ? (
<>
{muted ? <MicOff sx={{ height: "100%" }} /> : <Mic />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
) : (
<>
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
{remoteAudioMuted && <MicOff />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
)}
</Box>
{/* Interactive controls: rendered inside target but referenced separately */}
<Box className="Controls" ref={controlsRef}>
{isSelf ? (
<IconButton onClick={toggleMute}>
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}

View File

@ -189,7 +189,7 @@ const PlayerList: React.FC = () => {
/>
{/* If this is the local player and they haven't picked a color, show a picker */}
{player.local && !player.color && (
{player.local && player.color === "unassigned" && (
<div style={{ marginTop: 8, width: "100%" }}>
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
<div style={{ display: "flex", gap: 8 }}>

View File

@ -149,15 +149,15 @@ const RoomView = (props: RoomProps) => {
switch (data.type) {
case "ping":
// Respond to server ping immediately to maintain connection
console.log("App - Received ping from server, sending pong");
console.log("room-view - Received ping from server, sending pong");
sendJsonMessage({ type: "pong" });
break;
case "error":
console.error(`App - error`, data.error);
setError(data.error);
console.error(`room-view - error`, data.error);
setError(data.data.error || JSON.stringify(data));
break;
case "warning":
console.warn(`App - warning`, data.warning);
console.warn(`room-view - warning`, data.warning);
setWarning(data.warning);
setTimeout(() => {
setWarning("");
@ -223,7 +223,7 @@ const RoomView = (props: RoomProps) => {
default:
break;
}
}, [lastJsonMessage, session, setError, setSession]);
}, [lastJsonMessage, session]);
useEffect(() => {
if (state === "volcano") {

View File

@ -16,7 +16,19 @@ import {
} from "./games/constants";
import { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLocations";
import { Player, Game, Session, CornerPlacement, RoadPlacement, Offer, Turn } from "./games/types";
import {
Player,
Game,
Session,
CornerPlacement,
RoadPlacement,
Offer,
Turn,
Tile,
PlayerColor,
PLAYER_COLORS,
RESOURCE_TYPES,
} from "./games/types";
import { newPlayer } from "./games/playerFactory";
import { normalizeIncoming, shuffleArray } from "./games/utils";
import {
@ -125,7 +137,7 @@ const processTies = (players: Player[]): boolean => {
return ties;
};
const processGameOrder = (game: Game, player: Player, dice: number): any => {
const processGameOrder = (game: Game, player: Player, dice: number): string | undefined => {
if (player.orderRoll) {
return `You have already rolled for game order and are not in a tie.`;
}
@ -154,7 +166,7 @@ const processGameOrder = (game: Game, player: Player, dice: number): any => {
players: getFilteredPlayers(game),
chat: game.chat,
});
return;
return undefined;
}
/* sort updated player.order into the array */
@ -176,7 +188,7 @@ const processGameOrder = (game: Game, player: Player, dice: number): any => {
players: getFilteredPlayers(game),
chat: game.chat,
});
return;
return undefined;
}
addChatMessage(
@ -187,11 +199,11 @@ const processGameOrder = (game: Game, player: Player, dice: number): any => {
game.playerOrder = players.map((player) => player.color as string);
game.state = "initial-placement";
(game as any)["direction"] = "forward";
game.direction = "forward";
const first = players[0];
game.turn = {
name: first?.name as string,
color: first?.color as string,
color: first?.color as PlayerColor,
};
setForSettlementPlacement(game, getValidCorners(game, ""));
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
@ -201,14 +213,16 @@ const processGameOrder = (game: Game, player: Player, dice: number): any => {
sendUpdateToPlayers(game, {
players: getFilteredPlayers(game),
state: game.state,
direction: (game as any)["direction"],
direction: game.direction,
turn: game.turn,
chat: game.chat,
activities: game.activities,
});
return undefined;
};
const processVolcano = (game: Game, session: Session, dice: number[]): any => {
const processVolcano = (game: Game, session: Session, dice: number[]) => {
const name = session.name ? session.name : "Unnamed";
void session.player;
@ -232,7 +246,7 @@ const processVolcano = (game: Game, session: Session, dice: number[]): any => {
}
const volcanoIdx = typeof game.turn.volcano === "number" ? game.turn.volcano : undefined;
const corner = volcanoIdx !== undefined ? game.placements.corners[volcanoIdx] : undefined;
if (corner && corner.color) {
if (corner && corner.color && corner.color !== "unassigned") {
const player = game.players[corner.color];
if (player) {
if (corner.type === "city") {
@ -243,14 +257,14 @@ const processVolcano = (game: Game, session: Session, dice: number[]): any => {
corner.type = "settlement";
} else {
addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`);
delete corner.type;
delete corner.color;
corner.type = "none";
corner.color = "unassigned";
player.cities = (player.cities || 0) + 1;
}
} else {
addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
delete corner.type;
delete corner.color;
corner.type = "none";
corner.color = "unassigned";
player.settlements = (player.settlements || 0) + 1;
}
}
@ -266,7 +280,7 @@ const processVolcano = (game: Game, session: Session, dice: number[]): any => {
});
};
const roll = (game: Game, session: Session, dice?: number[] | undefined): any => {
const roll = (game: Game, session: Session, dice?: number[] | undefined): string | undefined => {
const player = session.player as Player,
name = session.name ? session.name : "Unnamed";
@ -281,7 +295,7 @@ const roll = (game: Game, session: Session, dice?: number[] | undefined): any =>
return undefined;
case "game-order":
(game as any)["startTime"] = Date.now();
game.startTime = Date.now();
addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
if (typeof dice[0] !== "number") {
return `Invalid roll value.`;
@ -327,83 +341,105 @@ const sessionFromColor = (game: Game, color: string): Session | undefined => {
return undefined;
};
interface ResourceCount {
wood: number;
brick: number;
sheep: number;
wheat: number;
stone: number;
}
type Received = Record<PlayerColor | "robber", ResourceCount>;
const distributeResources = (game: Game, roll: number): void => {
console.log(`Roll: ${roll}`);
/* Find which tiles have this roll */
const matchedTiles: { robber: boolean; index: number }[] = [];
const matchedTiles: Tile[] = [];
const pipOrder = game.pipOrder || [];
for (let i = 0; i < pipOrder.length; i++) {
const index = pipOrder[i];
if (typeof index === "number" && staticData.pips?.[index] && staticData.pips[index].roll === roll) {
matchedTiles.push({ robber: game.robber === i, index: i });
pipOrder.forEach((pipIndex: number, pos: number) => {
if (staticData.pips?.[pipIndex] && staticData.pips[pipIndex].roll === roll) {
/* TODO: Fix so it isn't hard coded to "wheat" and instead is the correct resource given
* the resource distribution in shuffeled */
matchedTiles.push({ type: "wheat", robber: game.robber === pos, index: pos });
}
}
});
const receives: Record<string, Record<string, number>> = {
O: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
R: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
W: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
B: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
robber: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
};
const receives: Received = {} as Received;
PLAYER_COLORS.forEach((color) => {
receives[color] = { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 };
});
/* Find which corners are on each tile */
matchedTiles.forEach((tile) => {
const tileOrder = game.tileOrder || [];
const gameTiles = game.tiles || [];
const shuffle = tileOrder[tile.index];
const resource = typeof shuffle === "number" ? gameTiles[shuffle] : undefined;
const tileLayout = layout.tiles?.[tile.index];
tileLayout?.corners.forEach((cornerIndex: number) => {
const active = game.placements.corners?.[cornerIndex];
if (active && active.color && resource) {
const count = active.type === "settlement" ? 1 : 2;
if (!tile.robber) {
if (!receives[active.color]) receives[active.color] = { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 };
if (resource && resource.type) (receives as any)[active.color][resource.type] += count;
matchedTiles.forEach((tile: Tile) => {
const tileOrder = game.tileOrder;
const gameTiles = game.tiles;
if (tile.index >= tileOrder.length) {
return;
}
const shuffle = tileOrder[tile.index]!;
const resource = gameTiles[shuffle] ? gameTiles[shuffle] : null;
if (!resource) {
return;
}
const tileLayout = layout.tiles[tile.index];
if (!tileLayout) {
return;
}
tileLayout.corners.forEach((cornerIndex: number) => {
const active = game.placements.corners[cornerIndex];
if (!active) {
return;
}
const count = active.type === "settlement" ? 1 : 2;
if (!tile.robber) {
if (resource.type) {
receives[active.color][resource.type]! += count;
}
} else {
const victim = game.players[active.color];
if (isRuleEnabled(game, `robin-hood-robber`) && victim && (victim.points || 0) <= 2) {
addChatMessage(
game,
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;
} else {
const victim = game.players[active.color];
if (isRuleEnabled(game, `robin-hood-robber`) && victim && (victim.points || 0) <= 2) {
addChatMessage(
game,
null,
`Robber does not steal ${count} ${resource.type} from ${victim?.name} due to Robin Hood Robber house rule.`
);
if (resource && resource.type) (receives as any)[active.color][resource.type] += count;
} else {
trackTheft(game, active.color, "robber", resource.type, count);
if (resource && resource.type) (receives as any)["robber"][resource.type] += count;
}
trackTheft(game, active.color, "robber", resource.type, count);
if (resource.type) receives.robber[resource.type] += count;
}
}
});
});
const robberList: string[] = [];
for (const color in receives) {
PLAYER_COLORS.forEach((color) => {
const entry = receives[color];
if (!entry || !(entry["wood"] || entry["brick"] || entry["sheep"] || entry["wheat"] || entry["stone"])) {
continue;
if (!(entry.wood || entry.brick || entry.sheep || entry.wheat || entry.stone)) {
return;
}
const messageParts: string[] = [];
let s: Session | undefined;
for (const type in entry) {
if (entry[type] === 0) continue;
RESOURCE_TYPES.forEach((type) => {
if (entry[type] === 0) {
return;
}
if (color !== "robber") {
s = sessionFromColor(game, color);
if (!s || !s.player) continue;
(s.player as any)[type] = ((s.player as any)[type] || 0) + entry[type];
(s.player as any).resources = ((s.player as any).resources || 0) + entry[type];
messageParts.push(`${entry[type]} ${type}`);
if (s && s.player) {
s.player[type] += entry[type];
s.player.resources += entry[type];
messageParts.push(`${entry[type]} ${type}`);
}
} else {
robberList.push(`${entry[type]} ${type}`);
}
}
});
if (s) {
addChatMessage(game, s, `${s.name} receives ${messageParts.join(", ")} for pip ${roll}.`);
}
}
});
if (robberList.length) {
addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robberList.join(", ")}!`);
@ -502,7 +538,7 @@ const processRoll = (game: Game, session: Session, dice: number[]): any => {
const vCorners = layout.tiles[volcanoIdx].corners || [];
vCorners.forEach((index: number) => {
const corner = game.placements.corners[index];
if (corner && corner.color) {
if (corner && corner.color && corner.color !== "unassigned") {
if (!game.turn.select) {
game.turn.select = {} as Record<string, number>;
}
@ -649,7 +685,7 @@ const getSession = (game: Game, id: string): Session => {
id: id,
short: `[${id.substring(0, 8)}]`,
name: "",
color: "",
color: "unassigned",
lastActive: Date.now(),
live: true,
};
@ -669,7 +705,8 @@ const getSession = (game: Game, id: string): Session => {
if (!_session) {
continue;
}
if (_session.color || _session.name || _session.player) {
// Treat the explicit "unassigned" sentinel as not set for expiring sessions
if ((_session.color && _session.color !== "unassigned") || _session.name || _session.player) {
continue;
}
if (_id === id) {
@ -754,7 +791,7 @@ const loadGame = async (id: string) => {
game.unselected = [];
for (let id in game.sessions) {
const session = game.sessions[id];
if (session.name && session.color && session.color in game.players) {
if (session.name && session.color && session.color !== "unassigned" && session.color in game.players) {
session.player = game.players[session.color];
session.player.name = session.name;
session.player.status = "Active";
@ -767,11 +804,57 @@ const loadGame = async (id: string) => {
session.live = false;
/* Populate the 'unselected' list from the session table */
if (!game.sessions[id].color && game.sessions[id].name) {
if ((!game.sessions[id].color || game.sessions[id].color === "unassigned") && game.sessions[id].name) {
game.unselected.push(game.sessions[id]);
}
}
/* Reconstruct turn.limits if in initial-placement state and limits are missing */
if (
game.state === "initial-placement" &&
game.turn &&
(!game.turn.limits || Object.keys(game.turn.limits).length === 0)
) {
console.log(`${info}: Reconstructing turn.limits for initial-placement state after reload`);
const currentColor = game.turn.color || "";
// Check if we need to place a settlement (no action or place-settlement action)
if (!game.turn.actions || game.turn.actions.length === 0 || game.turn.actions.indexOf("place-settlement") !== -1) {
setForSettlementPlacement(game, getValidCorners(game, currentColor ? currentColor : ""));
console.log(
`${info}: Set turn limits for settlement placement (${game.turn.limits?.corners?.length || 0} valid corners)`
);
}
// Check if we need to place a road
else if (game.turn.actions.indexOf("place-road") !== -1) {
// Find the most recently placed settlement by the current player
let mostRecentSettlementIndex = -1;
if (game.placements && game.placements.corners) {
// Look for settlements of the current player's color
for (let i = game.placements.corners.length - 1; i >= 0; i--) {
const corner = game.placements.corners[i];
if (corner && corner.color === currentColor && corner.type === "settlement") {
mostRecentSettlementIndex = i;
break;
}
}
}
if (mostRecentSettlementIndex >= 0 && layout.corners?.[mostRecentSettlementIndex]?.roads) {
const roads = layout.corners?.[mostRecentSettlementIndex]?.roads || [];
setForRoadPlacement(game, roads);
console.log(
`${info}: Set turn limits for road placement (${roads.length} valid roads from settlement ${mostRecentSettlementIndex})`
);
} else {
// Fallback: Allow all valid roads for this player
const roads = getValidRoads(game, currentColor);
setForRoadPlacement(game, roads);
console.log(`${info}: Set turn limits for road placement (fallback: ${roads.length} valid roads)`);
}
}
}
games[id] = game;
return game;
};
@ -857,7 +940,7 @@ const adminCommands = (game: Game, action: string, value: string, query: any): a
if (session.player.roads === 0) {
return `Player ${game.turn.name} does not have any more roads to give.`;
}
let roads = getValidRoads(game, session.color);
let roads = getValidRoads(game, session.color === "unassigned" ? "" : session.color);
if (roads.length === 0) {
return `There are no valid locations for ${game.turn.name} to place a road.`;
}
@ -874,7 +957,7 @@ const adminCommands = (game: Game, action: string, value: string, query: any): a
if (session.player.cities === 0) {
return `Player ${game.turn.name} does not have any more cities to give.`;
}
corners = getValidCorners(game, session.color, "settlement");
corners = getValidCorners(game, session.color === "unassigned" ? "" : session.color, "settlement");
if (corners.length === 0) {
return `There are no valid locations for ${game.turn.name} to place a settlement.`;
}
@ -891,7 +974,7 @@ const adminCommands = (game: Game, action: string, value: string, query: any): a
if (session.player.settlements === 0) {
return `Player ${game.turn.name} does not have any more settlements to give.`;
}
corners = getValidCorners(game, session.color);
corners = getValidCorners(game, session.color === "unassigned" ? "" : session.color);
if (corners.length === 0) {
return `There are no valid locations for ${game.turn.name} to place a settlement.`;
}
@ -963,7 +1046,7 @@ const adminCommands = (game: Game, action: string, value: string, query: any): a
color = "W";
break;
}
if (corner && corner.color) {
if (corner && corner.color && corner.color !== "unassigned") {
const player = game.players ? game.players[corner.color] : undefined;
if (player) {
if (corner.type === "city") {
@ -1031,7 +1114,7 @@ const adminCommands = (game: Game, action: string, value: string, query: any): a
color = "W";
break;
}
if (corner && corner.color) {
if (corner && corner.color && corner.color !== "unassigned") {
const player = game.players[corner.color];
if (player) {
if (corner.type === "city") {
@ -1089,7 +1172,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
if (session.name === name) {
return; /* no-op */
}
if (session.color) {
if (session.color !== "unassigned") {
return `You cannot change your name while you have a color selected.`;
}
@ -1129,7 +1212,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
message = `A new player has entered the lobby as ${name}.`;
} else {
if (rejoin) {
if (session.color) {
if (session.color !== "unassigned") {
message = `${name} has reconnected to the game.`;
} else {
message = `${name} has rejoined the lobby.`;
@ -1167,7 +1250,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
addChatMessage(game, null, message);
/* Rebuild the unselected list */
if (!session.color) {
if (session.color === "unassigned") {
console.log(`${info}: Adding ${session.name} to the unselected`);
}
game.unselected = [];
@ -1223,7 +1306,7 @@ const getActiveCount = (game: Game): number => {
return active;
};
const setPlayerColor = (game: Game, session: Session, color: string): string | undefined => {
const setPlayerColor = (game: Game, session: Session, color: PlayerColor): string | undefined => {
/* Selecting the same color is a NO-OP */
if (session.color === color) {
return;
@ -1262,7 +1345,7 @@ const setPlayerColor = (game: Game, session: Session, color: string): string | u
// remove the player association
delete (session as any).player;
const old_color = session.color;
session.color = "";
session.color = "unassigned";
active--;
/* If the player is not selecting a color, then return */
@ -1347,7 +1430,7 @@ const setPlayerColor = (game: Game, session: Session, color: string): string | u
const processCorner = (game: Game, color: string, cornerIndex: number, placedCorner: CornerPlacement): number => {
/* If this corner is allocated and isn't assigned to the walking color, skip it */
if (placedCorner.color && placedCorner.color !== color) {
if (placedCorner.color && placedCorner.color !== "unassigned" && placedCorner.color !== color) {
return 0;
}
/* If this corner is already being walked, skip it */
@ -1386,7 +1469,7 @@ const buildCornerGraph = (
set: any
): void => {
/* If this corner is allocated and isn't assigned to the walking color, skip it */
if (placedCorner.color && placedCorner.color !== color) {
if (placedCorner.color && placedCorner.color !== "unassigned" && placedCorner.color !== color) {
return;
}
/* If this corner is already being walked, skip it */
@ -1492,7 +1575,7 @@ const calculateRoadLengths = (game: Game, session: Session): void => {
let graphs: { color: string; set: number[]; longestRoad?: number; longestStartSegment?: number }[] = [];
layout.roads.forEach((_: any, roadIndex: number) => {
const placedRoad = game.placements?.roads?.[roadIndex];
if (placedRoad && placedRoad.color && typeof placedRoad.color === "string") {
if (placedRoad && placedRoad.color && placedRoad.color !== "unassigned" && typeof placedRoad.color === "string") {
let set: number[] = [];
buildRoadGraph(game, placedRoad.color, roadIndex, placedRoad, set);
if (set.length) {
@ -1542,7 +1625,7 @@ const calculateRoadLengths = (game: Game, session: Session): void => {
clearRoadWalking(game);
const longestRoad = processRoad(game, placedRoad.color as string, roadIndex, placedRoad);
placedRoad["longestRoad"] = longestRoad;
if (placedRoad.color && typeof placedRoad.color === "string") {
if (placedRoad.color && placedRoad.color !== "unassigned" && typeof placedRoad.color === "string") {
const player = game.players[placedRoad.color];
if (player) {
const prevVal = player["longestRoad"] || 0;
@ -1918,8 +2001,8 @@ const processOffer = (game: Game, session: Session, offer: Offer): string | unde
(player as any)["gets"] = (offer as any)["gets"];
(player as any)["offerRejected"] = {};
if ((game.turn as any)["color"] === session.color) {
(game.turn as any)["offer"] = offer;
if (game.turn.color === session.color) {
game.turn.offer = offer;
}
/* If this offer matches what another player wants, clear rejection on that other player's offer */
@ -2755,9 +2838,12 @@ const placeSettlement = (game: Game, session: Session, index: number | string):
return undefined;
};
const placeRoad = (game: any, session: any, index: any): string | undefined => {
const player = session.player;
if (typeof index === "string") index = parseInt(index);
const placeRoad = (game: Game, session: Session, index: number): string | undefined => {
if (!session.player) {
return `You are not playing a player.`;
}
const player: Player = session.player;
if (!game || !game.turn) {
return `Invalid game state.`;
@ -2777,7 +2863,7 @@ const placeRoad = (game: any, session: any, index: any): string | undefined => {
const road = game.placements.roads[index];
if (road.color) {
return `This location already has a road belonging to ${game.players[road.color].name}!`;
return `This location already has a road belonging to ${game.players[road.color]?.name}!`;
}
if (game.state === "normal") {
@ -2807,8 +2893,98 @@ const placeRoad = (game: any, session: any, index: any): string | undefined => {
road.type = "road";
player.roads--;
game.turn.actions = [];
game.turn.limits = {};
/* During initial placement, placing a road advances the initial-placement
* sequence. In forward direction we move to the next player; when the
* last player places their road we flip to backward and begin the reverse
* settlement placements. In backward direction we move to the previous
* player and when the first player finishes, initial placement is done
* and normal play begins. */
if (game.state === "initial-placement") {
const order: string[] = game.playerOrder || [];
const idx = order.indexOf(session.color);
// defensive: if player not found, just clear actions and continue
if (idx === -1 || order.length === 0) {
game.turn.actions = [];
game.turn.limits = {};
} else {
const direction = game.direction || "forward";
if (direction === "forward") {
if (idx === order.length - 1) {
// Last player in forward pass: switch to backward and allow that
// same last player to place a settlement to begin reverse pass.
game.direction = "backward";
const nextColor = order[order.length - 1];
if (nextColor && game.players && game.players[nextColor]) {
const limits = getValidCorners(game, "");
console.log(
`${info}: initial-placement - ${
session.name
} placed road; direction=forward; next=${nextColor}; nextName=${game.players[nextColor].name}; corners=${
limits ? limits.length : 0
}`
);
game.turn = {
name: game.players[nextColor].name,
color: nextColor,
} as unknown as Turn;
// During initial placement, settlements may be placed on any valid corner
setForSettlementPlacement(game, limits);
}
addChatMessage(
game,
null,
`Initial placement now proceeds in reverse order. It is ${game.turn.name}'s turn to place a settlement.`
);
} else {
const nextColor = order[idx + 1];
if (nextColor && game.players && game.players[nextColor]) {
game.turn = {
name: game.players[nextColor].name,
color: nextColor,
} as unknown as Turn;
setForSettlementPlacement(game, getValidCorners(game, nextColor as string));
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
}
}
} else {
// backward
if (idx === 0) {
// Finished reverse initial placement; move to normal play and first player's turn
game.state = "normal";
const firstColor = order[0];
if (firstColor && game.players && game.players[firstColor]) {
game.turn = {
name: game.players[firstColor].name,
color: firstColor,
} as unknown as Turn;
}
addChatMessage(game, null, `Initial placement complete. It is ${game.turn.name}'s turn.`);
} else {
const nextColor = order[idx - 1];
if (nextColor && game.players && game.players[nextColor]) {
const limits = getValidCorners(game, "");
console.log(
`${info}: initial-placement - ${
session.name
} placed road; direction=backward; next=${nextColor}; nextName=${game.players[nextColor].name}; corners=${
limits ? limits.length : 0
}`
);
game.turn = {
name: game.players[nextColor].name,
color: nextColor,
} as unknown as Turn;
// During initial placement, settlements may be placed on any valid corner
setForSettlementPlacement(game, limits);
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
}
}
}
}
} else {
game.turn.actions = [];
game.turn.limits = {};
}
calculateRoadLengths(game, session);
@ -2821,6 +2997,8 @@ const placeRoad = (game: any, session: any, index: any): string | undefined => {
chat: game.chat,
activities: game.activities,
players: getFilteredPlayers(game),
state: game.state,
direction: (game as any)["direction"],
});
return undefined;
};
@ -3306,7 +3484,7 @@ const placeCity = (game: any, session: any, index: any): string | undefined => {
return `You tried to cheat! You should not try to break the rules.`;
}
const corner = game.placements.corners[index];
if (corner.color !== session.color) {
if (corner.color !== "unassigned" && corner.color !== session.color) {
return `This location already has a settlement belonging to ${game.players[corner.color].name}!`;
}
if (corner.type !== "settlement") {
@ -3429,7 +3607,7 @@ const setGameState = (game: any, session: any, state: any): string | undefined =
return `Invalid state.`;
}
if (!session.color) {
if (session.color === "unassigned") {
return `You must have an active player to start the game.`;
}
@ -3533,7 +3711,7 @@ const departLobby = (game: any, session: any, _color?: string): void => {
}
if (session.name) {
if (session.color) {
if (session.color !== "unassigned") {
addChatMessage(game, null, `${session.name} has disconnected ` + `from the game.`);
} else {
addChatMessage(game, null, `${session.name} has left the lobby.`);
@ -4105,6 +4283,18 @@ router.ws("/ws/:id", async (ws, req) => {
console.log(`${short}: ws.on('error') - stack:`, new Error().stack);
// Only close the session.ws if it is the same socket that errored.
if (session.ws && session.ws === ws) {
// Clear ping interval
if (session.pingInterval) {
clearInterval(session.pingInterval);
session.pingInterval = undefined;
}
// Clear keepAlive timeout
if (session.keepAlive) {
clearTimeout(session.keepAlive);
session.keepAlive = undefined;
}
try {
session.ws.close();
} catch (e) {
@ -4298,16 +4488,17 @@ router.ws("/ws/:id", async (ws, req) => {
let warning: string | void | undefined;
let processed = true;
// The initial-game snapshot is sent from the connection attach path to
// ensure it is only sent once per websocket lifecycle. Avoid sending it
// here from the message handler to prevent duplicate snapshots when a
// client sends messages during the attach/reconnect sequence.
// The initial-game snapshot is sent from the connection attach path to
// ensure it is only sent once per websocket lifecycle. Avoid sending it
// here from the message handler to prevent duplicate snapshots when a
// client sends messages during the attach/reconnect sequence.
switch (incoming.type) {
case "join":
// Accept either legacy `config` or newer `data` field from clients
webrtcJoin(audio[gameId], session, data.config || data.data || {});
// 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 || {});
break;
case "part":
@ -4317,21 +4508,21 @@ router.ws("/ws/:id", async (ws, req) => {
case "relayICECandidate":
{
// Delegate to the webrtc signaling helper (it performs its own checks)
const cfg = data.config || data.data || {};
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
handleRelayICECandidate(gameId, cfg, session, undefined, debug);
}
break;
case "relaySessionDescription":
{
const cfg = data.config || data.data || {};
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
handleRelaySessionDescription(gameId, cfg, session, undefined, debug);
}
break;
case "pong":
console.log(`${short}: Received pong from ${getName(session)}`);
// Clear the keepAlive timeout since we got a response
if (session.keepAlive) {
clearTimeout(session.keepAlive);
@ -4342,15 +4533,9 @@ router.ws("/ws/:id", async (ws, req) => {
if (session.ping) {
session.lastPong = Date.now();
const latency = session.lastPong - session.ping;
// Only accept latency values that are within a reasonable window
// (e.g. 0 - 60s). Ignore stale or absurdly large stored ping
// timestamps which can occur if session state was persisted or
// restored with an old ping value.
if (latency >= 0 && latency < 60000) {
console.log(`${short}: Latency: ${latency}ms`);
} else {
console.warn(`${short}: Ignoring stale ping value; computed latency ${latency}ms`);
}
console.log(`${short}: Received pong from ${getName(session)}. Latency: ${latency}ms`);
} else {
console.log(`${short}: Received pong from ${getName(session)}.`);
}
// No need to resetDisconnectCheck since it's non-functional
@ -4363,7 +4548,8 @@ router.ws("/ws/:id", async (ws, req) => {
case "peer_state_update":
{
const cfg = data.config || data.data || {};
// Accept either config/data or a flat payload (data).
const cfg = data.config || data.data || data || {};
broadcastPeerStateUpdate(gameId, cfg, session, undefined);
}
break;
@ -4594,7 +4780,7 @@ router.ws("/ws/:id", async (ws, req) => {
break;
case "place-road":
console.log(`${short}: <- place-road:${getName(session)} ${data.index}`);
warning = placeRoad(game, session, data.index);
warning = placeRoad(game, session, parseInt(data.index));
if (warning) {
sendWarning(session, warning);
}
@ -4779,7 +4965,7 @@ router.ws("/ws/:id", async (ws, req) => {
}
if (session.name) {
if (session.color) {
if (session.color !== "unassigned") {
addChatMessage(game, null, `${session.name} has reconnected to the game.`);
} else {
addChatMessage(game, null, `${session.name} has rejoined the lobby.`);
@ -5120,7 +5306,7 @@ const resetGame = (game: any) => {
/* Ensure sessions are connected to player objects */
for (let key in game.sessions) {
const session = game.sessions[key];
if (session.color) {
if (session.color !== "unassigned") {
game.active++;
session.player = game.players[session.color];
session.player.status = "Active";
@ -5162,7 +5348,7 @@ const createGame = async (id: any) => {
}
console.log(`${info}: creating ${id}`);
const game = {
const game: Game = {
id: id,
developmentCards: [],
players: {
@ -5179,7 +5365,7 @@ const createGame = async (id: any) => {
},
turn: {
name: "",
color: "",
color: "unassigned",
actions: [],
limits: {},
roll: 0,
@ -5189,6 +5375,11 @@ const createGame = async (id: any) => {
points: 10,
},
},
pipOrder: [],
borderOrder: [],
tileOrder: [],
tiles: [],
pips: [],
step: 0 /* used for the suffix # in game backups */,
};

View File

@ -7,7 +7,8 @@ export const addActivity = (game: Game, session: Session | null, message: string
if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
date++;
}
game.activities.push({ color: session ? session.color : "", message, date });
const actColor = session && session.color && session.color !== "unassigned" ? session.color : "";
game.activities.push({ color: actColor, message, date });
if (game.activities.length > 30) {
game.activities.splice(0, game.activities.length - 30);
}
@ -34,7 +35,7 @@ export const addChatMessage = (game: Game, session: Session | null, message: str
if (session && session.name) {
entry.from = session.name;
}
if (session && session.color) {
if (session && session.color && session.color !== "unassigned") {
entry.color = session.color;
}
game.chat.push(entry);
@ -47,7 +48,7 @@ export const getColorFromName = (game: Game, name: string): string => {
for (let id in game.sessions) {
const s = game.sessions[id];
if (s && s.name === name) {
return s.color || "";
return s.color && s.color !== "unassigned" ? s.color : "";
}
}
return "";

View File

@ -8,59 +8,59 @@ export interface TransientGameState {
}
export interface Player {
name?: string;
color?: string;
name: string;
color: PlayerColor;
order: number;
orderRoll?: number;
position?: string;
orderStatus?: string;
tied?: boolean;
roads?: number;
settlements?: number;
cities?: number;
longestRoad?: number;
orderRoll: number;
position: string;
orderStatus: string;
tied: boolean;
roads: number;
settlements: number;
cities: number;
longestRoad: number;
mustDiscard?: number;
sheep?: number;
wheat?: number;
stone?: number;
brick?: number;
wood?: number;
points?: number;
resources?: number;
lastActive?: number;
live?: boolean;
status?: string;
developmentCards?: number;
development?: DevelopmentCard[];
turnNotice?: string;
turnStart?: number;
totalTime?: number;
sheep: number;
wheat: number;
stone: number;
brick: number;
wood: number;
points: number;
resources: number;
lastActive: number;
live: boolean;
status: string;
developmentCards: number;
development: DevelopmentCard[];
turnNotice: string;
turnStart: number;
totalTime: number;
[key: string]: any; // allow incremental fields until fully typed
}
export interface CornerPlacement {
color?: string;
type?: "settlement" | "city";
color: PlayerColor;
type: "settlement" | "city" | "none";
walking?: boolean;
longestRoad?: number;
[key: string]: any;
}
export interface RoadPlacement {
color?: string;
color?: PlayerColor;
walking?: boolean;
[key: string]: any;
type?: "road" | "ship";
longestRoad?: number;
}
export interface Placements {
corners: CornerPlacement[];
roads: RoadPlacement[];
[key: string]: any;
}
export interface Turn {
name?: string;
color?: string;
color?: PlayerColor;
actions?: string[];
limits?: any;
roll?: number;
@ -71,6 +71,7 @@ export interface Turn {
active?: string;
robberInAction?: boolean;
placedRobber?: number;
offer?: Offer;
[key: string]: any;
}
@ -81,7 +82,7 @@ export interface DevelopmentCard {
}
// Import from schema for DRY compliance
import { TransientSessionState } from './transientSchema';
import { TransientSessionState } from "./transientSchema";
/**
* Persistent Session data (saved to DB)
@ -89,7 +90,7 @@ import { TransientSessionState } from './transientSchema';
export interface PersistentSessionData {
id: string;
name: string;
color: string;
color: PlayerColor;
lastActive: number;
userId?: number;
player?: Player;
@ -97,6 +98,9 @@ export interface PersistentSessionData {
resources?: number;
}
export type PlayerColor = "R" | "B" | "O" | "W" | "robber" | "unassigned";
export const PLAYER_COLORS = ["R", "B", "O", "W", "robber"] as PlayerColor[];
/**
* Runtime Session type = Persistent + Transient
* At runtime, sessions have both persistent and transient fields
@ -114,6 +118,17 @@ export interface Offer {
[key: string]: any;
}
type ResourceType = "wood" | "brick" | "sheep" | "wheat" | "stone";
export const RESOURCE_TYPES = ["wood", "brick", "sheep", "wheat", "stone"] as ResourceType[];
export interface Tile {
robber: boolean;
index: number;
type: ResourceType;
resource?: ResourceKey | null;
roll?: number | null;
}
export interface Game {
id: string;
developmentCards: DevelopmentCard[];
@ -127,10 +142,10 @@ export interface Game {
step?: number;
placements: Placements;
turn: Turn;
pipOrder?: number[];
tileOrder?: number[];
pipOrder: number[];
tileOrder: number[];
resources?: number;
tiles?: any[];
tiles: Tile[];
pips?: any[];
dice?: number[];
chat?: any[];
@ -152,7 +167,8 @@ export interface Game {
lastActivity?: number;
signature?: string;
animationSeeds?: number[];
[key: string]: any;
startTime?: number;
direction?: "forward" | "backward";
}
export type IncomingMessage = { type: string | null; data: any };

View File

@ -12,19 +12,33 @@ const getValidRoads = (game: any, color: string): number[] => {
* has a matching color, add this to the set. Otherwise skip.
*/
layout.roads.forEach((road, roadIndex) => {
if (!game.placements || !game.placements.roads || game.placements.roads[roadIndex]?.color) {
// Skip if placements or roads missing, or if this road is already occupied
// Treat the explicit sentinel "unassigned" as "not available" so only
// consider a road occupied when a color is present and not "unassigned".
if (
!game.placements ||
!game.placements.roads ||
(game.placements.roads[roadIndex] &&
game.placements.roads[roadIndex].color &&
game.placements.roads[roadIndex].color !== "unassigned")
) {
return;
}
let valid = false;
for (let c = 0; !valid && c < road.corners.length; c++) {
for (let c = 0; !valid && c < road.corners.length; c++) {
const cornerIndex = road.corners[c] as number;
if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) {
continue;
}
const corner = (layout as any).corners[cornerIndex];
const cornerColor = (game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cornerIndex] && (game as any).placements.corners[cornerIndex].color;
/* Roads do not pass through other player's settlements */
if (cornerColor && cornerColor !== color) {
const cornerColor =
(game as any).placements &&
(game as any).placements.corners &&
(game as any).placements.corners[cornerIndex] &&
(game as any).placements.corners[cornerIndex].color;
/* Roads do not pass through other player's settlements.
* Consider a corner with color === "unassigned" as empty. */
if (cornerColor && cornerColor !== "unassigned" && cornerColor !== color) {
continue;
}
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
@ -33,7 +47,9 @@ const getValidRoads = (game: any, color: string): number[] => {
continue;
}
const rr = corner.roads[r];
if (rr == null) { continue; }
if (rr == null) {
continue;
}
const placementsRoads = (game as any).placements && (game as any).placements.roads;
if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) {
valid = true;
@ -75,7 +91,9 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
return;
}
if (placement.color) {
// If the corner has a color set and it's not the explicit sentinel
// "unassigned" then it's occupied and should be skipped.
if (placement.color && placement.color !== "unassigned") {
return;
}
@ -86,19 +104,25 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
valid = false;
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
const rr = corner.roads[r];
if (rr == null) { continue; }
if (rr == null) {
continue;
}
const placementsRoads = (game as any).placements && (game as any).placements.roads;
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
}
}
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
if (!corner.roads) { break; }
const ridx = corner.roads[r] as number;
if (ridx == null || (layout as any).roads[ridx] == null) { continue; }
const road = (layout as any).roads[ridx];
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
if (!corner.roads) {
break;
}
const ridx = corner.roads[r] as number;
if (ridx == null || (layout as any).roads[ridx] == null) {
continue;
}
const road = (layout as any).roads[ridx];
for (let c = 0; valid && c < (road.corners || []).length; c++) {
/* This side of the road is pointing to the corner being validated.
/* This side of the road is pointing to the corner being validated.
* Skip it. */
if (road.corners[c] === cornerIndex) {
continue;
@ -106,7 +130,13 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
/* There is a settlement within one segment from this
* corner, so it is invalid for settlement placement */
const cc = road.corners[c] as number;
if ((game as any).placements && (game as any).placements.corners && (game as any).placements.corners[cc] && (game as any).placements.corners[cc].color) {
if (
(game as any).placements &&
(game as any).placements.corners &&
(game as any).placements.corners[cc] &&
(game as any).placements.corners[cc].color &&
(game as any).placements.corners[cc].color !== "unassigned"
) {
valid = false;
}
}
@ -115,10 +145,16 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
/* During initial placement, if volcano is enabled, do not allow
* placement on a corner connected to the volcano (robber starts
* on the volcano) */
if (!(game.state === 'initial-placement'
&& isRuleEnabled(game, 'volcano')
&& (layout as any).tiles && (layout as any).tiles[(game as any).robber] && Array.isArray((layout as any).tiles[(game as any).robber].corners) && (layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1
)) {
if (
!(
game.state === "initial-placement" &&
isRuleEnabled(game, "volcano") &&
(layout as any).tiles &&
(layout as any).tiles[(game as any).robber] &&
Array.isArray((layout as any).tiles[(game as any).robber].corners) &&
(layout as any).tiles[(game as any).robber].corners.indexOf(cornerIndex as number) !== -1
)
) {
limits.push(cornerIndex);
}
}