feat(know-your-world): sync letter confirmation across multiplayer sessions
Add shared state for learning mode name confirmation: - Add nameConfirmationProgress to KnowYourWorldState - Add CONFIRM_LETTER move type for validating letter entry - Implement validateConfirmLetter in Validator (looks up region name from ID) - Add confirmLetter action to Provider context - Update GameInfoPanel to use shared state with optimistic updates Fixes race condition when typing fast by using an optimistic ref that updates immediately before server responds, then syncs with server state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
285b128bb8
commit
655660f7cf
|
|
@ -13,6 +13,7 @@ import { buildPlayerOwnershipFromRoomData } from '@/lib/arcade/player-ownership.
|
|||
import type { KnowYourWorldState, AssistanceLevel } from './types'
|
||||
import type { RegionSize } from './maps'
|
||||
import type { FeedbackType } from './utils/hotColdPhrases'
|
||||
import { MusicProvider } from './music'
|
||||
|
||||
// Controls state for GameInfoPanel (set by MapRenderer, read by GameInfoPanel)
|
||||
export interface ControlsState {
|
||||
|
|
@ -98,6 +99,7 @@ interface KnowYourWorldContextValue {
|
|||
giveUp: () => void
|
||||
requestHint: () => void
|
||||
returnToSetup: () => void
|
||||
confirmLetter: (letter: string, letterIndex: number) => void
|
||||
|
||||
// Setup actions
|
||||
setMap: (map: 'world' | 'usa') => void
|
||||
|
|
@ -234,6 +236,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
nameConfirmationProgress: 0,
|
||||
}
|
||||
}, [roomData])
|
||||
|
||||
|
|
@ -377,6 +380,19 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
})
|
||||
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
|
||||
|
||||
// Action: Confirm Letter (for learning mode name confirmation)
|
||||
const confirmLetter = useCallback(
|
||||
(letter: string, letterIndex: number) => {
|
||||
sendMove({
|
||||
type: 'CONFIRM_LETTER',
|
||||
playerId: state.currentPlayer || activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { letter, letterIndex },
|
||||
})
|
||||
},
|
||||
[viewerId, sendMove, state.currentPlayer, activePlayers]
|
||||
)
|
||||
|
||||
// Setup Action: Set Map
|
||||
const setMap = useCallback(
|
||||
(selectedMap: 'world' | 'usa') => {
|
||||
|
|
@ -545,6 +561,12 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
>
|
||||
}, [roomData?.memberPlayers])
|
||||
|
||||
// Music is active when game is in playing phase
|
||||
const isMusicActive = state.gamePhase === 'playing'
|
||||
|
||||
// Pass celebration state to music provider (with type only)
|
||||
const musicCelebration = celebration ? { type: celebration.type } : null
|
||||
|
||||
return (
|
||||
<KnowYourWorldContext.Provider
|
||||
value={{
|
||||
|
|
@ -558,6 +580,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
endGame,
|
||||
giveUp,
|
||||
requestHint,
|
||||
confirmLetter,
|
||||
returnToSetup,
|
||||
setMap,
|
||||
setMode,
|
||||
|
|
@ -577,7 +600,15 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
sharedContainerRef,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<MusicProvider
|
||||
isGameActive={isMusicActive}
|
||||
currentRegionId={state.currentPrompt}
|
||||
mapType={state.selectedMap}
|
||||
hotColdFeedback={controlsState.hotColdFeedbackType}
|
||||
celebration={musicCelebration}
|
||||
>
|
||||
{children}
|
||||
</MusicProvider>
|
||||
</KnowYourWorldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export class KnowYourWorldValidator
|
|||
return await this.validateGiveUp(state, move.playerId, move.userId)
|
||||
case 'REQUEST_HINT':
|
||||
return this.validateRequestHint(state, move.playerId, move.timestamp)
|
||||
case 'CONFIRM_LETTER':
|
||||
return await this.validateConfirmLetter(state, move.playerId, move.userId, move.data)
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
|
|
@ -115,6 +117,7 @@ export class KnowYourWorldValidator
|
|||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
nameConfirmationProgress: 0, // Reset for new prompt
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -217,6 +220,7 @@ export class KnowYourWorldValidator
|
|||
giveUpVotes: [], // Clear votes when moving to next region
|
||||
hintActive: null, // Clear hint when moving to next region
|
||||
activeUserIds,
|
||||
nameConfirmationProgress: 0, // Reset for new prompt
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -301,6 +305,7 @@ export class KnowYourWorldValidator
|
|||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
nameConfirmationProgress: 0, // Reset for new round
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -423,6 +428,7 @@ export class KnowYourWorldValidator
|
|||
startTime: 0,
|
||||
endTime: undefined,
|
||||
giveUpReveal: null,
|
||||
nameConfirmationProgress: 0,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -551,6 +557,7 @@ export class KnowYourWorldValidator
|
|||
giveUpVotes: [], // Clear votes after give up is executed
|
||||
hintActive: null, // Clear hint when moving to next region
|
||||
activeUserIds,
|
||||
nameConfirmationProgress: 0, // Reset for new prompt
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -590,6 +597,71 @@ export class KnowYourWorldValidator
|
|||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private async validateConfirmLetter(
|
||||
state: KnowYourWorldState,
|
||||
playerId: string,
|
||||
userId: string,
|
||||
data: { letter: string; letterIndex: number }
|
||||
): Promise<ValidationResult> {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only confirm letters during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.currentPrompt) {
|
||||
return { valid: false, error: 'No region to confirm' }
|
||||
}
|
||||
|
||||
// For turn-based: check if it's this player's turn
|
||||
if (state.gameMode === 'turn-based' && state.currentPlayer !== playerId) {
|
||||
return { valid: false, error: 'Not your turn' }
|
||||
}
|
||||
|
||||
const { letter, letterIndex } = data
|
||||
|
||||
// Check that letterIndex matches current progress
|
||||
const currentProgress = state.nameConfirmationProgress ?? 0
|
||||
if (letterIndex !== currentProgress) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Expected letter index ${currentProgress}, got ${letterIndex}`,
|
||||
}
|
||||
}
|
||||
|
||||
// currentPrompt is actually the region ID (e.g., "ru"), not the name
|
||||
// We need to look up the actual region name from the map data
|
||||
const mapData = await getFilteredMapDataBySizesLazy(
|
||||
state.selectedMap,
|
||||
state.selectedContinent,
|
||||
state.includeSizes
|
||||
)
|
||||
const region = mapData.regions.find((r) => r.id === state.currentPrompt)
|
||||
if (!region) {
|
||||
return { valid: false, error: 'Region not found in map data' }
|
||||
}
|
||||
const regionName = region.name
|
||||
|
||||
// Check if the letter matches
|
||||
const expectedLetter = regionName[letterIndex]?.toLowerCase()
|
||||
if (letter.toLowerCase() !== expectedLetter) {
|
||||
// Wrong letter - don't advance progress (but move is still valid, just ignored)
|
||||
return { valid: true, newState: state }
|
||||
}
|
||||
|
||||
// Correct letter - advance progress
|
||||
const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId)
|
||||
|
||||
const newState: KnowYourWorldState = {
|
||||
...state,
|
||||
nameConfirmationProgress: currentProgress + 1,
|
||||
activeUserIds,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
isGameComplete(state: KnowYourWorldState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
|
@ -642,6 +714,7 @@ export class KnowYourWorldValidator
|
|||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
nameConfirmationProgress: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export function GameInfoPanel({
|
|||
}: GameInfoPanelProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const { state, lastError, clearError, giveUp, controlsState, setIsInTakeover } =
|
||||
const { state, lastError, clearError, giveUp, confirmLetter, controlsState, setIsInTakeover } =
|
||||
useKnowYourWorld()
|
||||
|
||||
// Destructure controls state from context
|
||||
|
|
@ -170,10 +170,14 @@ export function GameInfoPanel({
|
|||
// Track if we're in the "attention grab" phase for the name display
|
||||
const [isAttentionPhase, setIsAttentionPhase] = useState(false)
|
||||
|
||||
// Track name confirmation via keypress (count of confirmed letters)
|
||||
const [confirmedLetterCount, setConfirmedLetterCount] = useState(0)
|
||||
// Name confirmation progress from shared state (synced across sessions)
|
||||
const confirmedLetterCount = state.nameConfirmationProgress ?? 0
|
||||
const [nameConfirmed, setNameConfirmed] = useState(false)
|
||||
|
||||
// Optimistic letter count ref - prevents race conditions when typing fast
|
||||
// This is updated immediately on keypress, before server responds
|
||||
const optimisticLetterCountRef = useRef(confirmedLetterCount)
|
||||
|
||||
// Get assistance level config
|
||||
const assistanceConfig = useMemo(() => {
|
||||
return getAssistanceLevel(state.assistanceLevel)
|
||||
|
|
@ -225,8 +229,15 @@ export function GameInfoPanel({
|
|||
// During give up animation, suppress takeover (progress = 1 means no takeover)
|
||||
if (isGiveUpAnimating) return 1
|
||||
if (!isLearningMode || requiresNameConfirmation === 0) return 1
|
||||
return Math.min(1, confirmedLetterCount / requiresNameConfirmation)
|
||||
}, [isLearningMode, requiresNameConfirmation, confirmedLetterCount, isGiveUpAnimating])
|
||||
const progress = Math.min(1, confirmedLetterCount / requiresNameConfirmation)
|
||||
console.log('[GameInfoPanel] takeoverProgress:', {
|
||||
confirmedLetterCount,
|
||||
requiresNameConfirmation,
|
||||
progress,
|
||||
stateProgress: state.nameConfirmationProgress,
|
||||
})
|
||||
return progress
|
||||
}, [isLearningMode, requiresNameConfirmation, confirmedLetterCount, isGiveUpAnimating, state.nameConfirmationProgress])
|
||||
|
||||
// Spring animation for scale only - position is handled by CSS centering
|
||||
const takeoverSpring = useSpring({
|
||||
|
|
@ -243,10 +254,10 @@ export function GameInfoPanel({
|
|||
setIsInTakeover(isInTakeoverLocal)
|
||||
}, [isInTakeoverLocal, setIsInTakeover])
|
||||
|
||||
// Reset name confirmation when region changes
|
||||
// Reset local UI state when region changes
|
||||
// Note: nameConfirmationProgress is reset on the server when prompt changes
|
||||
useEffect(() => {
|
||||
if (currentRegionId) {
|
||||
setConfirmedLetterCount(0)
|
||||
setNameConfirmed(false)
|
||||
setIsAttentionPhase(true)
|
||||
|
||||
|
|
@ -271,7 +282,19 @@ export function GameInfoPanel({
|
|||
}
|
||||
}, [confirmedLetterCount, requiresNameConfirmation, nameConfirmed, onHintsUnlock])
|
||||
|
||||
// Sync optimistic ref with server state when it arrives
|
||||
// This ensures we stay in sync if server rejects a move
|
||||
useEffect(() => {
|
||||
optimisticLetterCountRef.current = confirmedLetterCount
|
||||
}, [confirmedLetterCount])
|
||||
|
||||
// Reset optimistic ref when region changes
|
||||
useEffect(() => {
|
||||
optimisticLetterCountRef.current = 0
|
||||
}, [currentRegionName])
|
||||
|
||||
// Listen for keypresses to confirm letters (only when name confirmation is required)
|
||||
// Dispatches to shared state so all multiplayer sessions see the same progress
|
||||
useEffect(() => {
|
||||
if (requiresNameConfirmation === 0 || nameConfirmed || !currentRegionName) {
|
||||
return
|
||||
|
|
@ -283,8 +306,8 @@ export function GameInfoPanel({
|
|||
return
|
||||
}
|
||||
|
||||
// Get the next expected letter
|
||||
const nextLetterIndex = confirmedLetterCount
|
||||
// Use optimistic count to prevent race conditions when typing fast
|
||||
const nextLetterIndex = optimisticLetterCountRef.current
|
||||
if (nextLetterIndex >= requiresNameConfirmation) {
|
||||
return // Already confirmed all required letters
|
||||
}
|
||||
|
|
@ -295,7 +318,10 @@ export function GameInfoPanel({
|
|||
// Only accept single character keys (letters and space)
|
||||
if (pressedLetter.length === 1 && /[a-z ]/i.test(pressedLetter)) {
|
||||
if (pressedLetter === expectedLetter) {
|
||||
setConfirmedLetterCount((prev) => prev + 1)
|
||||
// Optimistically advance count before server responds
|
||||
optimisticLetterCountRef.current = nextLetterIndex + 1
|
||||
// Dispatch to shared state - server validates and broadcasts to all sessions
|
||||
confirmLetter(pressedLetter, nextLetterIndex)
|
||||
}
|
||||
// Ignore wrong characters silently (no feedback, no backspace needed)
|
||||
}
|
||||
|
|
@ -303,7 +329,12 @@ export function GameInfoPanel({
|
|||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [requiresNameConfirmation, nameConfirmed, currentRegionName, confirmedLetterCount])
|
||||
}, [
|
||||
requiresNameConfirmation,
|
||||
nameConfirmed,
|
||||
currentRegionName,
|
||||
confirmLetter,
|
||||
])
|
||||
|
||||
// Check if animation is in progress based on timestamp
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,11 @@ export interface KnowYourWorldState extends GameState {
|
|||
regionId: string
|
||||
timestamp: number // For animation timing
|
||||
} | null
|
||||
|
||||
// Name confirmation progress (learning mode - type first N letters)
|
||||
// Tracks how many letters have been confirmed for the current prompt
|
||||
// Resets to 0 when currentPrompt changes
|
||||
nameConfirmationProgress: number
|
||||
}
|
||||
|
||||
// Move types
|
||||
|
|
@ -209,3 +214,13 @@ export type KnowYourWorldMove =
|
|||
timestamp: number
|
||||
data: {}
|
||||
}
|
||||
| {
|
||||
type: 'CONFIRM_LETTER'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
letter: string // The letter being confirmed (lowercase)
|
||||
letterIndex: number // Which letter position (0-indexed)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue