import { useState, useRef, useEffect, useMemo } from 'react'; import { useCollection, useIdentity, useReactions, useAI, playSound } from '@deplixo/sdk'; import { GameSetup } from './components/GameSetup.jsx'; import { ScoreBoard } from './components/ScoreBoard.jsx'; import { GameHistory } from './components/GameHistory.jsx'; function ReactionBar({ targetId, targetLabel }) { const { counts, toggle, loading } = useReactions(targetId); const emojis = ['👍', '❤️', '🎉', '🔥', '👀']; if (loading || !targetId) return null; return (
{emojis.map(emoji => ( ))}
); } function PlayReactionFeed({ activeGame, turnTiming }) { const rounds = Array.isArray(activeGame?.value?.rounds) ? activeGame.value.rounds : []; const currentRound = rounds.length > 0 ? rounds[rounds.length - 1] : null; const currentRoundId = currentRound ? `${activeGame.id}-round-${currentRound.round || rounds.length}` : null; const currentPlayerLabel = currentRound?.activePlayerId || activeGame?.value?.currentTurnPlayerId || 'Current play'; return (

Live Reactions

Spectators can react to the current play in real time.

{turnTiming?.isTimedTurns && ( Turn {turnTiming.remaining}s )}
{currentRound ? ( <>
Round {currentRound.round || rounds.length} {currentPlayerLabel}
) : (
Reactions will appear here once a round starts.
)}
); } function App() { const [tab, setTab] = useState('play'); const [setupGameData, setSetupGameData] = useState(null); const [handicapSuggestion, setHandicapSuggestion] = useState(null); const [handicapLoading, setHandicapLoading] = useState(false); const [handicapError, setHandicapError] = useState(''); const [applyHandicapToNewGame, setApplyHandicapToNewGame] = useState(true); const { items: games, loading, add, update, remove } = useCollection('games', { personal: true }); const { user: identityUser, loading: identityLoading } = useIdentity(); const { items: identities, loading: identitiesLoading, add: addIdentity, update: updateIdentity } = useCollection('player-identities', { personal: true }); const { items: completedGameSessions, loading: sessionsLoading, add: addGameSession, update: updateGameSession } = useCollection('game-sessions', { personal: true }); const { generate: generateAI, loading: aiLoading, error: aiError } = useAI(); const lastLeaderIdRef = useRef(null); const lastGameStatusRef = useRef(null); const timerTickRef = useRef(null); const savedIdentity = identities && identities.length > 0 ? identities[0] : null; const activeGame = games.find(g => g.value.status === 'active'); const playerIdentity = savedIdentity ? savedIdentity.value : identityUser ? { id: identityUser.id, name: identityUser.name, avatar: identityUser.avatar } : null; const buildGameSummary = (gameValue) => { const players = Array.isArray(gameValue?.players) ? gameValue.players : []; const rounds = Array.isArray(gameValue?.rounds) ? gameValue.rounds : []; const totals = Array.isArray(gameValue?.totals) ? gameValue.totals : []; const sortedTotals = [...totals].sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0)); const winner = sortedTotals.length > 0 ? sortedTotals[0] : null; const summary = { playerCount: players.length, roundCount: rounds.length, totalScores: sortedTotals.reduce((acc, item) => acc + (Number(item.total) || 0), 0), winnerName: winner ? winner.name : 'Unknown', winnerScore: winner ? Number(winner.total) || 0 : 0 }; return { players, rounds, rules: gameValue?.rules || {}, totals: sortedTotals, winner, summary }; }; const getLeaderIdFromGame = (gameValue) => { const totals = Array.isArray(gameValue?.totals) ? gameValue.totals : []; if (!totals.length) return null; const sorted = [...totals].sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0)); return sorted[0]?.id || sorted[0]?.playerId || sorted[0]?.name || null; }; const getRoundTurnConfig = (gameValue) => { const rules = gameValue?.rules || {}; const turnDuration = Number(rules.turnDurationSeconds || rules.turnDuration || 0); const isTimedTurns = Boolean(rules.timedTurns) || turnDuration > 0; return { isTimedTurns, turnDuration: turnDuration > 0 ? turnDuration : 0, autoAdvance: rules.autoAdvanceTurns !== false }; }; const getElapsedSeconds = (startedAt, now = Date.now()) => { if (!startedAt) return 0; return Math.max(0, Math.floor((now - startedAt) / 1000)); }; const getTurnTiming = (gameValue) => { const { isTimedTurns, turnDuration, autoAdvance } = getRoundTurnConfig(gameValue); const rounds = Array.isArray(gameValue?.rounds) ? gameValue.rounds : []; const lastRound = rounds.length > 0 ? rounds[rounds.length - 1] : null; const startedAt = Number(lastRound?.startedAt || lastRound?.createdAt || gameValue?.currentTurnStartedAt || gameValue?.createdAt || Date.now()); const elapsed = getElapsedSeconds(startedAt); const remaining = isTimedTurns ? Math.max(0, turnDuration - elapsed) : null; const expired = isTimedTurns && remaining === 0; const activePlayerId = lastRound?.activePlayerId || gameValue?.currentTurnPlayerId || null; return { isTimedTurns, turnDuration, autoAdvance, startedAt, elapsed, remaining, expired, activePlayerId }; }; const analyzeHandicapSuggestion = async (gameData) => { const players = Array.isArray(gameData?.players) ? gameData.players : []; const rules = gameData?.rules || {}; const totals = Array.isArray(gameData?.totals) ? gameData.totals : []; const names = players.map(player => player?.name || player?.playerName || 'Player'); const strongestSignals = totals.length ? [...totals].sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0)).slice(0, 2).map(item => ({ name: item?.name || item?.playerName || 'Player', total: Number(item?.total) || 0 })) : []; const prompt = `Analyze this board game setup and suggest handicap adjustments only if the group appears unbalanced. Players: ${names.join(', ') || 'None'} Player count: ${players.length} Rules: ${JSON.stringify(rules)} Recent totals or skill signals: ${JSON.stringify(strongestSignals)} Return JSON with this shape: { "needsHandicap": boolean, "balanceLevel": "low" | "medium" | "high", "reason": string, "suggestions": [ { "playerName": string, "adjustmentType": "bonus_points" | "starting_points" | "extra_action" | "delayed_start" | "target_reduction" | "other", "value": number, "description": string } ], "applyToNewSession": boolean } Guidelines: - Keep suggestions small and practical. - If the group looks balanced, set needsHandicap to false and suggestions to []. - Prefer one recommendation per weaker player. - Do not invent complex rules; keep it simple.`; const response = await generateAI({ system: 'You are a helpful board game balance assistant that suggests fair, concise handicap adjustments for tabletop game sessions.', user: prompt, json: true }); return response; }; const buildGameWithHandicap = (gameData, suggestion) => { if (!suggestion?.needsHandicap || !Array.isArray(suggestion?.suggestions) || suggestion.suggestions.length === 0) { return gameData; } const players = Array.isArray(gameData?.players) ? gameData.players : []; const handicapMap = new Map(); suggestion.suggestions.forEach(item => { if (!item?.playerName) return; handicapMap.set(item.playerName, { adjustmentType: item.adjustmentType || 'other', value: Number(item.value) || 0, description: item.description || '' }); }); return { ...gameData, players: players.map(player => { const key = player?.name || player?.playerName; const handicap = handicapMap.get(key); return handicap ? { ...player, handicapAdjustment: handicap, handicapApplied: true } : player; }), rules: { ...(gameData.rules || {}), handicapSuggestionApplied: true, handicapSuggestionSummary: suggestion.reason || 'AI suggested handicap adjustments were applied.' } }; }; const upsertGameSession = async (gameId, gameValue) => { const completedAt = Date.now(); const summaryData = buildGameSummary(gameValue); const payload = { sourceGameId: gameId, gameName: gameValue?.name || 'Untitled Game', players: summaryData.players, rules: summaryData.rules, rounds: summaryData.rounds, totals: summaryData.totals, winner: summaryData.winner, summary: summaryData.summary, createdAt: gameValue?.createdAt || completedAt, endedAt: completedAt, updatedAt: completedAt, status: 'completed' }; const existingSession = completedGameSessions && completedGameSessions.find(session => session.value && session.value.sourceGameId === gameId); if (existingSession) { await updateGameSession(existingSession.id, { ...existingSession.value, ...payload }); } else { await addGameSession(payload); } }; const handleSaveIdentity = async (playerData) => { if (!playerData) return null; const nextIdentity = { id: playerData.id || (savedIdentity && savedIdentity.value && savedIdentity.value.id) || (identityUser && identityUser.id) || Date.now().toString(), name: playerData.name || (savedIdentity && savedIdentity.value && savedIdentity.value.name) || (identityUser && identityUser.name) || 'Player', avatar: playerData.avatar || (savedIdentity && savedIdentity.value && savedIdentity.value.avatar) || (identityUser && identityUser.avatar) || '', updatedAt: Date.now() }; if (savedIdentity) { await updateIdentity(savedIdentity.id, nextIdentity); } else { await addIdentity({ ...nextIdentity, createdAt: Date.now() }); } return nextIdentity; }; const advanceTimedTurn = async (gameId, gameValue, reason = 'timer') => { const currentRounds = Array.isArray(gameValue?.rounds) ? [...gameValue.rounds] : []; const players = Array.isArray(gameValue?.players) ? gameValue.players : []; const totals = Array.isArray(gameValue?.totals) ? [...gameValue.totals] : []; if (!players.length) return; const lastRound = currentRounds.length > 0 ? currentRounds[currentRounds.length - 1] : null; const nextPlayerIndex = Math.max(0, (players.findIndex(p => (p?.id || p?.playerId || p?.name) === (lastRound?.activePlayerId || gameValue?.currentTurnPlayerId)) + 1) % players.length); const nextActivePlayer = players[nextPlayerIndex] || players[0]; const now = Date.now(); const nextRoundNumber = currentRounds.length + 1; const nextRound = { round: nextRoundNumber, startedAt: now, createdAt: now, reason, activePlayerId: nextActivePlayer?.id || nextActivePlayer?.playerId || nextActivePlayer?.name || null, status: 'open', scores: [] }; const updatedGame = { ...gameValue, rounds: [...currentRounds, nextRound], totals: totals.length ? totals : gameValue?.totals || [], currentTurnStartedAt: now, currentTurnPlayerId: nextRound.activePlayerId, updatedAt: now }; await update(gameId, updatedGame); playSound('@success'); }; const handleStartGame = async (gameData) => { const existing = games.filter(g => g.value.status === 'active'); for (const g of existing) { await update(g.id, { ...g.value, status: 'completed', endedAt: Date.now() }); await upsertGameSession(g.id, { ...g.value, status: 'completed', endedAt: Date.now() }); } const identityToUse = playerIdentity || { id: identityUser ? identityUser.id : Date.now().toString(), name: identityUser ? identityUser.name : 'Player', avatar: identityUser ? identityUser.avatar : '' }; const now = Date.now(); const initialRules = gameData?.rules || {}; const timedTurnsEnabled = Boolean(initialRules.timedTurns) || Number(initialRules.turnDurationSeconds || initialRules.turnDuration || 0) > 0; const baseGameData = { ...gameData, rules: { ...initialRules, timedTurns: timedTurnsEnabled, turnDurationSeconds: Number(initialRules.turnDurationSeconds || initialRules.turnDuration || 0) || 0, autoAdvanceTurns: initialRules.autoAdvanceTurns !== false }, hostIdentity: identityToUse, playerIdentity: identityToUse, players: Array.isArray(gameData.players) ? gameData.players.map(player => { if (player && player.id && player.id === identityToUse.id) { return { ...player, ...identityToUse, returningPlayer: true }; } return player; }) : gameData.players, status: 'active', createdAt: now, currentTurnStartedAt: timedTurnsEnabled ? now : null, currentTurnPlayerId: Array.isArray(gameData.players) && gameData.players.length > 0 ? (gameData.players[0]?.id || gameData.players[0]?.playerId || gameData.players[0]?.name || null) : null, rounds: timedTurnsEnabled && Array.isArray(gameData.players) && gameData.players.length > 0 ? [{ round: 1, startedAt: now, createdAt: now, activePlayerId: (gameData.players[0]?.id || gameData.players[0]?.playerId || gameData.players[0]?.name || null), status: 'open', scores: [] }] : (Array.isArray(gameData.rounds) ? gameData.rounds : []) }; const finalGameData = applyHandicapToNewGame && handicapSuggestion ? buildGameWithHandicap(baseGameData, handicapSuggestion) : baseGameData; await add(finalGameData); await handleSaveIdentity(identityToUse); setTab('play'); setSetupGameData(null); }; const handlePrepareGame = async (gameData) => { setSetupGameData(gameData); setHandicapError(''); setHandicapSuggestion(null); try { setHandicapLoading(true); const suggestion = await analyzeHandicapSuggestion(gameData); setHandicapSuggestion(suggestion); } catch (error) { setHandicapError(error?.message || aiError || 'Unable to generate a handicap suggestion right now.'); setHandicapSuggestion(null); } finally { setHandicapLoading(false); } }; const handleEndGame = async (gameId) => { const target = games.find(g => g.id === gameId); if (!target) return; const endedAt = Date.now(); const completedGame = { ...target.value, status: 'completed', endedAt, updatedAt: endedAt }; await update(gameId, completedGame); await upsertGameSession(gameId, completedGame); }; const historySessions = (completedGameSessions || []) .filter(session => session && session.value && session.value.status === 'completed') .map(session => ({ id: session.id, value: session.value })); const activeTiming = useMemo(() => (activeGame ? getTurnTiming(activeGame.value) : null), [activeGame, games]); useEffect(() => { if (!activeGame) return; if (!activeTiming?.isTimedTurns || !activeTiming.expired || !activeTiming.autoAdvance) return; const existingTick = timerTickRef.current; if (existingTick === activeGame.id) return; timerTickRef.current = activeGame.id; advanceTimedTurn(activeGame.id, activeGame.value, 'timer'); }, [activeGame, activeTiming]); useEffect(() => { if (loading) return; const active = games.find(g => g.value && g.value.status === 'active'); if (!active) { lastLeaderIdRef.current = null; return; } const leaderId = getLeaderIdFromGame(active.value); const lastLeaderId = lastLeaderIdRef.current; if (leaderId && lastLeaderId && leaderId !== lastLeaderId) { playSound('@success'); } lastLeaderIdRef.current = leaderId; }, [games, loading]); useEffect(() => { const active = games.find(g => g.value && g.value.status === 'active'); const activeStatus = active ? 'active' : 'none'; if (lastGameStatusRef.current === 'active' && activeStatus === 'none') { playSound('@ding'); } lastGameStatusRef.current = activeStatus; }, [games]); if (loading || identityLoading || identitiesLoading || sessionsLoading) { return (
🎲

Loading your games...

); } return (
🎲

ScoreKeep

{tab === 'setup' && ( <> {handicapSuggestion?.needsHandicap && (

AI Handicap Suggestion

{handicapSuggestion.reason || 'The AI suggested small balance adjustments for this setup.'}

{Array.isArray(handicapSuggestion.suggestions) && handicapSuggestion.suggestions.map((item, index) => (
{item.playerName} {item.description || `${item.adjustmentType} ${item.value}`}
))}
)} )} {tab === 'play' && (
setTab('setup')} playerIdentity={playerIdentity} onSaveIdentity={handleSaveIdentity} turnTiming={activeTiming} onAdvanceTurn={async () => { if (!activeGame) return; await advanceTimedTurn(activeGame.id, activeGame.value, 'manual'); }} /> {activeGame && ( )}
)} {tab === 'history' && ( { await remove(id); }} onReplay={handleStartGame} /> )}
); } ReactDOM.createRoot(document.getElementById('root')).render();