From 2dae5b7b170722fac90d676106fea39f524f3e24 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Thu, 9 Oct 2025 15:40:39 -0700 Subject: [PATCH] Lots of type edits --- client/src/App.tsx | 19 +- client/src/MediaControl.css | 77 +++++- client/src/MediaControl.tsx | 68 +++++- client/src/PlayerList.tsx | 2 +- client/src/RoomView.tsx | 10 +- server/routes/games.ts | 431 ++++++++++++++++++++++++--------- server/routes/games/helpers.ts | 7 +- server/routes/games/types.ts | 90 ++++--- server/util/validLocations.ts | 74 ++++-- 9 files changed, 576 insertions(+), 202 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8af21fd..91b1d2a 100755 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 = () => { )} {error && ( - + {error} )} diff --git a/client/src/MediaControl.css b/client/src/MediaControl.css index 2d357fe..6e45cb2 100644 --- a/client/src/MediaControl.css +++ b/client/src/MediaControl.css @@ -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; diff --git a/client/src/MediaControl.tsx b/client/src/MediaControl.tsx index 862e177..1c64162 100644 --- a/client/src/MediaControl.tsx +++ b/client/src/MediaControl.tsx @@ -1333,8 +1333,13 @@ const MediaControl: React.FC = ({ const containerRef = useRef(null); const targetRef = useRef(null); const spacerRef = useRef(null); + const controlsRef = useRef(null); + const indicatorsRef = useRef(null); const moveableRef = useRef(null); const [isDragging, setIsDragging] = useState(false); + // Controls expansion state for hover/tap compact mode + const [controlsExpanded, setControlsExpanded] = useState(false); + const touchCollapseTimeoutRef = useRef(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 = ({ {/* Moveable element - positioned absolute relative to container */}
= ({ 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); + }} > - + {/* Visual indicators: placed inside a clipped container that matches the + Moveable target size so indicators scale with and are clipped by the target. */} + + {isSelf ? ( + <> + {muted ? : } + {videoOn ? : } + + ) : ( + <> + {muted ? : } + {remoteAudioMuted && } + {videoOn ? : } + + )} + + + {/* Interactive controls: rendered inside target but referenced separately */} + {isSelf ? ( {muted ? : } diff --git a/client/src/PlayerList.tsx b/client/src/PlayerList.tsx index 3680a64..16efdcf 100644 --- a/client/src/PlayerList.tsx +++ b/client/src/PlayerList.tsx @@ -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" && (
Pick your color:
diff --git a/client/src/RoomView.tsx b/client/src/RoomView.tsx index 77b20f0..23e938d 100644 --- a/client/src/RoomView.tsx +++ b/client/src/RoomView.tsx @@ -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") { diff --git a/server/routes/games.ts b/server/routes/games.ts index 0b36466..e11c1ca 100755 --- a/server/routes/games.ts +++ b/server/routes/games.ts @@ -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; + 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> = { - 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; } @@ -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 */, }; diff --git a/server/routes/games/helpers.ts b/server/routes/games/helpers.ts index 3ce799a..fab09de 100644 --- a/server/routes/games/helpers.ts +++ b/server/routes/games/helpers.ts @@ -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 ""; diff --git a/server/routes/games/types.ts b/server/routes/games/types.ts index 990d251..71cebdd 100644 --- a/server/routes/games/types.ts +++ b/server/routes/games/types.ts @@ -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 }; diff --git a/server/util/validLocations.ts b/server/util/validLocations.ts index ef612ee..6504330 100644 --- a/server/util/validLocations.ts +++ b/server/util/validLocations.ts @@ -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); } }