Compare commits
5 Commits
f1a5946045
...
419b36c22f
Author | SHA1 | Date | |
---|---|---|---|
419b36c22f | |||
67f2580e3a | |||
58d2a19ada | |||
ecfcb0476b | |||
bc5a928da6 |
@ -246,7 +246,13 @@ const Activities: React.FC = () => {
|
|||||||
|
|
||||||
{placement && (
|
{placement && (
|
||||||
<div className="Requirement">
|
<div className="Requirement">
|
||||||
{who} must place a {placeRoad ? "road" : "settlement"}.
|
{who} must place {
|
||||||
|
(turn && (turn as any).active === "road-building" && (turn as any).freeRoads)
|
||||||
|
? `${(turn as any).freeRoads} roads`
|
||||||
|
: placeRoad
|
||||||
|
? "a road"
|
||||||
|
: "a settlement"
|
||||||
|
}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -28,13 +28,6 @@
|
|||||||
border: 2px dashed #666; /* Visual indicator for drop zone */
|
border: 2px dashed #666; /* Visual indicator for drop zone */
|
||||||
}
|
}
|
||||||
|
|
||||||
.MediaControlSpacer.Medium {
|
|
||||||
width: 11.5em;
|
|
||||||
height: 8.625em;
|
|
||||||
/* min-width: 11.5em;
|
|
||||||
min-height: 8.625em; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.MediaControl {
|
.MediaControl {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: absolute; /* Out of flow */
|
position: absolute; /* Out of flow */
|
||||||
@ -60,13 +53,6 @@
|
|||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MediaControl.Medium {
|
|
||||||
width: 11.5em;
|
|
||||||
height: 8.625em;
|
|
||||||
/* min-width: 11.5em;
|
|
||||||
min-height: 8.625em; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.MediaControl .Controls {
|
.MediaControl .Controls {
|
||||||
display: none; /* Hidden by default, shown on hover */
|
display: none; /* Hidden by default, shown on hover */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -7,18 +7,20 @@ type PlayerColorProps = { color?: string };
|
|||||||
|
|
||||||
const mapColor = (c?: string) => {
|
const mapColor = (c?: string) => {
|
||||||
if (!c) return undefined;
|
if (!c) return undefined;
|
||||||
const key = c.toLowerCase();
|
switch (c.toLowerCase()) {
|
||||||
switch (key) {
|
case 'red':
|
||||||
case "red":
|
return 'R'
|
||||||
return "R";
|
case 'orange':
|
||||||
case "orange":
|
return 'O'
|
||||||
return "O";
|
case 'white':
|
||||||
case "white":
|
return 'W'
|
||||||
return "W";
|
case 'blue':
|
||||||
case "blue":
|
return 'B'
|
||||||
return "B";
|
|
||||||
default:
|
default:
|
||||||
return undefined;
|
if (['R', 'O', 'W', 'B'].includes(c)) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -47,31 +47,12 @@
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
}
|
}
|
||||||
|
|
||||||
.PlayerList .Unselected > div:nth-child(2) > div {
|
|
||||||
justify-content: flex-end;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0.25rem;
|
|
||||||
padding: 0.25rem;
|
|
||||||
max-width: 8rem;
|
|
||||||
background-color: #eee;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.PlayerList .Unselected .Self {
|
|
||||||
border: 1px solid black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.PlayerList .PlayerSelector .PlayerColor {
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.PlayerList .PlayerSelector {
|
.PlayerList .PlayerSelector {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.PlayerList .PlayerSelector.MuiList-padding {
|
.PlayerList .PlayerSelector.MuiList-padding {
|
||||||
@ -91,30 +72,28 @@
|
|||||||
.PlayerList .PlayerSelector .PlayerEntry {
|
.PlayerList .PlayerSelector .PlayerEntry {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
text-overflow: ellipsis;
|
align-items: stretch;
|
||||||
white-space: nowrap;
|
/* min-width: 10rem; */
|
||||||
overflow: hidden;
|
padding: 0.25rem;
|
||||||
flex: 1 1 0px;
|
|
||||||
align-items: flex-start;
|
|
||||||
border: 1px solid rgba(0,0,0,0);
|
|
||||||
border-radius: 0.25em;
|
|
||||||
min-width: 11em;
|
|
||||||
padding: 0 1px;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
max-width: min-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.PlayerList .PlayerSelector .PlayerEntry > div:first-child {
|
/* .PlayerList .PlayerSelector .PlayerEntry > div:first-child {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.PlayerList .PlayerEntry[data-selectable=true]:hover {
|
/* .PlayerList .PlayerEntry[data-selectable=true]:hover {
|
||||||
border-color: rgba(0,0,0,0.5);
|
border-color: rgba(0,0,0,0.5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
} */
|
||||||
|
|
||||||
|
/* Ensure the inline border color (player color) is visible */
|
||||||
|
/* inline style borderColor on .PlayerEntry will override background/border defaults */
|
||||||
|
|
||||||
.PlayerList .Players .PlayerToggle {
|
.PlayerList .Players .PlayerToggle {
|
||||||
min-width: 5em;
|
min-width: 5em;
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import React, { useState, useEffect, useCallback, useContext } from "react";
|
import React, { useState, useEffect, useCallback, useContext, useMemo } from 'react';
|
||||||
import Paper from "@mui/material/Paper";
|
import Paper from '@mui/material/Paper';
|
||||||
import List from "@mui/material/List";
|
import List from '@mui/material/List';
|
||||||
import "./PlayerList.css";
|
import './PlayerList.css';
|
||||||
import { MediaControl, MediaAgent, Peer } from "./MediaControl";
|
import { MediaControl, MediaAgent, Peer } from './MediaControl';
|
||||||
import { PlayerColor } from "./PlayerColor";
|
import { PlayerColor } from './PlayerColor';
|
||||||
import Box from "@mui/material/Box";
|
import Box from '@mui/material/Box';
|
||||||
import { GlobalContext } from "./GlobalContext";
|
import { GlobalContext } from './GlobalContext';
|
||||||
|
import { styles } from './Styles';
|
||||||
|
|
||||||
type Player = {
|
type Player = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -24,14 +25,17 @@ type Player = {
|
|||||||
|
|
||||||
const PlayerList: React.FC = () => {
|
const PlayerList: React.FC = () => {
|
||||||
const { session, socketUrl, lastJsonMessage, sendJsonMessage } = useContext(GlobalContext);
|
const { session, socketUrl, lastJsonMessage, sendJsonMessage } = useContext(GlobalContext);
|
||||||
const [players, setPlayers] = useState<Player[] | null>(null);
|
const [players, setPlayers] = useState<Player[]>([]);
|
||||||
|
const [player, setPlayer] = useState<Player | null>(null);
|
||||||
const [peers, setPeers] = useState<Record<string, Peer>>({});
|
const [peers, setPeers] = useState<Record<string, Peer>>({});
|
||||||
|
const [gameState, setGameState] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("player-list - Mounted - requesting fields");
|
console.log('player-list - Mounted - requesting fields');
|
||||||
if (sendJsonMessage) {
|
if (sendJsonMessage) {
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "get",
|
type: 'get',
|
||||||
fields: ["participants"],
|
fields: ['participants'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [sendJsonMessage]);
|
}, [sendJsonMessage]);
|
||||||
@ -64,20 +68,6 @@ const PlayerList: React.FC = () => {
|
|||||||
[session]
|
[session]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!players) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
players.forEach((player) => {
|
|
||||||
console.log("rabbit - player:", {
|
|
||||||
name: player.name,
|
|
||||||
live: player.live,
|
|
||||||
in_peers: peers[player.session_id],
|
|
||||||
local_or_media: player.local || player.has_media !== false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [players]);
|
|
||||||
|
|
||||||
// Use the WebSocket hook for room events with automatic reconnection
|
// Use the WebSocket hook for room events with automatic reconnection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastJsonMessage) {
|
if (!lastJsonMessage) {
|
||||||
@ -85,25 +75,33 @@ const PlayerList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
const data: any = lastJsonMessage;
|
const data: any = lastJsonMessage;
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "game-update": {
|
case 'game-update': {
|
||||||
console.log(`player-list - game-update:`, data.update);
|
console.log(`player-list - game-update:`, data.update);
|
||||||
|
|
||||||
|
// Track game state if provided
|
||||||
|
if ('state' in data.update) {
|
||||||
|
setGameState(data.update.state);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle participants list
|
// Handle participants list
|
||||||
if ("participants" in data.update && data.update.participants) {
|
if ('participants' in data.update && data.update.participants) {
|
||||||
const participantsList: Player[] = data.update.participants;
|
const participantsList: Player[] = data.update.participants;
|
||||||
console.log(`player-list - participants:`, participantsList);
|
console.log(`player-list - participants:`, participantsList);
|
||||||
|
|
||||||
participantsList.forEach((player) => {
|
participantsList.forEach(player => {
|
||||||
player.local = player.session_id === session?.id;
|
player.local = player.session_id === session?.id;
|
||||||
|
if (player.local) {
|
||||||
|
setPlayer(player);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
participantsList.sort(sortPlayers);
|
participantsList.sort(sortPlayers);
|
||||||
console.log(`player-list - sorted participants:`, participantsList);
|
console.log(`player-list - sorted participants:`, participantsList);
|
||||||
setPlayers(participantsList);
|
setPlayers(participantsList);
|
||||||
|
|
||||||
// Initialize peers with remote mute/video state
|
// Initialize peers with remote mute/video state
|
||||||
setPeers((prevPeers) => {
|
setPeers(prevPeers => {
|
||||||
const updated: Record<string, Peer> = { ...prevPeers };
|
const updated: Record<string, Peer> = { ...prevPeers };
|
||||||
participantsList.forEach((player) => {
|
participantsList.forEach(player => {
|
||||||
// Only update remote peers, never overwrite local peer object
|
// Only update remote peers, never overwrite local peer object
|
||||||
if (!player.local && updated[player.session_id]) {
|
if (!player.local && updated[player.session_id]) {
|
||||||
updated[player.session_id] = {
|
updated[player.session_id] = {
|
||||||
@ -118,9 +116,9 @@ const PlayerList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "peer_state_update": {
|
case 'peer_state_update': {
|
||||||
// Update peer state in peers, but do not override local mute
|
// Update peer state in peers, but do not override local mute
|
||||||
setPeers((prevPeers) => {
|
setPeers(prevPeers => {
|
||||||
const updated = { ...prevPeers };
|
const updated = { ...prevPeers };
|
||||||
const peerId = data.data?.peer_id || data.peer_id;
|
const peerId = data.data?.peer_id || data.peer_id;
|
||||||
if (peerId && updated[peerId]) {
|
if (peerId && updated[peerId]) {
|
||||||
@ -140,62 +138,68 @@ const PlayerList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]);
|
}, [lastJsonMessage, session, sortPlayers, setPeers, setPlayers]);
|
||||||
|
|
||||||
|
// Compute which colors are already taken
|
||||||
|
const availableColors = useMemo(() => {
|
||||||
|
const assignedColors = new Set(players.filter(p => p.color !== 'unassigned').map(p => p.color));
|
||||||
|
return ['O', 'R', 'W', 'B'].filter(color => !assignedColors.has(color));
|
||||||
|
}, [players]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (players !== null || !sendJsonMessage) {
|
if (players.length !== 0 || !sendJsonMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Request participants list
|
// Request participants list
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "get",
|
type: 'get',
|
||||||
fields: ["participants"],
|
fields: ['participants'],
|
||||||
});
|
});
|
||||||
}, [players, sendJsonMessage]);
|
}, [players, sendJsonMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: "relative", width: "100%" }}>
|
<Box sx={{ position: 'relative', width: '100%' }}>
|
||||||
<Paper
|
<Paper
|
||||||
className={`player-list Medium`}
|
className={`PlayerList Medium`}
|
||||||
sx={{
|
sx={{
|
||||||
maxWidth: { xs: "100%", sm: 500 },
|
maxWidth: { xs: '100%', sm: 500 },
|
||||||
p: { xs: 1, sm: 2 },
|
p: { xs: 1, sm: 2 },
|
||||||
m: { xs: 0, sm: 2 },
|
m: { xs: 0, sm: 2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MediaAgent {...{ session, peers, setPeers }} />
|
<MediaAgent {...{ session, peers, setPeers }} />
|
||||||
<List className="PlayerSelector">
|
<List className="PlayerSelector Players">
|
||||||
{players?.map((player) => {
|
{players
|
||||||
|
.filter(p => p.color && p.color !== 'unassigned')
|
||||||
|
.map(player => {
|
||||||
const peerObj = peers[player.session_id] || peers[player.name];
|
const peerObj = peers[player.session_id] || peers[player.name];
|
||||||
|
const playerStyle = player.color !== 'unassigned' ? styles[player.color] : {};
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={player.session_id}
|
key={player.session_id}
|
||||||
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
|
sx={{
|
||||||
className={`PlayerEntry ${player.local ? "PlayerSelf" : ""}`}
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 1,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
...playerStyle,
|
||||||
|
}}
|
||||||
|
className={`PlayerEntry ${player.local ? 'PlayerSelf' : ''}`}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box
|
||||||
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
|
style={{
|
||||||
<Box style={{ display: "flex-wrap", alignItems: "center" }}>
|
display: 'flex-wrap',
|
||||||
<div className="Name">{player.name ? player.name : player.session_id}</div>
|
alignItems: 'center',
|
||||||
{player.protected && (
|
justifyContent: 'space-between',
|
||||||
<div
|
}}
|
||||||
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
|
|
||||||
title="This name is protected with a password"
|
|
||||||
>
|
>
|
||||||
🔒
|
<Box style={{ display: 'flex-wrap', alignItems: 'center' }} className="Name">
|
||||||
</div>
|
{player.bot_instance_id && <>🤖</>}
|
||||||
)}
|
{player.name ? player.name : player.session_id}
|
||||||
{player.bot_instance_id && (
|
|
||||||
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
|
|
||||||
🤖
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
{player.name && !player.live && <Box className="NoNetwork"></Box>}
|
||||||
</Box>
|
</Box>
|
||||||
{player.name && !player.live && <div className="NoNetwork"></div>}
|
{player.name && player.live ? (
|
||||||
</Box>
|
|
||||||
{player.name && player.live && peerObj && (player.local || player.has_media !== false) ? (
|
|
||||||
<>
|
|
||||||
<MediaControl
|
<MediaControl
|
||||||
className="Medium"
|
|
||||||
key={player.session_id}
|
key={player.session_id}
|
||||||
peer={peerObj}
|
peer={peerObj}
|
||||||
isSelf={player.local}
|
isSelf={player.local}
|
||||||
@ -203,61 +207,100 @@ const PlayerList: React.FC = () => {
|
|||||||
remoteAudioMuted={peerObj?.muted}
|
remoteAudioMuted={peerObj?.muted}
|
||||||
remoteVideoOff={peerObj?.video_on === false}
|
remoteVideoOff={peerObj?.video_on === false}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
{/* If this is the local player and they haven't picked a color, show a picker */}
|
|
||||||
{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 }}>
|
|
||||||
{["orange", "red", "white", "blue"].map((c) => (
|
|
||||||
<Box
|
|
||||||
key={c}
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "6px 8px",
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
background: "#fff",
|
|
||||||
cursor: sendJsonMessage ? "pointer" : "not-allowed",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!sendJsonMessage) return;
|
|
||||||
sendJsonMessage({ type: "set", field: "color", value: c[0].toUpperCase() });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayerColor color={c} />
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : player.name && player.live && player.has_media === false ? (
|
|
||||||
<div
|
<div
|
||||||
className="Video fade-in"
|
className="Video fade-in"
|
||||||
style={{
|
style={{
|
||||||
background: "#333",
|
background: '#333',
|
||||||
color: "#fff",
|
color: '#fff',
|
||||||
display: "flex",
|
display: 'flex',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
fontSize: "14px",
|
fontSize: '14px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
💬 Chat Only
|
💬 Chat Only
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<video className="Video"></video>
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
{gameState === 'lobby' && (
|
||||||
|
<Paper sx={{ p: 0.5, mt: 0.5, backgroundColor: '#f9f9f9' }}>
|
||||||
|
<div style={{ marginBottom: 6, fontSize: '0.9em' }}>
|
||||||
|
{player && player.color !== 'unassigned' ? 'Change' : 'Pick'} your color:
|
||||||
|
</div>
|
||||||
|
<Box style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{availableColors.map(c => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={c}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
sendJsonMessage({ type: 'set', field: 'color', value: c });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayerColor color={c} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
<Paper>
|
||||||
|
<List className="PlayerSelector Observers">
|
||||||
|
{players
|
||||||
|
.filter(p => !p.color || p.color === 'unassigned')
|
||||||
|
.map(player => {
|
||||||
|
const peerObj = peers[player.session_id] || peers[player.name];
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={player.session_id}
|
||||||
|
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||||
|
className={`PlayerEntry ${player.local ? 'PlayerSelf' : ''}`}
|
||||||
|
data-selectable={player.local && gameState === 'lobby'}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'flex-wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box style={{ display: 'flex-wrap', alignItems: 'center' }} className="Name">
|
||||||
|
{player.bot_instance_id && <>🤖</>}
|
||||||
|
{player.name ? player.name : player.session_id}
|
||||||
|
</Box>
|
||||||
|
{player.name && !player.live && <Box className="NoNetwork"></Box>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Show media control if available */}
|
||||||
|
{player.name &&
|
||||||
|
player.live &&
|
||||||
|
peerObj &&
|
||||||
|
(player.local || player.has_media !== false) && (
|
||||||
|
<MediaControl
|
||||||
|
key={player.session_id}
|
||||||
|
peer={peerObj}
|
||||||
|
isSelf={player.local}
|
||||||
|
sendJsonMessage={player.local ? sendJsonMessage : undefined}
|
||||||
|
remoteAudioMuted={peerObj?.muted}
|
||||||
|
remoteVideoOff={peerObj?.video_on === false}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -4,18 +4,30 @@ import { orange, lightBlue, red, grey } from '@mui/material/colors';
|
|||||||
const styles = {
|
const styles = {
|
||||||
R: {
|
R: {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: red[700],
|
||||||
backgroundColor: red[500],
|
backgroundColor: red[500],
|
||||||
},
|
},
|
||||||
O: {
|
O: {
|
||||||
color: '#000',
|
color: '#000',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: orange[600],
|
||||||
backgroundColor: orange[500],
|
backgroundColor: orange[500],
|
||||||
},
|
},
|
||||||
W: {
|
W: {
|
||||||
color: '#000',
|
color: '#000',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: grey[300],
|
||||||
backgroundColor: grey[50],
|
backgroundColor: grey[50],
|
||||||
},
|
},
|
||||||
B: {
|
B: {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: lightBlue[700],
|
||||||
backgroundColor: lightBlue[500],
|
backgroundColor: lightBlue[500],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -4,15 +4,22 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
|
/* allow the Trade component to stretch to its sidebar/container */
|
||||||
|
align-self: stretch;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Trade > * {
|
.Trade > * {
|
||||||
max-height: calc(100dvh - 2rem);
|
max-height: calc(100dvh - 2rem);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
width: 32em;
|
/* fill the available width of the sidebar rather than using a fixed size */
|
||||||
display: inline-flex;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Trade .Title {
|
.Trade .Title {
|
||||||
|
@ -60,7 +60,12 @@ const processTies = (players: Player[]): boolean => {
|
|||||||
/* Sort the players into buckets based on their
|
/* Sort the players into buckets based on their
|
||||||
* order, and their current roll. If a resulting
|
* order, and their current roll. If a resulting
|
||||||
* roll array has more than one element, then there
|
* roll array has more than one element, then there
|
||||||
* is a tie that must be resolved */
|
* is a tie that must be resolved. To ensure that
|
||||||
|
* earlier rolls remain more significant across
|
||||||
|
* subsequent tie-break rounds, we pad singleton
|
||||||
|
* players' numeric `order` when ties are detected so
|
||||||
|
* their earlier digits don't get outranked by extra
|
||||||
|
* digits appended to tied players. */
|
||||||
let slots: Player[][] = [];
|
let slots: Player[][] = [];
|
||||||
players.forEach((player: Player) => {
|
players.forEach((player: Player) => {
|
||||||
if (!slots[player.order]) {
|
if (!slots[player.order]) {
|
||||||
@ -87,6 +92,37 @@ const processTies = (players: Player[]): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// First detect whether any ties exist at all. If so, we need to pad
|
||||||
|
// singleton players' `order` values (multiply by 6) so that their
|
||||||
|
// existing rolls remain more significant than any additional digits
|
||||||
|
// that will be appended by tied players when they re-roll.
|
||||||
|
for (const slot of slots) {
|
||||||
|
if (slot && slot.length > 1) {
|
||||||
|
ties = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ties) {
|
||||||
|
// Pad singleton players by shifting their order a digit left in base-6
|
||||||
|
players.forEach((player: Player) => {
|
||||||
|
const slot = slots[player.order];
|
||||||
|
if (slot && slot.length === 1) {
|
||||||
|
player.order = (player.order || 0) * 6;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rebuild slots based on the newly padded orders so we can assign
|
||||||
|
// positions correctly below.
|
||||||
|
slots = [];
|
||||||
|
players.forEach((player: Player) => {
|
||||||
|
if (!slots[player.order]) {
|
||||||
|
slots[player.order] = [];
|
||||||
|
}
|
||||||
|
slots[player.order]!.push(player);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* Reverse from high to low */
|
/* Reverse from high to low */
|
||||||
const rev = slots.slice().reverse();
|
const rev = slots.slice().reverse();
|
||||||
for (const slot of rev) {
|
for (const slot of rev) {
|
||||||
@ -2803,14 +2839,43 @@ const placeRoad = (game: Game, session: Session, index: number): string | undefi
|
|||||||
road.color = session.color;
|
road.color = session.color;
|
||||||
road.type = "road";
|
road.type = "road";
|
||||||
player.roads--;
|
player.roads--;
|
||||||
|
/* Handle normal play road placement including Road Building free roads.
|
||||||
|
* If the turn is a road-building action, decrement freeRoads and
|
||||||
|
* only clear actions when no free roads remain. Otherwise, during
|
||||||
|
* initial placement we advance the initial-placement sequence. */
|
||||||
|
if (game.state === "normal") {
|
||||||
|
addActivity(game, session, `${session.name} placed a road.`);
|
||||||
|
calculateRoadLengths(game, session);
|
||||||
|
|
||||||
/* During initial placement, placing a road advances the initial-placement
|
let resetLimits = true;
|
||||||
* sequence. In forward direction we move to the next player; when the
|
if (game.turn && (game.turn as any).active === "road-building") {
|
||||||
* last player places their road we flip to backward and begin the reverse
|
if ((game.turn as any).freeRoads !== undefined) {
|
||||||
* settlement placements. In backward direction we move to the previous
|
(game.turn as any).freeRoads = (game.turn as any).freeRoads - 1;
|
||||||
* player and when the first player finishes, initial placement is done
|
}
|
||||||
* and normal play begins. */
|
if ((game.turn as any).freeRoads === 0) {
|
||||||
if (game.state === "initial-placement") {
|
delete (game.turn as any).free;
|
||||||
|
delete (game.turn as any).active;
|
||||||
|
delete (game.turn as any).freeRoads;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roads = getValidRoads(game, session.color as string);
|
||||||
|
if (!roads || roads.length === 0) {
|
||||||
|
delete (game.turn as any).active;
|
||||||
|
delete (game.turn as any).freeRoads;
|
||||||
|
addActivity(game, session, `${session.name} has another road to play, but there are no more valid locations.`);
|
||||||
|
} else if ((game.turn as any).freeRoads !== 0) {
|
||||||
|
(game.turn as any).free = true;
|
||||||
|
setForRoadPlacement(game, roads);
|
||||||
|
resetLimits = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetLimits) {
|
||||||
|
delete (game.turn as any).free;
|
||||||
|
game.turn.actions = [];
|
||||||
|
game.turn.limits = {};
|
||||||
|
}
|
||||||
|
} else if (game.state === "initial-placement") {
|
||||||
const order: PlayerColor[] = game.playerOrder;
|
const order: PlayerColor[] = game.playerOrder;
|
||||||
const idx = order.indexOf(session.color);
|
const idx = order.indexOf(session.color);
|
||||||
// defensive: if player not found, just clear actions and continue
|
// defensive: if player not found, just clear actions and continue
|
||||||
@ -4829,3 +4894,6 @@ router.post("/:id?", async (req, res /*, next*/) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
// Export helpers for unit testing
|
||||||
|
export { processTies, processGameOrder };
|
||||||
|
@ -93,10 +93,12 @@ process.on("SIGINT", () => {
|
|||||||
server.close(() => process.exit(1));
|
server.close(() => process.exit(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Opening server.");
|
if (process.env['NODE_ENV'] !== 'test') {
|
||||||
server.listen(serverConfig.port, () => {
|
console.log("Opening server.");
|
||||||
|
server.listen(serverConfig.port, () => {
|
||||||
console.log(`http/ws server listening on ${serverConfig.port}`);
|
console.log(`http/ws server listening on ${serverConfig.port}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
server.on("error", function (error: any) {
|
server.on("error", function (error: any) {
|
||||||
if (error.syscall !== "listen") {
|
if (error.syscall !== "listen") {
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
const { app } = require('../src/app');
|
const { app } = require('../src/app');
|
||||||
|
const gamesModule = require('../../server/routes/games');
|
||||||
|
|
||||||
|
// Import helpers for direct testing (exported for tests)
|
||||||
|
const { processTies } = gamesModule;
|
||||||
|
|
||||||
describe('Server Routes', () => {
|
describe('Server Routes', () => {
|
||||||
it('should respond to GET /', async () => {
|
it('should respond to GET /', async () => {
|
||||||
@ -8,4 +12,43 @@ describe('Server Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add more tests as needed
|
// Add more tests as needed
|
||||||
|
it('resolves ties and preserves earlier rolls precedence', () => {
|
||||||
|
/* Build fake players to simulate the scenario:
|
||||||
|
* - James rolled 2
|
||||||
|
* - Guest rolled 2
|
||||||
|
* - Incognito rolled 5
|
||||||
|
* Expectation: Incognito 1st, Guest 2nd, James 3rd
|
||||||
|
* To simulate tie re-roll, processTies should mark ties and pad singleton orders.
|
||||||
|
*/
|
||||||
|
const players = [
|
||||||
|
{ name: 'James', color: 'R', order: 2, orderRoll: 2, position: '', orderStatus: '', tied: false },
|
||||||
|
{ name: 'Guest', color: 'B', order: 2, orderRoll: 2, position: '', orderStatus: '', tied: false },
|
||||||
|
{ name: 'Incognito', color: 'O', order: 5, orderRoll: 5, position: '', orderStatus: '', tied: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// First sort like the server does (descending by order)
|
||||||
|
players.sort((A: any, B: any) => B.order - A.order);
|
||||||
|
|
||||||
|
// Invoke processTies to simulate the server behavior
|
||||||
|
const hadTies = processTies(players as any);
|
||||||
|
|
||||||
|
// There should be ties among the two players who rolled 2
|
||||||
|
expect(hadTies).toBe(true);
|
||||||
|
|
||||||
|
// Incognito should be placed 1st and not tied
|
||||||
|
const inc = players.find((p: any) => p.name === 'Incognito');
|
||||||
|
expect(inc).toBeDefined();
|
||||||
|
expect(inc!.position).toBe('1st');
|
||||||
|
expect(inc!.tied).toBe(false);
|
||||||
|
|
||||||
|
// The two players who tied should have been marked tied and have orderRoll reset
|
||||||
|
const james = players.find((p: any) => p.name === 'James');
|
||||||
|
const guest = players.find((p: any) => p.name === 'Guest');
|
||||||
|
expect(james).toBeDefined();
|
||||||
|
expect(guest).toBeDefined();
|
||||||
|
expect(james!.tied).toBe(true);
|
||||||
|
expect(guest!.tied).toBe(true);
|
||||||
|
expect(james!.orderRoll).toBe(0);
|
||||||
|
expect(guest!.orderRoll).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
Loading…
x
Reference in New Issue
Block a user