Lots of type edits
This commit is contained in:
parent
d12d87a796
commit
2dae5b7b17
@ -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>
|
||||
)}
|
||||
|
@ -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;
|
||||
|
@ -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} />}
|
||||
|
@ -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 }}>
|
||||
|
@ -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") {
|
||||
|
@ -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 */,
|
||||
};
|
||||
|
||||
|
@ -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 "";
|
||||
|
@ -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 };
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user