Lots of type edits
This commit is contained in:
parent
d12d87a796
commit
2dae5b7b17
@ -50,7 +50,10 @@ const App = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
setTimeout(() => setError(null), 5000);
|
setTimeout(() => {
|
||||||
|
setError(null);
|
||||||
|
}, 5000);
|
||||||
|
console.error(`App - error`, error);
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
@ -121,7 +124,19 @@ const App = () => {
|
|||||||
</Router>
|
</Router>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{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>
|
<Typography color="red">{error}</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
@ -42,8 +42,8 @@
|
|||||||
left: 0; /* Start at left of container */
|
left: 0; /* Start at left of container */
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
height: 3.75rem;
|
height: 3.75rem;
|
||||||
min-width: 1.25rem;
|
min-width: 3.5rem;
|
||||||
min-height: 0.9375rem;
|
min-height: 1.8725rem;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
@ -68,30 +68,81 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.MediaControl .Controls {
|
.MediaControl .Controls {
|
||||||
display: flex;
|
display: none; /* Hidden by default, shown on hover */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
gap: 0;
|
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
flex-direction: column;
|
right: 0;
|
||||||
z-index: 1;
|
min-width: fit-content;
|
||||||
align-items: flex-start;
|
min-height: fit-content;
|
||||||
justify-content: center
|
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;
|
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 {
|
.MediaControl .Controls > div {
|
||||||
border-radius: 0.25em;
|
border-radius: 0.25em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MediaControl .Controls > div:hover {
|
|
||||||
background-color: #d0d0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.moveable-control-box {
|
.moveable-control-box {
|
||||||
border: none;
|
border: none;
|
||||||
--moveable-color: unset !important;
|
--moveable-color: unset !important;
|
||||||
|
@ -1333,8 +1333,13 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const targetRef = useRef<HTMLDivElement>(null);
|
const targetRef = useRef<HTMLDivElement>(null);
|
||||||
const spacerRef = useRef<HTMLDivElement>(null);
|
const spacerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const controlsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const indicatorsRef = useRef<HTMLDivElement>(null);
|
||||||
const moveableRef = useRef<any>(null);
|
const moveableRef = useRef<any>(null);
|
||||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
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
|
// Get sendJsonMessage from props
|
||||||
// Reset drag state on pointerup/touchend/mouseup anywhere in the document
|
// Reset drag state on pointerup/touchend/mouseup anywhere in the document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1644,7 +1649,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
|||||||
{/* Moveable element - positioned absolute relative to container */}
|
{/* Moveable element - positioned absolute relative to container */}
|
||||||
<div
|
<div
|
||||||
ref={targetRef}
|
ref={targetRef}
|
||||||
className={`MediaControl ${className}`}
|
className={`MediaControl ${className} ${controlsExpanded ? "Expanded" : "Small"}`}
|
||||||
data-peer={peer.session_id}
|
data-peer={peer.session_id}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
style={{
|
style={{
|
||||||
@ -1655,8 +1660,67 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
|||||||
height: frame.height ? `${frame.height}px` : undefined,
|
height: frame.height ? `${frame.height}px` : undefined,
|
||||||
transform: `translate(${frame.translate[0]}px, ${frame.translate[1]}px)`,
|
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 ? (
|
{isSelf ? (
|
||||||
<IconButton onClick={toggleMute}>
|
<IconButton onClick={toggleMute}>
|
||||||
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}
|
{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 */}
|
{/* 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={{ marginTop: 8, width: "100%" }}>
|
||||||
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
|
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
@ -149,15 +149,15 @@ const RoomView = (props: RoomProps) => {
|
|||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "ping":
|
case "ping":
|
||||||
// Respond to server ping immediately to maintain connection
|
// 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" });
|
sendJsonMessage({ type: "pong" });
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
console.error(`App - error`, data.error);
|
console.error(`room-view - error`, data.error);
|
||||||
setError(data.error);
|
setError(data.data.error || JSON.stringify(data));
|
||||||
break;
|
break;
|
||||||
case "warning":
|
case "warning":
|
||||||
console.warn(`App - warning`, data.warning);
|
console.warn(`room-view - warning`, data.warning);
|
||||||
setWarning(data.warning);
|
setWarning(data.warning);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setWarning("");
|
setWarning("");
|
||||||
@ -223,7 +223,7 @@ const RoomView = (props: RoomProps) => {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [lastJsonMessage, session, setError, setSession]);
|
}, [lastJsonMessage, session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state === "volcano") {
|
if (state === "volcano") {
|
||||||
|
@ -16,7 +16,19 @@ import {
|
|||||||
} from "./games/constants";
|
} from "./games/constants";
|
||||||
|
|
||||||
import { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLocations";
|
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 { newPlayer } from "./games/playerFactory";
|
||||||
import { normalizeIncoming, shuffleArray } from "./games/utils";
|
import { normalizeIncoming, shuffleArray } from "./games/utils";
|
||||||
import {
|
import {
|
||||||
@ -125,7 +137,7 @@ const processTies = (players: Player[]): boolean => {
|
|||||||
return ties;
|
return ties;
|
||||||
};
|
};
|
||||||
|
|
||||||
const processGameOrder = (game: Game, player: Player, dice: number): any => {
|
const processGameOrder = (game: Game, player: Player, dice: number): string | undefined => {
|
||||||
if (player.orderRoll) {
|
if (player.orderRoll) {
|
||||||
return `You have already rolled for game order and are not in a tie.`;
|
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),
|
players: getFilteredPlayers(game),
|
||||||
chat: game.chat,
|
chat: game.chat,
|
||||||
});
|
});
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* sort updated player.order into the array */
|
/* sort updated player.order into the array */
|
||||||
@ -176,7 +188,7 @@ const processGameOrder = (game: Game, player: Player, dice: number): any => {
|
|||||||
players: getFilteredPlayers(game),
|
players: getFilteredPlayers(game),
|
||||||
chat: game.chat,
|
chat: game.chat,
|
||||||
});
|
});
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
addChatMessage(
|
addChatMessage(
|
||||||
@ -187,11 +199,11 @@ const processGameOrder = (game: Game, player: Player, dice: number): any => {
|
|||||||
|
|
||||||
game.playerOrder = players.map((player) => player.color as string);
|
game.playerOrder = players.map((player) => player.color as string);
|
||||||
game.state = "initial-placement";
|
game.state = "initial-placement";
|
||||||
(game as any)["direction"] = "forward";
|
game.direction = "forward";
|
||||||
const first = players[0];
|
const first = players[0];
|
||||||
game.turn = {
|
game.turn = {
|
||||||
name: first?.name as string,
|
name: first?.name as string,
|
||||||
color: first?.color as string,
|
color: first?.color as PlayerColor,
|
||||||
};
|
};
|
||||||
setForSettlementPlacement(game, getValidCorners(game, ""));
|
setForSettlementPlacement(game, getValidCorners(game, ""));
|
||||||
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
|
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, {
|
sendUpdateToPlayers(game, {
|
||||||
players: getFilteredPlayers(game),
|
players: getFilteredPlayers(game),
|
||||||
state: game.state,
|
state: game.state,
|
||||||
direction: (game as any)["direction"],
|
direction: game.direction,
|
||||||
turn: game.turn,
|
turn: game.turn,
|
||||||
chat: game.chat,
|
chat: game.chat,
|
||||||
activities: game.activities,
|
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";
|
const name = session.name ? session.name : "Unnamed";
|
||||||
void session.player;
|
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 volcanoIdx = typeof game.turn.volcano === "number" ? game.turn.volcano : undefined;
|
||||||
const corner = volcanoIdx !== undefined ? game.placements.corners[volcanoIdx] : 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];
|
const player = game.players[corner.color];
|
||||||
if (player) {
|
if (player) {
|
||||||
if (corner.type === "city") {
|
if (corner.type === "city") {
|
||||||
@ -243,14 +257,14 @@ const processVolcano = (game: Game, session: Session, dice: number[]): any => {
|
|||||||
corner.type = "settlement";
|
corner.type = "settlement";
|
||||||
} else {
|
} else {
|
||||||
addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`);
|
addChatMessage(game, null, `${player.name}'s city was wiped out, and they have no settlements to replace it!`);
|
||||||
delete corner.type;
|
corner.type = "none";
|
||||||
delete corner.color;
|
corner.color = "unassigned";
|
||||||
player.cities = (player.cities || 0) + 1;
|
player.cities = (player.cities || 0) + 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
|
addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
|
||||||
delete corner.type;
|
corner.type = "none";
|
||||||
delete corner.color;
|
corner.color = "unassigned";
|
||||||
player.settlements = (player.settlements || 0) + 1;
|
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,
|
const player = session.player as Player,
|
||||||
name = session.name ? session.name : "Unnamed";
|
name = session.name ? session.name : "Unnamed";
|
||||||
|
|
||||||
@ -281,7 +295,7 @@ const roll = (game: Game, session: Session, dice?: number[] | undefined): any =>
|
|||||||
return undefined;
|
return undefined;
|
||||||
|
|
||||||
case "game-order":
|
case "game-order":
|
||||||
(game as any)["startTime"] = Date.now();
|
game.startTime = Date.now();
|
||||||
addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
|
addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
|
||||||
if (typeof dice[0] !== "number") {
|
if (typeof dice[0] !== "number") {
|
||||||
return `Invalid roll value.`;
|
return `Invalid roll value.`;
|
||||||
@ -327,83 +341,105 @@ const sessionFromColor = (game: Game, color: string): Session | undefined => {
|
|||||||
return 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 => {
|
const distributeResources = (game: Game, roll: number): void => {
|
||||||
console.log(`Roll: ${roll}`);
|
console.log(`Roll: ${roll}`);
|
||||||
/* Find which tiles have this roll */
|
/* Find which tiles have this roll */
|
||||||
const matchedTiles: { robber: boolean; index: number }[] = [];
|
const matchedTiles: Tile[] = [];
|
||||||
const pipOrder = game.pipOrder || [];
|
const pipOrder = game.pipOrder || [];
|
||||||
for (let i = 0; i < pipOrder.length; i++) {
|
pipOrder.forEach((pipIndex: number, pos: number) => {
|
||||||
const index = pipOrder[i];
|
if (staticData.pips?.[pipIndex] && staticData.pips[pipIndex].roll === roll) {
|
||||||
if (typeof index === "number" && staticData.pips?.[index] && staticData.pips[index].roll === roll) {
|
/* TODO: Fix so it isn't hard coded to "wheat" and instead is the correct resource given
|
||||||
matchedTiles.push({ robber: game.robber === i, index: i });
|
* the resource distribution in shuffeled */
|
||||||
|
matchedTiles.push({ type: "wheat", robber: game.robber === pos, index: pos });
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
const receives: Record<string, Record<string, number>> = {
|
const receives: Received = {} as Received;
|
||||||
O: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
|
PLAYER_COLORS.forEach((color) => {
|
||||||
R: { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 },
|
receives[color] = { 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 },
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Find which corners are on each tile */
|
/* Find which corners are on each tile */
|
||||||
matchedTiles.forEach((tile) => {
|
matchedTiles.forEach((tile: Tile) => {
|
||||||
const tileOrder = game.tileOrder || [];
|
const tileOrder = game.tileOrder;
|
||||||
const gameTiles = game.tiles || [];
|
const gameTiles = game.tiles;
|
||||||
const shuffle = tileOrder[tile.index];
|
if (tile.index >= tileOrder.length) {
|
||||||
const resource = typeof shuffle === "number" ? gameTiles[shuffle] : undefined;
|
return;
|
||||||
const tileLayout = layout.tiles?.[tile.index];
|
}
|
||||||
tileLayout?.corners.forEach((cornerIndex: number) => {
|
const shuffle = tileOrder[tile.index]!;
|
||||||
const active = game.placements.corners?.[cornerIndex];
|
const resource = gameTiles[shuffle] ? gameTiles[shuffle] : null;
|
||||||
if (active && active.color && resource) {
|
if (!resource) {
|
||||||
const count = active.type === "settlement" ? 1 : 2;
|
return;
|
||||||
if (!tile.robber) {
|
}
|
||||||
if (!receives[active.color]) receives[active.color] = { wood: 0, brick: 0, sheep: 0, wheat: 0, stone: 0 };
|
const tileLayout = layout.tiles[tile.index];
|
||||||
if (resource && resource.type) (receives as any)[active.color][resource.type] += count;
|
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 {
|
} else {
|
||||||
const victim = game.players[active.color];
|
trackTheft(game, active.color, "robber", resource.type, count);
|
||||||
if (isRuleEnabled(game, `robin-hood-robber`) && victim && (victim.points || 0) <= 2) {
|
if (resource.type) receives.robber[resource.type] += count;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const robberList: string[] = [];
|
const robberList: string[] = [];
|
||||||
for (const color in receives) {
|
PLAYER_COLORS.forEach((color) => {
|
||||||
const entry = receives[color];
|
const entry = receives[color];
|
||||||
if (!entry || !(entry["wood"] || entry["brick"] || entry["sheep"] || entry["wheat"] || entry["stone"])) {
|
if (!(entry.wood || entry.brick || entry.sheep || entry.wheat || entry.stone)) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
const messageParts: string[] = [];
|
const messageParts: string[] = [];
|
||||||
let s: Session | undefined;
|
let s: Session | undefined;
|
||||||
for (const type in entry) {
|
RESOURCE_TYPES.forEach((type) => {
|
||||||
if (entry[type] === 0) continue;
|
if (entry[type] === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (color !== "robber") {
|
if (color !== "robber") {
|
||||||
s = sessionFromColor(game, color);
|
s = sessionFromColor(game, color);
|
||||||
if (!s || !s.player) continue;
|
if (s && s.player) {
|
||||||
(s.player as any)[type] = ((s.player as any)[type] || 0) + entry[type];
|
s.player[type] += entry[type];
|
||||||
(s.player as any).resources = ((s.player as any).resources || 0) + entry[type];
|
s.player.resources += entry[type];
|
||||||
messageParts.push(`${entry[type]} ${type}`);
|
messageParts.push(`${entry[type]} ${type}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
robberList.push(`${entry[type]} ${type}`);
|
robberList.push(`${entry[type]} ${type}`);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if (s) {
|
if (s) {
|
||||||
addChatMessage(game, s, `${s.name} receives ${messageParts.join(", ")} for pip ${roll}.`);
|
addChatMessage(game, s, `${s.name} receives ${messageParts.join(", ")} for pip ${roll}.`);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if (robberList.length) {
|
if (robberList.length) {
|
||||||
addChatMessage(game, null, `That pesky ${game.robberName} Robber Roberson stole ${robberList.join(", ")}!`);
|
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 || [];
|
const vCorners = layout.tiles[volcanoIdx].corners || [];
|
||||||
vCorners.forEach((index: number) => {
|
vCorners.forEach((index: number) => {
|
||||||
const corner = game.placements.corners[index];
|
const corner = game.placements.corners[index];
|
||||||
if (corner && corner.color) {
|
if (corner && corner.color && corner.color !== "unassigned") {
|
||||||
if (!game.turn.select) {
|
if (!game.turn.select) {
|
||||||
game.turn.select = {} as Record<string, number>;
|
game.turn.select = {} as Record<string, number>;
|
||||||
}
|
}
|
||||||
@ -649,7 +685,7 @@ const getSession = (game: Game, id: string): Session => {
|
|||||||
id: id,
|
id: id,
|
||||||
short: `[${id.substring(0, 8)}]`,
|
short: `[${id.substring(0, 8)}]`,
|
||||||
name: "",
|
name: "",
|
||||||
color: "",
|
color: "unassigned",
|
||||||
lastActive: Date.now(),
|
lastActive: Date.now(),
|
||||||
live: true,
|
live: true,
|
||||||
};
|
};
|
||||||
@ -669,7 +705,8 @@ const getSession = (game: Game, id: string): Session => {
|
|||||||
if (!_session) {
|
if (!_session) {
|
||||||
continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (_id === id) {
|
if (_id === id) {
|
||||||
@ -754,7 +791,7 @@ const loadGame = async (id: string) => {
|
|||||||
game.unselected = [];
|
game.unselected = [];
|
||||||
for (let id in game.sessions) {
|
for (let id in game.sessions) {
|
||||||
const session = game.sessions[id];
|
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 = game.players[session.color];
|
||||||
session.player.name = session.name;
|
session.player.name = session.name;
|
||||||
session.player.status = "Active";
|
session.player.status = "Active";
|
||||||
@ -767,11 +804,57 @@ const loadGame = async (id: string) => {
|
|||||||
session.live = false;
|
session.live = false;
|
||||||
|
|
||||||
/* Populate the 'unselected' list from the session table */
|
/* 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]);
|
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;
|
games[id] = game;
|
||||||
return game;
|
return game;
|
||||||
};
|
};
|
||||||
@ -857,7 +940,7 @@ const adminCommands = (game: Game, action: string, value: string, query: any): a
|
|||||||
if (session.player.roads === 0) {
|
if (session.player.roads === 0) {
|
||||||
return `Player ${game.turn.name} does not have any more roads to give.`;
|
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) {
|
if (roads.length === 0) {
|
||||||
return `There are no valid locations for ${game.turn.name} to place a road.`;
|
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) {
|
if (session.player.cities === 0) {
|
||||||
return `Player ${game.turn.name} does not have any more cities to give.`;
|
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) {
|
if (corners.length === 0) {
|
||||||
return `There are no valid locations for ${game.turn.name} to place a settlement.`;
|
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) {
|
if (session.player.settlements === 0) {
|
||||||
return `Player ${game.turn.name} does not have any more settlements to give.`;
|
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) {
|
if (corners.length === 0) {
|
||||||
return `There are no valid locations for ${game.turn.name} to place a settlement.`;
|
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";
|
color = "W";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (corner && corner.color) {
|
if (corner && corner.color && corner.color !== "unassigned") {
|
||||||
const player = game.players ? game.players[corner.color] : undefined;
|
const player = game.players ? game.players[corner.color] : undefined;
|
||||||
if (player) {
|
if (player) {
|
||||||
if (corner.type === "city") {
|
if (corner.type === "city") {
|
||||||
@ -1031,7 +1114,7 @@ const adminCommands = (game: Game, action: string, value: string, query: any): a
|
|||||||
color = "W";
|
color = "W";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (corner && corner.color) {
|
if (corner && corner.color && corner.color !== "unassigned") {
|
||||||
const player = game.players[corner.color];
|
const player = game.players[corner.color];
|
||||||
if (player) {
|
if (player) {
|
||||||
if (corner.type === "city") {
|
if (corner.type === "city") {
|
||||||
@ -1089,7 +1172,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
|
|||||||
if (session.name === name) {
|
if (session.name === name) {
|
||||||
return; /* no-op */
|
return; /* no-op */
|
||||||
}
|
}
|
||||||
if (session.color) {
|
if (session.color !== "unassigned") {
|
||||||
return `You cannot change your name while you have a color selected.`;
|
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}.`;
|
message = `A new player has entered the lobby as ${name}.`;
|
||||||
} else {
|
} else {
|
||||||
if (rejoin) {
|
if (rejoin) {
|
||||||
if (session.color) {
|
if (session.color !== "unassigned") {
|
||||||
message = `${name} has reconnected to the game.`;
|
message = `${name} has reconnected to the game.`;
|
||||||
} else {
|
} else {
|
||||||
message = `${name} has rejoined the lobby.`;
|
message = `${name} has rejoined the lobby.`;
|
||||||
@ -1167,7 +1250,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und
|
|||||||
addChatMessage(game, null, message);
|
addChatMessage(game, null, message);
|
||||||
|
|
||||||
/* Rebuild the unselected list */
|
/* Rebuild the unselected list */
|
||||||
if (!session.color) {
|
if (session.color === "unassigned") {
|
||||||
console.log(`${info}: Adding ${session.name} to the unselected`);
|
console.log(`${info}: Adding ${session.name} to the unselected`);
|
||||||
}
|
}
|
||||||
game.unselected = [];
|
game.unselected = [];
|
||||||
@ -1223,7 +1306,7 @@ const getActiveCount = (game: Game): number => {
|
|||||||
return active;
|
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 */
|
/* Selecting the same color is a NO-OP */
|
||||||
if (session.color === color) {
|
if (session.color === color) {
|
||||||
return;
|
return;
|
||||||
@ -1262,7 +1345,7 @@ const setPlayerColor = (game: Game, session: Session, color: string): string | u
|
|||||||
// remove the player association
|
// remove the player association
|
||||||
delete (session as any).player;
|
delete (session as any).player;
|
||||||
const old_color = session.color;
|
const old_color = session.color;
|
||||||
session.color = "";
|
session.color = "unassigned";
|
||||||
active--;
|
active--;
|
||||||
|
|
||||||
/* If the player is not selecting a color, then return */
|
/* 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 => {
|
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 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;
|
return 0;
|
||||||
}
|
}
|
||||||
/* If this corner is already being walked, skip it */
|
/* If this corner is already being walked, skip it */
|
||||||
@ -1386,7 +1469,7 @@ const buildCornerGraph = (
|
|||||||
set: any
|
set: any
|
||||||
): void => {
|
): void => {
|
||||||
/* If this corner is allocated and isn't assigned to the walking color, skip it */
|
/* 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;
|
return;
|
||||||
}
|
}
|
||||||
/* If this corner is already being walked, skip it */
|
/* 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 }[] = [];
|
let graphs: { color: string; set: number[]; longestRoad?: number; longestStartSegment?: number }[] = [];
|
||||||
layout.roads.forEach((_: any, roadIndex: number) => {
|
layout.roads.forEach((_: any, roadIndex: number) => {
|
||||||
const placedRoad = game.placements?.roads?.[roadIndex];
|
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[] = [];
|
let set: number[] = [];
|
||||||
buildRoadGraph(game, placedRoad.color, roadIndex, placedRoad, set);
|
buildRoadGraph(game, placedRoad.color, roadIndex, placedRoad, set);
|
||||||
if (set.length) {
|
if (set.length) {
|
||||||
@ -1542,7 +1625,7 @@ const calculateRoadLengths = (game: Game, session: Session): void => {
|
|||||||
clearRoadWalking(game);
|
clearRoadWalking(game);
|
||||||
const longestRoad = processRoad(game, placedRoad.color as string, roadIndex, placedRoad);
|
const longestRoad = processRoad(game, placedRoad.color as string, roadIndex, placedRoad);
|
||||||
placedRoad["longestRoad"] = longestRoad;
|
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];
|
const player = game.players[placedRoad.color];
|
||||||
if (player) {
|
if (player) {
|
||||||
const prevVal = player["longestRoad"] || 0;
|
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)["gets"] = (offer as any)["gets"];
|
||||||
(player as any)["offerRejected"] = {};
|
(player as any)["offerRejected"] = {};
|
||||||
|
|
||||||
if ((game.turn as any)["color"] === session.color) {
|
if (game.turn.color === session.color) {
|
||||||
(game.turn as any)["offer"] = offer;
|
game.turn.offer = offer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If this offer matches what another player wants, clear rejection on that other player's 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;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const placeRoad = (game: any, session: any, index: any): string | undefined => {
|
const placeRoad = (game: Game, session: Session, index: number): string | undefined => {
|
||||||
const player = session.player;
|
if (!session.player) {
|
||||||
if (typeof index === "string") index = parseInt(index);
|
return `You are not playing a player.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player: Player = session.player;
|
||||||
|
|
||||||
if (!game || !game.turn) {
|
if (!game || !game.turn) {
|
||||||
return `Invalid game state.`;
|
return `Invalid game state.`;
|
||||||
@ -2777,7 +2863,7 @@ const placeRoad = (game: any, session: any, index: any): string | undefined => {
|
|||||||
|
|
||||||
const road = game.placements.roads[index];
|
const road = game.placements.roads[index];
|
||||||
if (road.color) {
|
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") {
|
if (game.state === "normal") {
|
||||||
@ -2807,8 +2893,98 @@ const placeRoad = (game: any, session: any, index: any): string | undefined => {
|
|||||||
road.type = "road";
|
road.type = "road";
|
||||||
player.roads--;
|
player.roads--;
|
||||||
|
|
||||||
game.turn.actions = [];
|
/* During initial placement, placing a road advances the initial-placement
|
||||||
game.turn.limits = {};
|
* 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);
|
calculateRoadLengths(game, session);
|
||||||
|
|
||||||
@ -2821,6 +2997,8 @@ const placeRoad = (game: any, session: any, index: any): string | undefined => {
|
|||||||
chat: game.chat,
|
chat: game.chat,
|
||||||
activities: game.activities,
|
activities: game.activities,
|
||||||
players: getFilteredPlayers(game),
|
players: getFilteredPlayers(game),
|
||||||
|
state: game.state,
|
||||||
|
direction: (game as any)["direction"],
|
||||||
});
|
});
|
||||||
return undefined;
|
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.`;
|
return `You tried to cheat! You should not try to break the rules.`;
|
||||||
}
|
}
|
||||||
const corner = game.placements.corners[index];
|
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}!`;
|
return `This location already has a settlement belonging to ${game.players[corner.color].name}!`;
|
||||||
}
|
}
|
||||||
if (corner.type !== "settlement") {
|
if (corner.type !== "settlement") {
|
||||||
@ -3429,7 +3607,7 @@ const setGameState = (game: any, session: any, state: any): string | undefined =
|
|||||||
return `Invalid state.`;
|
return `Invalid state.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.color) {
|
if (session.color === "unassigned") {
|
||||||
return `You must have an active player to start the game.`;
|
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.name) {
|
||||||
if (session.color) {
|
if (session.color !== "unassigned") {
|
||||||
addChatMessage(game, null, `${session.name} has disconnected ` + `from the game.`);
|
addChatMessage(game, null, `${session.name} has disconnected ` + `from the game.`);
|
||||||
} else {
|
} else {
|
||||||
addChatMessage(game, null, `${session.name} has left the lobby.`);
|
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);
|
console.log(`${short}: ws.on('error') - stack:`, new Error().stack);
|
||||||
// Only close the session.ws if it is the same socket that errored.
|
// Only close the session.ws if it is the same socket that errored.
|
||||||
if (session.ws && session.ws === ws) {
|
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 {
|
try {
|
||||||
session.ws.close();
|
session.ws.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -4298,16 +4488,17 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
let warning: string | void | undefined;
|
let warning: string | void | undefined;
|
||||||
let processed = true;
|
let processed = true;
|
||||||
|
|
||||||
// The initial-game snapshot is sent from the connection attach path to
|
// The initial-game snapshot is sent from the connection attach path to
|
||||||
// ensure it is only sent once per websocket lifecycle. Avoid sending it
|
// ensure it is only sent once per websocket lifecycle. Avoid sending it
|
||||||
// here from the message handler to prevent duplicate snapshots when a
|
// here from the message handler to prevent duplicate snapshots when a
|
||||||
// client sends messages during the attach/reconnect sequence.
|
// client sends messages during the attach/reconnect sequence.
|
||||||
|
|
||||||
switch (incoming.type) {
|
switch (incoming.type) {
|
||||||
case "join":
|
case "join":
|
||||||
// Accept either legacy `config` or newer `data` field from clients
|
// Accept either legacy `config`, newer `data`, or flat payloads where
|
||||||
|
// the client sent fields at the top level (normalizeIncoming will
|
||||||
webrtcJoin(audio[gameId], session, data.config || data.data || {});
|
// populate `data` with the parsed object in that case).
|
||||||
|
webrtcJoin(audio[gameId], session, data.config || data.data || data || {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "part":
|
case "part":
|
||||||
@ -4317,21 +4508,21 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
case "relayICECandidate":
|
case "relayICECandidate":
|
||||||
{
|
{
|
||||||
// Delegate to the webrtc signaling helper (it performs its own checks)
|
// 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);
|
handleRelayICECandidate(gameId, cfg, session, undefined, debug);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "relaySessionDescription":
|
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);
|
handleRelaySessionDescription(gameId, cfg, session, undefined, debug);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "pong":
|
case "pong":
|
||||||
console.log(`${short}: Received pong from ${getName(session)}`);
|
|
||||||
|
|
||||||
// Clear the keepAlive timeout since we got a response
|
// Clear the keepAlive timeout since we got a response
|
||||||
if (session.keepAlive) {
|
if (session.keepAlive) {
|
||||||
clearTimeout(session.keepAlive);
|
clearTimeout(session.keepAlive);
|
||||||
@ -4342,15 +4533,9 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
if (session.ping) {
|
if (session.ping) {
|
||||||
session.lastPong = Date.now();
|
session.lastPong = Date.now();
|
||||||
const latency = session.lastPong - session.ping;
|
const latency = session.lastPong - session.ping;
|
||||||
// Only accept latency values that are within a reasonable window
|
console.log(`${short}: Received pong from ${getName(session)}. Latency: ${latency}ms`);
|
||||||
// (e.g. 0 - 60s). Ignore stale or absurdly large stored ping
|
} else {
|
||||||
// timestamps which can occur if session state was persisted or
|
console.log(`${short}: Received pong from ${getName(session)}.`);
|
||||||
// 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`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to resetDisconnectCheck since it's non-functional
|
// No need to resetDisconnectCheck since it's non-functional
|
||||||
@ -4363,7 +4548,8 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
|
|
||||||
case "peer_state_update":
|
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);
|
broadcastPeerStateUpdate(gameId, cfg, session, undefined);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -4594,7 +4780,7 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
break;
|
break;
|
||||||
case "place-road":
|
case "place-road":
|
||||||
console.log(`${short}: <- place-road:${getName(session)} ${data.index}`);
|
console.log(`${short}: <- place-road:${getName(session)} ${data.index}`);
|
||||||
warning = placeRoad(game, session, data.index);
|
warning = placeRoad(game, session, parseInt(data.index));
|
||||||
if (warning) {
|
if (warning) {
|
||||||
sendWarning(session, warning);
|
sendWarning(session, warning);
|
||||||
}
|
}
|
||||||
@ -4779,7 +4965,7 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session.name) {
|
if (session.name) {
|
||||||
if (session.color) {
|
if (session.color !== "unassigned") {
|
||||||
addChatMessage(game, null, `${session.name} has reconnected to the game.`);
|
addChatMessage(game, null, `${session.name} has reconnected to the game.`);
|
||||||
} else {
|
} else {
|
||||||
addChatMessage(game, null, `${session.name} has rejoined the lobby.`);
|
addChatMessage(game, null, `${session.name} has rejoined the lobby.`);
|
||||||
@ -5120,7 +5306,7 @@ const resetGame = (game: any) => {
|
|||||||
/* Ensure sessions are connected to player objects */
|
/* Ensure sessions are connected to player objects */
|
||||||
for (let key in game.sessions) {
|
for (let key in game.sessions) {
|
||||||
const session = game.sessions[key];
|
const session = game.sessions[key];
|
||||||
if (session.color) {
|
if (session.color !== "unassigned") {
|
||||||
game.active++;
|
game.active++;
|
||||||
session.player = game.players[session.color];
|
session.player = game.players[session.color];
|
||||||
session.player.status = "Active";
|
session.player.status = "Active";
|
||||||
@ -5162,7 +5348,7 @@ const createGame = async (id: any) => {
|
|||||||
}
|
}
|
||||||
console.log(`${info}: creating ${id}`);
|
console.log(`${info}: creating ${id}`);
|
||||||
|
|
||||||
const game = {
|
const game: Game = {
|
||||||
id: id,
|
id: id,
|
||||||
developmentCards: [],
|
developmentCards: [],
|
||||||
players: {
|
players: {
|
||||||
@ -5179,7 +5365,7 @@ const createGame = async (id: any) => {
|
|||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
name: "",
|
name: "",
|
||||||
color: "",
|
color: "unassigned",
|
||||||
actions: [],
|
actions: [],
|
||||||
limits: {},
|
limits: {},
|
||||||
roll: 0,
|
roll: 0,
|
||||||
@ -5189,6 +5375,11 @@ const createGame = async (id: any) => {
|
|||||||
points: 10,
|
points: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
pipOrder: [],
|
||||||
|
borderOrder: [],
|
||||||
|
tileOrder: [],
|
||||||
|
tiles: [],
|
||||||
|
pips: [],
|
||||||
step: 0 /* used for the suffix # in game backups */,
|
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) {
|
if (game.activities.length && game.activities[game.activities.length - 1].date === 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) {
|
if (game.activities.length > 30) {
|
||||||
game.activities.splice(0, 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) {
|
if (session && session.name) {
|
||||||
entry.from = session.name;
|
entry.from = session.name;
|
||||||
}
|
}
|
||||||
if (session && session.color) {
|
if (session && session.color && session.color !== "unassigned") {
|
||||||
entry.color = session.color;
|
entry.color = session.color;
|
||||||
}
|
}
|
||||||
game.chat.push(entry);
|
game.chat.push(entry);
|
||||||
@ -47,7 +48,7 @@ export const getColorFromName = (game: Game, name: string): string => {
|
|||||||
for (let id in game.sessions) {
|
for (let id in game.sessions) {
|
||||||
const s = game.sessions[id];
|
const s = game.sessions[id];
|
||||||
if (s && s.name === name) {
|
if (s && s.name === name) {
|
||||||
return s.color || "";
|
return s.color && s.color !== "unassigned" ? s.color : "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
|
@ -8,59 +8,59 @@ export interface TransientGameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
name?: string;
|
name: string;
|
||||||
color?: string;
|
color: PlayerColor;
|
||||||
order: number;
|
order: number;
|
||||||
orderRoll?: number;
|
orderRoll: number;
|
||||||
position?: string;
|
position: string;
|
||||||
orderStatus?: string;
|
orderStatus: string;
|
||||||
tied?: boolean;
|
tied: boolean;
|
||||||
roads?: number;
|
roads: number;
|
||||||
settlements?: number;
|
settlements: number;
|
||||||
cities?: number;
|
cities: number;
|
||||||
longestRoad?: number;
|
longestRoad: number;
|
||||||
mustDiscard?: number;
|
mustDiscard?: number;
|
||||||
sheep?: number;
|
sheep: number;
|
||||||
wheat?: number;
|
wheat: number;
|
||||||
stone?: number;
|
stone: number;
|
||||||
brick?: number;
|
brick: number;
|
||||||
wood?: number;
|
wood: number;
|
||||||
points?: number;
|
points: number;
|
||||||
resources?: number;
|
resources: number;
|
||||||
lastActive?: number;
|
lastActive: number;
|
||||||
live?: boolean;
|
live: boolean;
|
||||||
status?: string;
|
status: string;
|
||||||
developmentCards?: number;
|
developmentCards: number;
|
||||||
development?: DevelopmentCard[];
|
development: DevelopmentCard[];
|
||||||
turnNotice?: string;
|
turnNotice: string;
|
||||||
turnStart?: number;
|
turnStart: number;
|
||||||
totalTime?: number;
|
totalTime: number;
|
||||||
[key: string]: any; // allow incremental fields until fully typed
|
[key: string]: any; // allow incremental fields until fully typed
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CornerPlacement {
|
export interface CornerPlacement {
|
||||||
color?: string;
|
color: PlayerColor;
|
||||||
type?: "settlement" | "city";
|
type: "settlement" | "city" | "none";
|
||||||
walking?: boolean;
|
walking?: boolean;
|
||||||
longestRoad?: number;
|
longestRoad?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoadPlacement {
|
export interface RoadPlacement {
|
||||||
color?: string;
|
color?: PlayerColor;
|
||||||
walking?: boolean;
|
walking?: boolean;
|
||||||
[key: string]: any;
|
type?: "road" | "ship";
|
||||||
|
longestRoad?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Placements {
|
export interface Placements {
|
||||||
corners: CornerPlacement[];
|
corners: CornerPlacement[];
|
||||||
roads: RoadPlacement[];
|
roads: RoadPlacement[];
|
||||||
[key: string]: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Turn {
|
export interface Turn {
|
||||||
name?: string;
|
name?: string;
|
||||||
color?: string;
|
color?: PlayerColor;
|
||||||
actions?: string[];
|
actions?: string[];
|
||||||
limits?: any;
|
limits?: any;
|
||||||
roll?: number;
|
roll?: number;
|
||||||
@ -71,6 +71,7 @@ export interface Turn {
|
|||||||
active?: string;
|
active?: string;
|
||||||
robberInAction?: boolean;
|
robberInAction?: boolean;
|
||||||
placedRobber?: number;
|
placedRobber?: number;
|
||||||
|
offer?: Offer;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +82,7 @@ export interface DevelopmentCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import from schema for DRY compliance
|
// Import from schema for DRY compliance
|
||||||
import { TransientSessionState } from './transientSchema';
|
import { TransientSessionState } from "./transientSchema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent Session data (saved to DB)
|
* Persistent Session data (saved to DB)
|
||||||
@ -89,7 +90,7 @@ import { TransientSessionState } from './transientSchema';
|
|||||||
export interface PersistentSessionData {
|
export interface PersistentSessionData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: PlayerColor;
|
||||||
lastActive: number;
|
lastActive: number;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
player?: Player;
|
player?: Player;
|
||||||
@ -97,6 +98,9 @@ export interface PersistentSessionData {
|
|||||||
resources?: number;
|
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
|
* Runtime Session type = Persistent + Transient
|
||||||
* At runtime, sessions have both persistent and transient fields
|
* At runtime, sessions have both persistent and transient fields
|
||||||
@ -114,6 +118,17 @@ export interface Offer {
|
|||||||
[key: string]: any;
|
[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 {
|
export interface Game {
|
||||||
id: string;
|
id: string;
|
||||||
developmentCards: DevelopmentCard[];
|
developmentCards: DevelopmentCard[];
|
||||||
@ -127,10 +142,10 @@ export interface Game {
|
|||||||
step?: number;
|
step?: number;
|
||||||
placements: Placements;
|
placements: Placements;
|
||||||
turn: Turn;
|
turn: Turn;
|
||||||
pipOrder?: number[];
|
pipOrder: number[];
|
||||||
tileOrder?: number[];
|
tileOrder: number[];
|
||||||
resources?: number;
|
resources?: number;
|
||||||
tiles?: any[];
|
tiles: Tile[];
|
||||||
pips?: any[];
|
pips?: any[];
|
||||||
dice?: number[];
|
dice?: number[];
|
||||||
chat?: any[];
|
chat?: any[];
|
||||||
@ -152,7 +167,8 @@ export interface Game {
|
|||||||
lastActivity?: number;
|
lastActivity?: number;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
animationSeeds?: number[];
|
animationSeeds?: number[];
|
||||||
[key: string]: any;
|
startTime?: number;
|
||||||
|
direction?: "forward" | "backward";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IncomingMessage = { type: string | null; data: any };
|
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.
|
* has a matching color, add this to the set. Otherwise skip.
|
||||||
*/
|
*/
|
||||||
layout.roads.forEach((road, roadIndex) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let valid = false;
|
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;
|
const cornerIndex = road.corners[c] as number;
|
||||||
if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) {
|
if (cornerIndex == null || (layout as any).corners[cornerIndex] == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const corner = (layout as any).corners[cornerIndex];
|
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;
|
const cornerColor =
|
||||||
/* Roads do not pass through other player's settlements */
|
(game as any).placements &&
|
||||||
if (cornerColor && cornerColor !== color) {
|
(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;
|
continue;
|
||||||
}
|
}
|
||||||
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
|
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
|
||||||
@ -33,7 +47,9 @@ const getValidRoads = (game: any, color: string): number[] => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const rr = corner.roads[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;
|
const placementsRoads = (game as any).placements && (game as any).placements.roads;
|
||||||
if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) {
|
if (placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color) {
|
||||||
valid = true;
|
valid = true;
|
||||||
@ -75,7 +91,9 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,17 +104,23 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
valid = false;
|
valid = false;
|
||||||
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
|
for (let r = 0; !valid && r < (corner.roads || []).length; r++) {
|
||||||
const rr = corner.roads[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;
|
const placementsRoads = (game as any).placements && (game as any).placements.roads;
|
||||||
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
|
valid = !!(placementsRoads && placementsRoads[rr] && placementsRoads[rr].color === color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
|
for (let r = 0; valid && r < (corner.roads || []).length; r++) {
|
||||||
if (!corner.roads) { break; }
|
if (!corner.roads) {
|
||||||
const ridx = corner.roads[r] as number;
|
break;
|
||||||
if (ridx == null || (layout as any).roads[ridx] == null) { continue; }
|
}
|
||||||
const road = (layout as any).roads[ridx];
|
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++) {
|
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. */
|
* Skip it. */
|
||||||
@ -106,7 +130,13 @@ const getValidCorners = (game: any, color: string, type?: string): number[] => {
|
|||||||
/* There is a settlement within one segment from this
|
/* There is a settlement within one segment from this
|
||||||
* corner, so it is invalid for settlement placement */
|
* corner, so it is invalid for settlement placement */
|
||||||
const cc = road.corners[c] as number;
|
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;
|
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
|
/* During initial placement, if volcano is enabled, do not allow
|
||||||
* placement on a corner connected to the volcano (robber starts
|
* placement on a corner connected to the volcano (robber starts
|
||||||
* on the volcano) */
|
* on the volcano) */
|
||||||
if (!(game.state === 'initial-placement'
|
if (
|
||||||
&& 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
|
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);
|
limits.push(cornerIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user