From e7e67a023d9e95154a028b7e70e6dbcfdbf8d8eb Mon Sep 17 00:00:00 2001
From: Thomas Hallock
Date: Wed, 29 Oct 2025 17:03:30 -0500
Subject: [PATCH 1/2] Improve Rithmomachia player roster handling
---
.../arcade-games/rithmomachia/Provider.tsx | 136 +++++--
.../components/RithmomachiaGame.tsx | 380 +++++++++++++++++-
2 files changed, 466 insertions(+), 50 deletions(-)
diff --git a/apps/web/src/arcade-games/rithmomachia/Provider.tsx b/apps/web/src/arcade-games/rithmomachia/Provider.tsx
index 2bdbf2e0..92f08350 100644
--- a/apps/web/src/arcade-games/rithmomachia/Provider.tsx
+++ b/apps/web/src/arcade-games/rithmomachia/Provider.tsx
@@ -21,6 +21,14 @@ import type {
/**
* Context value for Rithmomachia game.
*/
+export type RithmomachiaRosterStatus =
+ | { status: 'ok'; activePlayerCount: number; localPlayerCount: number }
+ | {
+ status: 'tooFew' | 'tooMany' | 'noLocalControl'
+ activePlayerCount: number
+ localPlayerCount: number
+ }
+
interface RithmomachiaContextValue {
// State
state: RithmomachiaState
@@ -30,6 +38,11 @@ interface RithmomachiaContextValue {
viewerId: string | null
playerColor: Color | null
isMyTurn: boolean
+ rosterStatus: RithmomachiaRosterStatus
+ localActivePlayerIds: string[]
+ whitePlayerId: string | null
+ blackPlayerId: string | null
+ localTurnPlayerId: string | null
// Game actions
startGame: () => void
@@ -92,13 +105,38 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
- // Get local player ID
- const localPlayerId = useMemo(() => {
- return Array.from(activePlayerIds).find((id) => {
- const player = players.get(id)
- return player?.isLocal !== false
- })
- }, [activePlayerIds, players])
+ const activePlayerList = useMemo(() => Array.from(activePlayerIds), [activePlayerIds])
+
+ const whitePlayerId = activePlayerList[0] ?? null
+ const blackPlayerId = activePlayerList[1] ?? null
+
+ const localActivePlayerIds = useMemo(
+ () =>
+ activePlayerList.filter((id) => {
+ const player = players.get(id)
+ return player?.isLocal !== false
+ }),
+ [activePlayerList, players]
+ )
+
+ const rosterStatus = useMemo(() => {
+ const activeCount = activePlayerList.length
+ const localCount = localActivePlayerIds.length
+
+ if (activeCount < 2) {
+ return { status: 'tooFew', activePlayerCount: activeCount, localPlayerCount: localCount }
+ }
+
+ if (activeCount > 2) {
+ return { status: 'tooMany', activePlayerCount: activeCount, localPlayerCount: localCount }
+ }
+
+ if (localCount === 0) {
+ return { status: 'noLocalControl', activePlayerCount: activeCount, localPlayerCount: localCount }
+ }
+
+ return { status: 'ok', activePlayerCount: activeCount, localPlayerCount: localCount }
+ }, [activePlayerList, localActivePlayerIds])
// Merge saved config from room data
const mergedInitialState = useMemo(() => {
@@ -129,33 +167,46 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
applyMove: (state) => state, // No optimistic updates for v1 - rely on server validation
})
- // Determine player color (simplified: first player is White, second is Black)
+ const localTurnPlayerId = useMemo(() => {
+ const currentId = state.turn === 'W' ? whitePlayerId : blackPlayerId
+ if (!currentId) return null
+ return localActivePlayerIds.includes(currentId) ? currentId : null
+ }, [state.turn, whitePlayerId, blackPlayerId, localActivePlayerIds])
+
const playerColor = useMemo((): Color | null => {
- if (!localPlayerId) return null
- const playerIndex = Array.from(activePlayerIds).indexOf(localPlayerId)
- return playerIndex === 0 ? 'W' : 'B'
- }, [localPlayerId, activePlayerIds])
+ if (localTurnPlayerId) {
+ return state.turn
+ }
+
+ if (localActivePlayerIds.length === 1) {
+ const soleLocalId = localActivePlayerIds[0]
+ if (soleLocalId === whitePlayerId) return 'W'
+ if (soleLocalId === blackPlayerId) return 'B'
+ }
+
+ return null
+ }, [localTurnPlayerId, localActivePlayerIds, whitePlayerId, blackPlayerId, state.turn])
// Check if it's my turn
const isMyTurn = useMemo(() => {
- if (!playerColor) return false
- return state.turn === playerColor
- }, [state.turn, playerColor])
+ if (rosterStatus.status !== 'ok') return false
+ return localTurnPlayerId !== null
+ }, [rosterStatus.status, localTurnPlayerId])
// Action: Start game
const startGame = useCallback(() => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'START_GAME',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {
playerColor: playerColor || 'W',
- activePlayers: Array.from(activePlayerIds),
+ activePlayers: activePlayerList,
},
})
- }, [sendMove, viewerId, localPlayerId, playerColor, activePlayerIds])
+ }, [sendMove, viewerId, localTurnPlayerId, playerColor, activePlayerList])
// Action: Make a move
const makeMove = useCallback(
@@ -167,11 +218,11 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
capture?: CaptureData,
ambush?: AmbushContext
) => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'MOVE',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {
from,
@@ -189,17 +240,17 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
},
})
},
- [sendMove, viewerId, localPlayerId]
+ [sendMove, viewerId, localTurnPlayerId]
)
// Action: Declare harmony
const declareHarmony = useCallback(
(pieceIds: string[], harmonyType: HarmonyType, params: Record) => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'DECLARE_HARMONY',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {
pieceIds,
@@ -208,68 +259,68 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
},
})
},
- [sendMove, viewerId, localPlayerId]
+ [sendMove, viewerId, localTurnPlayerId]
)
// Action: Resign
const resign = useCallback(() => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'RESIGN',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
- }, [sendMove, viewerId, localPlayerId])
+ }, [sendMove, viewerId, localTurnPlayerId])
// Action: Offer draw
const offerDraw = useCallback(() => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'OFFER_DRAW',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
- }, [sendMove, viewerId, localPlayerId])
+ }, [sendMove, viewerId, localTurnPlayerId])
// Action: Accept draw
const acceptDraw = useCallback(() => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'ACCEPT_DRAW',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
- }, [sendMove, viewerId, localPlayerId])
+ }, [sendMove, viewerId, localTurnPlayerId])
// Action: Claim repetition
const claimRepetition = useCallback(() => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'CLAIM_REPETITION',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
- }, [sendMove, viewerId, localPlayerId])
+ }, [sendMove, viewerId, localTurnPlayerId])
// Action: Claim fifty-move rule
const claimFiftyMove = useCallback(() => {
- if (!viewerId || !localPlayerId) return
+ if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'CLAIM_FIFTY_MOVE',
- playerId: localPlayerId,
+ playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
- }, [sendMove, viewerId, localPlayerId])
+ }, [sendMove, viewerId, localTurnPlayerId])
// Action: Set config
const setConfig = useCallback(
@@ -338,6 +389,11 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
viewerId: viewerId ?? null,
playerColor,
isMyTurn,
+ rosterStatus,
+ localActivePlayerIds,
+ whitePlayerId,
+ blackPlayerId,
+ localTurnPlayerId,
startGame,
makeMove,
declareHarmony,
diff --git a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx
index 66f7a277..1ab66d23 100644
--- a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx
+++ b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx
@@ -3,10 +3,12 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { animated, to, useSpring } from '@react-spring/web'
import { useRouter } from 'next/navigation'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { StandardGameLayout } from '@/components/StandardGameLayout'
import { Z_INDEX } from '@/constants/zIndex'
+import { useGameMode } from '@/contexts/GameModeContext'
+import type { Player } from '@/contexts/GameModeContext'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { css } from '../../../../styled-system/css'
import { useRithmomachia } from '../Provider'
@@ -194,11 +196,346 @@ export function RithmomachiaGame() {
)
}
+function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
+ const { rosterStatus, whitePlayerId, blackPlayerId } = useRithmomachia()
+ const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode()
+ const [showManager, setShowManager] = useState(false)
+
+ const playersArray = useMemo(() => {
+ const list = Array.from(playerMap.values())
+ return list.sort((a, b) => {
+ const aTime =
+ typeof a.createdAt === 'number'
+ ? a.createdAt
+ : a.createdAt instanceof Date
+ ? a.createdAt.getTime()
+ : 0
+ const bTime =
+ typeof b.createdAt === 'number'
+ ? b.createdAt
+ : b.createdAt instanceof Date
+ ? b.createdAt.getTime()
+ : 0
+ return aTime - bTime
+ })
+ }, [playerMap])
+
+ const activePlayerNames = useMemo(
+ () =>
+ playersArray
+ .filter((player) => activePlayerIds.has(player.id))
+ .map((player) =>
+ `${player.emoji} ${player.name}${player.isLocal === false ? ' (remote)' : ''}`
+ ),
+ [playersArray, activePlayerIds]
+ )
+
+ const inactiveLocalPlayer = useMemo(
+ () =>
+ playersArray.find(
+ (player) => player.isLocal !== false && !activePlayerIds.has(player.id)
+ ) || null,
+ [playersArray, activePlayerIds]
+ )
+
+ const removableLocalPlayer = useMemo(
+ () =>
+ playersArray.find(
+ (player) =>
+ player.isLocal !== false &&
+ activePlayerIds.has(player.id) &&
+ player.id !== whitePlayerId &&
+ player.id !== blackPlayerId
+ ) || null,
+ [playersArray, activePlayerIds, whitePlayerId, blackPlayerId]
+ )
+
+ const quickFix = useMemo(() => {
+ if (rosterStatus.status === 'tooFew') {
+ if (inactiveLocalPlayer) {
+ return {
+ label: `Activate ${inactiveLocalPlayer.name}`,
+ action: () => setActive(inactiveLocalPlayer.id, true),
+ }
+ }
+
+ return {
+ label: 'Create local player',
+ action: () => addPlayer({ isActive: true }),
+ }
+ }
+
+ if (rosterStatus.status === 'noLocalControl') {
+ if (inactiveLocalPlayer) {
+ return {
+ label: `Activate ${inactiveLocalPlayer.name}`,
+ action: () => setActive(inactiveLocalPlayer.id, true),
+ }
+ }
+
+ return null
+ }
+
+ if (rosterStatus.status === 'tooMany' && removableLocalPlayer) {
+ return {
+ label: `Deactivate ${removableLocalPlayer.name}`,
+ action: () => setActive(removableLocalPlayer.id, false),
+ }
+ }
+
+ return null
+ }, [rosterStatus.status, inactiveLocalPlayer, removableLocalPlayer, addPlayer, setActive])
+
+ const heading = useMemo(() => {
+ switch (rosterStatus.status) {
+ case 'tooFew':
+ return 'Need two active players'
+ case 'tooMany':
+ return 'Too many active players'
+ case 'noLocalControl':
+ return 'Join the roster from this device'
+ default:
+ return ''
+ }
+ }, [rosterStatus.status])
+
+ const description = useMemo(() => {
+ switch (rosterStatus.status) {
+ case 'tooFew':
+ return phase === 'setup'
+ ? 'Rithmomachia needs exactly two active players before the match can begin. Activate or add another player to continue.'
+ : 'Gameplay is paused until two players are active. Activate or add another player to resume the match.'
+ case 'tooMany':
+ return 'Rithmomachia supports only two active players. Deactivate extra players so each color has exactly one seat.'
+ case 'noLocalControl':
+ return phase === 'setup'
+ ? 'All active seats belong to other devices. Activate a local player to control a side before starting.'
+ : 'All active seats belong to other devices. Activate a local player if you want to make moves from this computer.'
+ default:
+ return ''
+ }
+ }, [phase, rosterStatus.status])
+
+ if (rosterStatus.status === 'ok') {
+ return null
+ }
+
+ const details: string[] = []
+ if (activePlayerNames.length > 0) {
+ details.push(`Active seats: ${activePlayerNames.join(', ')}`)
+ } else {
+ details.push('No players are currently marked active.')
+ }
+
+ return (
+ <>
+
+
+
{heading}
+
{description}
+ {details.map((detail) => (
+
+ {detail}
+
+ ))}
+
+
+ {quickFix && (
+
+ {quickFix.label}
+
+ )}
+ setShowManager((prev) => !prev)}
+ className={css({
+ px: '3',
+ py: '2',
+ bg: 'white',
+ color: 'amber.900',
+ borderRadius: 'md',
+ fontWeight: 'semibold',
+ fontSize: 'sm',
+ borderWidth: '1px',
+ borderColor: 'amber.300',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease',
+ _hover: { bg: 'amber.100' },
+ })}
+ >
+ {showManager ? 'Hide player manager' : 'Manage players'}
+
+
+
+ {showManager && (
+ addPlayer({ isActive: true })}
+ />
+ )}
+ >
+ )
+}
+
+function InlinePlayerManager({
+ players,
+ activePlayerIds,
+ onToggleActive,
+ onAddPlayer,
+}: {
+ players: Player[]
+ activePlayerIds: Set
+ onToggleActive: (playerId: string, active: boolean) => void
+ onAddPlayer: () => void
+}) {
+ return (
+
+
+
+ Player seats
+
+
+ Add local player
+
+
+
+
+ )
+}
+
/**
* Setup phase: game configuration and start button.
*/
function SetupPhase() {
- const { state, startGame, setConfig, lastError, clearError } = useRithmomachia()
+ const { state, startGame, setConfig, lastError, clearError, rosterStatus } = useRithmomachia()
+ const startDisabled = rosterStatus.status !== 'ok'
const toggleSetting = (key: keyof typeof state) => {
if (typeof state[key] === 'boolean') {
@@ -265,6 +602,8 @@ function SetupPhase() {
+
+
{/* Game Settings */}
Start Game
@@ -453,7 +796,7 @@ function SetupPhase() {
* Playing phase: main game board and controls.
*/
function PlayingPhase() {
- const { state, isMyTurn, lastError, clearError } = useRithmomachia()
+ const { state, isMyTurn, lastError, clearError, rosterStatus } = useRithmomachia()
return (
)}
+
+
)}
+ {!isMyTurn && rosterStatus.status === 'ok' && (
+
+ Waiting for {state.turn === 'W' ? 'White' : 'Black'}
+
+ )}
From 5b56f1272bfaff77b4699799676969a1c46c6942 Mon Sep 17 00:00:00 2001
From: Thomas Hallock
Date: Wed, 29 Oct 2025 17:15:02 -0500
Subject: [PATCH 2/2] Simplify Rithmomachia roster notice
---
.../components/RithmomachiaGame.tsx | 226 ++----------------
1 file changed, 25 insertions(+), 201 deletions(-)
diff --git a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx
index 1ab66d23..01c40e22 100644
--- a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx
+++ b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx
@@ -8,7 +8,6 @@ import { PageWithNav } from '@/components/PageWithNav'
import { StandardGameLayout } from '@/components/StandardGameLayout'
import { Z_INDEX } from '@/constants/zIndex'
import { useGameMode } from '@/contexts/GameModeContext'
-import type { Player } from '@/contexts/GameModeContext'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { css } from '../../../../styled-system/css'
import { useRithmomachia } from '../Provider'
@@ -199,7 +198,6 @@ export function RithmomachiaGame() {
function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
const { rosterStatus, whitePlayerId, blackPlayerId } = useRithmomachia()
const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode()
- const [showManager, setShowManager] = useState(false)
const playersArray = useMemo(() => {
const list = Array.from(playerMap.values())
@@ -220,16 +218,6 @@ function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
})
}, [playerMap])
- const activePlayerNames = useMemo(
- () =>
- playersArray
- .filter((player) => activePlayerIds.has(player.id))
- .map((player) =>
- `${player.emoji} ${player.name}${player.isLocal === false ? ' (remote)' : ''}`
- ),
- [playersArray, activePlayerIds]
- )
-
const inactiveLocalPlayer = useMemo(
() =>
playersArray.find(
@@ -303,14 +291,14 @@ function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
switch (rosterStatus.status) {
case 'tooFew':
return phase === 'setup'
- ? 'Rithmomachia needs exactly two active players before the match can begin. Activate or add another player to continue.'
- : 'Gameplay is paused until two players are active. Activate or add another player to resume the match.'
+ ? 'Rithmomachia needs exactly two active players before the match can begin. Use the roster controls in the game nav to activate or add another player.'
+ : 'Gameplay is paused until two players are active. Use the roster controls in the game nav to activate or add another player and resume the match.'
case 'tooMany':
- return 'Rithmomachia supports only two active players. Deactivate extra players so each color has exactly one seat.'
+ return 'Rithmomachia supports only two active players. Use the game nav roster to deactivate extras so each color has exactly one seat.'
case 'noLocalControl':
return phase === 'setup'
- ? 'All active seats belong to other devices. Activate a local player to control a side before starting.'
- : 'All active seats belong to other devices. Activate a local player if you want to make moves from this computer.'
+ ? 'All active seats belong to other devices. Activate a local player from the game nav if you want to start from this computer.'
+ : 'All active seats belong to other devices. Activate a local player in the game nav if you want to make moves from this computer.'
default:
return ''
}
@@ -320,212 +308,48 @@ function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
return null
}
- const details: string[] = []
- if (activePlayerNames.length > 0) {
- details.push(`Active seats: ${activePlayerNames.join(', ')}`)
- } else {
- details.push('No players are currently marked active.')
- }
-
- return (
- <>
-
-
-
{heading}
-
{description}
- {details.map((detail) => (
-
- {detail}
-
- ))}
-
-
- {quickFix && (
-
- {quickFix.label}
-
- )}
- setShowManager((prev) => !prev)}
- className={css({
- px: '3',
- py: '2',
- bg: 'white',
- color: 'amber.900',
- borderRadius: 'md',
- fontWeight: 'semibold',
- fontSize: 'sm',
- borderWidth: '1px',
- borderColor: 'amber.300',
- cursor: 'pointer',
- transition: 'all 0.2s ease',
- _hover: { bg: 'amber.100' },
- })}
- >
- {showManager ? 'Hide player manager' : 'Manage players'}
-
-
-
- {showManager && (
- addPlayer({ isActive: true })}
- />
- )}
- >
- )
-}
-
-function InlinePlayerManager({
- players,
- activePlayerIds,
- onToggleActive,
- onAddPlayer,
-}: {
- players: Player[]
- activePlayerIds: Set
- onToggleActive: (playerId: string, active: boolean) => void
- onAddPlayer: () => void
-}) {
return (
-
-
- Player seats
-
+
+
{heading}
+
{description}
+
+ {quickFix && (
- Add local player
+ {quickFix.label}
-
-
+ )}
)
}