feat(arcade): add yjs-demo collaborative game and Yjs persistence layer

- Add yjs-demo arcade game with collaborative state management
- Add Yjs persistence layer for real-time sync
- Update socket server with Yjs support
- Update Rithmomachia game component
- Add yjs type definitions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-10-29 09:45:40 -05:00
parent b0b0891c1a
commit d568955d6a
18 changed files with 1751 additions and 7 deletions

View File

@ -60,6 +60,7 @@
"emojibase-data": "^16.0.3",
"jose": "^6.1.0",
"js-yaml": "^4.1.0",
"lib0": "^0.2.114",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
"nanoid": "^5.1.6",
@ -72,6 +73,9 @@
"react-resizable-layout": "^0.7.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"y-protocols": "^1.0.6",
"y-websocket": "^3.0.0",
"yjs": "^13.6.27",
"zod": "^4.1.12"
},
"devDependencies": {
@ -87,6 +91,7 @@
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^8.0.0",
"drizzle-kit": "^0.31.5",

View File

@ -398,8 +398,8 @@
<!-- Import Typst.ts and SVG Processor -->
<script>
// Global variables
let typstRenderer = null;
let flashcardsTemplate = null;
const typstRenderer = null;
const flashcardsTemplate = null;
// Initialize everything - use web app's API instead of direct Typst
async function initialize() {

View File

@ -34,10 +34,36 @@ app.prepare().then(() => {
}
})
// Debug: Check upgrade handlers at each stage
console.log('📊 Stage 1 - After server creation:')
console.log(` Upgrade handlers: ${server.listeners('upgrade').length}`)
// Initialize Socket.IO
const { initializeSocketServer } = require('./dist/socket-server')
console.log('📊 Stage 2 - Before initializeSocketServer:')
console.log(` Upgrade handlers: ${server.listeners('upgrade').length}`)
initializeSocketServer(server)
console.log('📊 Stage 3 - After initializeSocketServer:')
const allHandlers = server.listeners('upgrade')
console.log(` Upgrade handlers: ${allHandlers.length}`)
allHandlers.forEach((handler, i) => {
console.log(` [${i}] ${handler.name || 'anonymous'} (length: ${handler.length} params)`)
})
// Log all upgrade requests to see handler execution order
const originalEmit = server.emit.bind(server)
server.emit = function (event, ...args) {
if (event === 'upgrade') {
const req = args[0]
console.log(`\n🔄 UPGRADE REQUEST: ${req.url}`)
console.log(` ${allHandlers.length} handlers will be called`)
}
return originalEmit(event, ...args)
}
server
.once('error', (err) => {
console.error(err)

View File

@ -489,6 +489,8 @@ function AnimatedPiece({
function BoardDisplay() {
const { state, makeMove, playerColor, isMyTurn } = useRithmomachia()
const [selectedSquare, setSelectedSquare] = useState<string | null>(null)
const [captureDialogOpen, setCaptureDialogOpen] = useState(false)
const [captureTarget, setCaptureTarget] = useState<{ from: string; to: string; pieceId: string } | null>(null)
const handleSquareClick = (square: string, piece: (typeof state.pieces)[string] | undefined) => {
if (!isMyTurn) return
@ -518,8 +520,23 @@ function BoardDisplay() {
(p) => p.square === selectedSquare && !p.captured
)
if (selectedPiece) {
// Simple move (no capture logic for now - just basic movement)
makeMove(selectedSquare, square, selectedPiece.id)
// If target square has an enemy piece, open capture dialog
if (piece && piece.color !== playerColor) {
setCaptureTarget({ from: selectedSquare, to: square, pieceId: selectedPiece.id })
setCaptureDialogOpen(true)
} else {
// Simple move (no capture)
makeMove(selectedSquare, square, selectedPiece.id)
setSelectedSquare(null)
}
}
}
const handleCaptureWithRelation = (relation: string) => {
if (captureTarget) {
makeMove(captureTarget.from, captureTarget.to, captureTarget.pieceId, relation)
setCaptureDialogOpen(false)
setCaptureTarget(null)
setSelectedSquare(null)
}
}
@ -528,7 +545,174 @@ function BoardDisplay() {
const activePieces = Object.values(state.pieces).filter((p) => !p.captured)
return (
<div
<>
{/* Capture relation dialog */}
{captureDialogOpen && (
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
})}
onClick={() => {
setCaptureDialogOpen(false)
setCaptureTarget(null)
}}
>
<div
className={css({
bg: 'white',
borderRadius: 'lg',
p: '6',
maxWidth: '500px',
boxShadow: '0 10px 40px rgba(0,0,0,0.3)',
})}
onClick={(e) => e.stopPropagation()}
>
<h2 className={css({ fontSize: 'xl', fontWeight: 'bold', mb: '4' })}>
Select Capture Relation
</h2>
<p className={css({ mb: '4', color: 'gray.600' })}>
Choose the mathematical relation for this capture:
</p>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2' })}>
<button
type="button"
onClick={() => handleCaptureWithRelation('EQUAL')}
className={css({
px: '4',
py: '3',
bg: 'purple.100',
borderRadius: 'md',
textAlign: 'left',
cursor: 'pointer',
_hover: { bg: 'purple.200' },
})}
>
<strong>Equality:</strong> Mover value = Target value
</button>
<button
type="button"
onClick={() => handleCaptureWithRelation('MULTIPLE')}
className={css({
px: '4',
py: '3',
bg: 'purple.100',
borderRadius: 'md',
textAlign: 'left',
cursor: 'pointer',
_hover: { bg: 'purple.200' },
})}
>
<strong>Multiple:</strong> Target is a multiple of Mover
</button>
<button
type="button"
onClick={() => handleCaptureWithRelation('DIVISOR')}
className={css({
px: '4',
py: '3',
bg: 'purple.100',
borderRadius: 'md',
textAlign: 'left',
cursor: 'pointer',
_hover: { bg: 'purple.200' },
})}
>
<strong>Divisor:</strong> Mover is a divisor of Target
</button>
<button
type="button"
onClick={() => handleCaptureWithRelation('SUM')}
className={css({
px: '4',
py: '3',
bg: 'blue.100',
borderRadius: 'md',
textAlign: 'left',
cursor: 'pointer',
_hover: { bg: 'blue.200' },
})}
>
<strong>Sum:</strong> Mover + Helper = Target (requires helper)
</button>
<button
type="button"
onClick={() => handleCaptureWithRelation('DIFF')}
className={css({
px: '4',
py: '3',
bg: 'blue.100',
borderRadius: 'md',
textAlign: 'left',
cursor: 'pointer',
_hover: { bg: 'blue.200' },
})}
>
<strong>Difference:</strong> |Mover - Helper| = Target (requires helper)
</button>
<button
type="button"
onClick={() => handleCaptureWithRelation('PRODUCT')}
className={css({
px: '4',
py: '3',
bg: 'blue.100',
borderRadius: 'md',
textAlign: 'left',
cursor: 'pointer',
_hover: { bg: 'blue.200' },
})}
>
<strong>Product:</strong> Mover × Helper = Target (requires helper)
</button>
<button
type="button"
onClick={() => handleCaptureWithRelation('RATIO')}
className={css({
px: '4',
py: '3',
bg: 'blue.100',
borderRadius: 'md',
textAlign: 'left',
cursor: 'pointer',
_hover: { bg: 'blue.200' },
})}
>
<strong>Ratio:</strong> Mover / Helper = Target / Helper (requires helper)
</button>
</div>
<button
type="button"
onClick={() => {
setCaptureDialogOpen(false)
setCaptureTarget(null)
}}
className={css({
mt: '4',
px: '4',
py: '2',
bg: 'gray.200',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'gray.300' },
width: '100%',
})}
>
Cancel
</button>
</div>
</div>
)}
<div
className={css({
position: 'relative',
width: '100%',
@ -600,6 +784,7 @@ function BoardDisplay() {
))}
</div>
</div>
</>
)
}

View File

@ -0,0 +1,301 @@
'use client'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type * as Y from 'yjs'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useArcadeSocket } from '@/hooks/useArcadeSocket'
import { useGameMode } from '@/contexts/GameModeContext'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GridCell, YjsDemoState } from './types'
interface YjsDemoContextValue {
state: YjsDemoState
yjsState: {
cells: Y.Array<GridCell> | null
awareness: any
}
addCell: (x: number, y: number) => void
startGame: () => void
endGame: () => void
goToSetup: () => void
exitSession: () => void
lastError: string | null
clearError: () => void
}
const YjsDemoContext = createContext<YjsDemoContextValue | null>(null)
export function useYjsDemo() {
const context = useContext(YjsDemoContext)
if (!context) {
throw new Error('useYjsDemo must be used within YjsDemoProvider')
}
return context
}
export function YjsDemoProvider({ children }: { children: React.ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds } = useGameMode()
const [forceUpdate, setForceUpdate] = useState(0)
// Initial state for arcade session
const initialState: YjsDemoState = {
gamePhase: 'setup',
gridSize: 8,
duration: 60,
activePlayers: [],
playerScores: {},
}
// Use arcade session for phase transitions
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<YjsDemoState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: (currentState) => currentState, // Server handles state
})
// Yjs setup - Socket.IO based sync
const docRef = useRef<Y.Doc | null>(null)
const awarenessRef = useRef<any>(null)
const cellsRef = useRef<Y.Array<GridCell> | null>(null)
// Get socket from arcade socket hook
const { socket } = useArcadeSocket()
useEffect(() => {
if (!roomData?.id || !socket) return
let doc: Y.Doc
let awareness: any
let cells: Y.Array<GridCell>
// Dynamic import to avoid loading Yjs in server bundle
const initYjs = async () => {
const Y = await import('yjs')
const awarenessProtocol = await import('y-protocols/awareness')
const syncProtocol = await import('y-protocols/sync')
const encoding = await import('lib0/encoding')
const decoding = await import('lib0/decoding')
doc = new Y.Doc()
docRef.current = doc
// Create awareness
awareness = new awarenessProtocol.Awareness(doc)
awarenessRef.current = awareness
cells = doc.getArray<GridCell>('cells')
cellsRef.current = cells
// Listen for changes in cells array to trigger re-renders
const observer = () => {
setForceUpdate((n) => n + 1)
}
cells.observe(observer)
// Set up Socket.IO handlers for Yjs sync
// Handle incoming sync/update messages from server
const handleYjsMessage = (data: number[]) => {
const message = new Uint8Array(data)
const decoder = decoding.createDecoder(message)
const messageType = decoding.readVarUint(decoder)
if (messageType === 0) {
// Sync protocol message (sync step or update)
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 0)
syncProtocol.readSyncMessage(decoder, encoder, doc, socket.id)
// Send response if there's content
if (encoding.length(encoder) > 1) {
socket.emit('yjs-update', Array.from(encoding.toUint8Array(encoder)))
}
}
}
// Handle incoming awareness updates
const handleYjsAwareness = (data: number[]) => {
const message = new Uint8Array(data)
const decoder = decoding.createDecoder(message)
const messageType = decoding.readVarUint(decoder)
if (messageType === 0) {
// Read the awareness update from the message
const awarenessUpdate = decoding.readVarUint8Array(decoder)
awarenessProtocol.applyAwarenessUpdate(awareness, awarenessUpdate, socket.id)
}
}
// Register Socket.IO event handlers
// Both sync and update events use the same handler since readSyncMessage handles both
socket.on('yjs-sync', handleYjsMessage)
socket.on('yjs-update', handleYjsMessage)
socket.on('yjs-awareness', handleYjsAwareness)
// Send updates to server when document changes
const updateHandler = (update: Uint8Array, origin: any) => {
// Don't send updates that came from the server
if (origin === socket.id) return
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 0) // Message type: sync
syncProtocol.writeUpdate(encoder, update)
const message = encoding.toUint8Array(encoder)
socket.emit('yjs-update', Array.from(message))
}
doc.on('update', updateHandler)
// Send awareness updates to server
const awarenessUpdateHandler = ({ added, updated, removed }: any) => {
const changedClients = added.concat(updated).concat(removed)
const update = awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
socket.emit('yjs-awareness', Array.from(update))
}
awareness.on('update', awarenessUpdateHandler)
// Set local awareness state
if (viewerId) {
awareness.setLocalStateField('user', {
id: viewerId,
timestamp: Date.now(),
})
}
// Join the Yjs room
console.log('[YjsDemo] Joining Yjs room:', roomData.id)
socket.emit('yjs-join', roomData.id)
// Cleanup function stored for later
return () => {
socket.off('yjs-sync', handleYjsMessage)
socket.off('yjs-update', handleYjsMessage)
socket.off('yjs-awareness', handleYjsAwareness)
doc.off('update', updateHandler)
awareness.off('update', awarenessUpdateHandler)
}
}
let cleanup: (() => void) | undefined
void initYjs().then((cleanupFn) => {
cleanup = cleanupFn
})
return () => {
if (cleanup) {
cleanup()
}
if (awarenessRef.current) {
awarenessRef.current.setLocalState(null)
awarenessRef.current.destroy()
}
if (docRef.current) {
docRef.current.destroy()
}
docRef.current = null
awarenessRef.current = null
cellsRef.current = null
}
}, [roomData?.id, viewerId, socket])
// Player colors
const playerColors = useMemo(() => {
const colors = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#FFA07A',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E2',
]
const playerList = Array.from(activePlayerIds)
const colorMap: Record<string, string> = {}
for (let i = 0; i < playerList.length; i++) {
colorMap[playerList[i]] = colors[i % colors.length]
}
return colorMap
}, [activePlayerIds])
// Actions
const addCell = useCallback(
(x: number, y: number) => {
if (!cellsRef.current || !viewerId || !docRef.current) return
if (state.gamePhase !== 'playing') return
const cell: GridCell = {
id: `${viewerId}-${Date.now()}`,
x,
y,
playerId: viewerId,
timestamp: Date.now(),
color: playerColors[viewerId] || '#999999',
}
docRef.current.transact(() => {
cellsRef.current?.push([cell])
})
// Update score in local state (this would be synced via Yjs in a real impl)
// For now, we're just showing the concept
},
[viewerId, state.gamePhase, playerColors]
)
const startGame = useCallback(() => {
const players = Array.from(activePlayerIds)
sendMove({
type: 'START_GAME',
playerId: players[0] || viewerId || '',
userId: viewerId || '',
data: { activePlayers: players },
})
}, [activePlayerIds, viewerId, sendMove])
const endGame = useCallback(() => {
sendMove({
type: 'END_GAME',
playerId: viewerId || '',
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const goToSetup = useCallback(() => {
sendMove({
type: 'GO_TO_SETUP',
playerId: viewerId || '',
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const yjsState = {
cells: cellsRef.current,
awareness: awarenessRef.current || null,
}
return (
<YjsDemoContext.Provider
value={{
state,
yjsState,
addCell,
startGame,
endGame,
goToSetup,
exitSession,
lastError,
clearError,
}}
>
{children}
</YjsDemoContext.Provider>
)
}

View File

@ -0,0 +1,85 @@
import type { GameValidator, ValidationResult } from '@/lib/arcade/validation/types'
import type { YjsDemoConfig, YjsDemoMove, YjsDemoState } from './types'
export class YjsDemoValidator implements GameValidator<YjsDemoState, YjsDemoMove> {
validateMove(state: YjsDemoState, move: YjsDemoMove): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers)
case 'END_GAME':
return this.validateEndGame(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
default:
return { valid: false, error: 'Unknown move type' }
}
}
private validateStartGame(state: YjsDemoState, activePlayers: string[]): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Game already started' }
}
if (activePlayers.length === 0) {
return { valid: false, error: 'No players selected' }
}
const playerScores: Record<string, number> = {}
for (const playerId of activePlayers) {
playerScores[playerId] = 0
}
const newState: YjsDemoState = {
...state,
gamePhase: 'playing',
activePlayers,
playerScores,
startTime: Date.now(),
}
return { valid: true, newState }
}
private validateEndGame(state: YjsDemoState): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game is not in progress' }
}
const newState: YjsDemoState = {
...state,
gamePhase: 'results',
endTime: Date.now(),
}
return { valid: true, newState }
}
private validateGoToSetup(state: YjsDemoState): ValidationResult {
const newState: YjsDemoState = {
...state,
gamePhase: 'setup',
activePlayers: [],
playerScores: {},
startTime: undefined,
endTime: undefined,
}
return { valid: true, newState }
}
isGameComplete(state: YjsDemoState): boolean {
return state.gamePhase === 'results'
}
getInitialState(config: YjsDemoConfig): YjsDemoState {
return {
gamePhase: 'setup',
gridSize: config.gridSize,
duration: config.duration,
activePlayers: [],
playerScores: {},
}
}
}
export const yjsDemoValidator = new YjsDemoValidator()

View File

@ -0,0 +1,215 @@
'use client'
import { useMemo } from 'react'
import { css } from '../../../../styled-system/css'
import { useYjsDemo } from '../Provider'
import { useViewerId } from '@/hooks/useViewerId'
export function PlayingPhase() {
const { state, yjsState, addCell, endGame } = useYjsDemo()
const { data: viewerId } = useViewerId()
const { gridSize } = state
// Convert Yjs array to regular array for rendering
const cells = useMemo(() => {
if (!yjsState.cells) return []
return yjsState.cells.toArray()
}, [yjsState.cells])
// Create a map of occupied cells
const occupiedCells = useMemo(() => {
const map = new Map<string, { color: string; playerId: string }>()
for (const cell of cells) {
const key = `${cell.x}-${cell.y}`
map.set(key, { color: cell.color, playerId: cell.playerId })
}
return map
}, [cells])
// Calculate scores
const scores = useMemo(() => {
const scoreMap: Record<string, number> = {}
for (const cell of cells) {
scoreMap[cell.playerId] = (scoreMap[cell.playerId] || 0) + 1
}
return scoreMap
}, [cells])
const handleCellClick = (x: number, y: number) => {
const key = `${x}-${y}`
if (occupiedCells.has(key)) return // Already occupied
addCell(x, y)
}
return (
<div className={containerStyle}>
<div className={headerStyle}>
<div className={titleStyle}>Click cells to claim them!</div>
<div className={statsStyle}>
Total cells claimed: {cells.length} / {gridSize * gridSize}
</div>
</div>
<div
className={gridStyle}
style={{
gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
gridTemplateRows: `repeat(${gridSize}, 1fr)`,
}}
>
{Array.from({ length: gridSize * gridSize }).map((_, index) => {
const x = Math.floor(index / gridSize)
const y = index % gridSize
const key = `${x}-${y}`
const cellData = occupiedCells.get(key)
const isOwn = cellData?.playerId === viewerId
return (
<button
key={key}
type="button"
onClick={() => handleCellClick(x, y)}
disabled={!!cellData}
className={cellStyle}
style={{
backgroundColor: cellData ? cellData.color : '#f0f0f0',
cursor: cellData ? 'default' : 'pointer',
border: isOwn ? '3px solid #333' : '1px solid #ccc',
}}
title={cellData ? `Claimed by ${cellData.playerId}` : 'Click to claim'}
/>
)
})}
</div>
<div className={scoresContainerStyle}>
<div className={scoresTitleStyle}>Current Scores:</div>
<div className={scoresListStyle}>
{Object.entries(scores)
.sort(([, a], [, b]) => b - a)
.map(([playerId, score]) => (
<div key={playerId} className={scoreItemStyle}>
<span className={scorePlayerStyle}>
{playerId === viewerId ? 'You' : playerId.slice(0, 8)}
</span>
<span className={scoreValueStyle}>{score} cells</span>
</div>
))}
</div>
</div>
<button type="button" onClick={endGame} className={endButtonStyle}>
End Game
</button>
</div>
)
}
const containerStyle = css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: { base: '12px', md: '24px' },
gap: '20px',
minHeight: '70vh',
})
const headerStyle = css({
textAlign: 'center',
})
const titleStyle = css({
fontSize: { base: '20px', md: '24px' },
fontWeight: 'bold',
color: 'blue.600',
marginBottom: '8px',
})
const statsStyle = css({
fontSize: { base: '14px', md: '16px' },
color: 'gray.600',
})
const gridStyle = css({
display: 'grid',
gap: '4px',
padding: '12px',
backgroundColor: 'gray.100',
borderRadius: '8px',
maxWidth: { base: '320px', sm: '400px', md: '500px' },
width: '100%',
aspectRatio: '1',
})
const cellStyle = css({
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
transition: 'all 0.2s',
'&:not(:disabled):hover': {
transform: 'scale(1.1)',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
},
'&:disabled': {
cursor: 'default',
},
})
const scoresContainerStyle = css({
backgroundColor: 'white',
borderRadius: '8px',
padding: '16px',
border: '1px solid',
borderColor: 'gray.200',
minWidth: '250px',
})
const scoresTitleStyle = css({
fontSize: '16px',
fontWeight: 'bold',
color: 'gray.700',
marginBottom: '12px',
})
const scoresListStyle = css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})
const scoreItemStyle = css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
backgroundColor: 'gray.50',
borderRadius: '4px',
})
const scorePlayerStyle = css({
fontSize: '14px',
fontWeight: '500',
color: 'gray.700',
})
const scoreValueStyle = css({
fontSize: '14px',
fontWeight: 'bold',
color: 'blue.600',
})
const endButtonStyle = css({
padding: '12px 24px',
fontSize: '16px',
fontWeight: 'bold',
backgroundColor: 'red.500',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
backgroundColor: 'red.600',
transform: 'scale(1.05)',
},
})

View File

@ -0,0 +1,225 @@
'use client'
import { useMemo } from 'react'
import { css } from '../../../../styled-system/css'
import { useYjsDemo } from '../Provider'
import { useViewerId } from '@/hooks/useViewerId'
export function ResultsPhase() {
const { state, yjsState, goToSetup } = useYjsDemo()
const { data: viewerId } = useViewerId()
// Convert Yjs array to regular array for rendering
const cells = useMemo(() => {
if (!yjsState.cells) return []
return yjsState.cells.toArray()
}, [yjsState.cells])
// Calculate final scores
const scores = useMemo(() => {
const scoreMap: Record<string, number> = {}
for (const cell of cells) {
scoreMap[cell.playerId] = (scoreMap[cell.playerId] || 0) + 1
}
return scoreMap
}, [cells])
const sortedScores = useMemo(() => {
return Object.entries(scores)
.sort(([, a], [, b]) => b - a)
.map(([playerId, score]) => ({ playerId, score }))
}, [scores])
const winner = sortedScores[0]
return (
<div className={containerStyle}>
<div className={titleStyle}>🎉 Game Complete! 🎉</div>
{winner && (
<div className={winnerBoxStyle}>
<div className={winnerTitleStyle}>Winner!</div>
<div className={winnerNameStyle}>
{winner.playerId === viewerId ? 'You' : winner.playerId}
</div>
<div className={winnerScoreStyle}>{winner.score} cells claimed</div>
</div>
)}
<div className={scoresContainerStyle}>
<div className={scoresTitleStyle}>Final Scores:</div>
<div className={scoresListStyle}>
{sortedScores.map(({ playerId, score }, index) => (
<div key={playerId} className={scoreItemStyle}>
<span className={scoreRankStyle}>#{index + 1}</span>
<span className={scorePlayerStyle}>
{playerId === viewerId ? 'You' : playerId.slice(0, 8)}
</span>
<span className={scoreValueStyle}>{score} cells</span>
</div>
))}
</div>
</div>
<div className={statsBoxStyle}>
<div className={statItemStyle}>
<span className={statLabelStyle}>Total cells claimed:</span>
<span className={statValueStyle}>{cells.length}</span>
</div>
<div className={statItemStyle}>
<span className={statLabelStyle}>Grid size:</span>
<span className={statValueStyle}>
{state.gridSize} × {state.gridSize}
</span>
</div>
</div>
<button type="button" onClick={goToSetup} className={playAgainButtonStyle}>
Play Again
</button>
</div>
)
}
const containerStyle = css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: { base: '20px', md: '40px' },
gap: '24px',
minHeight: '70vh',
})
const titleStyle = css({
fontSize: { base: '28px', md: '36px' },
fontWeight: 'bold',
color: 'blue.600',
textAlign: 'center',
})
const winnerBoxStyle = css({
backgroundColor: 'yellow.100',
borderRadius: '16px',
padding: '24px',
border: '3px solid',
borderColor: 'yellow.400',
textAlign: 'center',
minWidth: '250px',
})
const winnerTitleStyle = css({
fontSize: '20px',
fontWeight: 'bold',
color: 'yellow.700',
marginBottom: '8px',
})
const winnerNameStyle = css({
fontSize: '32px',
fontWeight: 'bold',
color: 'yellow.800',
marginBottom: '4px',
})
const winnerScoreStyle = css({
fontSize: '18px',
color: 'yellow.700',
})
const scoresContainerStyle = css({
backgroundColor: 'white',
borderRadius: '12px',
padding: '20px',
border: '1px solid',
borderColor: 'gray.200',
minWidth: '300px',
})
const scoresTitleStyle = css({
fontSize: '18px',
fontWeight: 'bold',
color: 'gray.700',
marginBottom: '16px',
})
const scoresListStyle = css({
display: 'flex',
flexDirection: 'column',
gap: '12px',
})
const scoreItemStyle = css({
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px',
backgroundColor: 'gray.50',
borderRadius: '8px',
})
const scoreRankStyle = css({
fontSize: '16px',
fontWeight: 'bold',
color: 'gray.500',
minWidth: '32px',
})
const scorePlayerStyle = css({
flex: 1,
fontSize: '16px',
fontWeight: '500',
color: 'gray.700',
})
const scoreValueStyle = css({
fontSize: '16px',
fontWeight: 'bold',
color: 'blue.600',
})
const statsBoxStyle = css({
backgroundColor: 'blue.50',
borderRadius: '8px',
padding: '16px',
border: '1px solid',
borderColor: 'blue.200',
minWidth: '250px',
})
const statItemStyle = css({
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid',
borderColor: 'blue.100',
'&:last-child': {
borderBottom: 'none',
},
})
const statLabelStyle = css({
fontSize: '14px',
color: 'gray.600',
})
const statValueStyle = css({
fontSize: '14px',
fontWeight: 'bold',
color: 'blue.700',
})
const playAgainButtonStyle = css({
padding: '16px 32px',
fontSize: '18px',
fontWeight: 'bold',
backgroundColor: 'blue.500',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
backgroundColor: 'blue.600',
transform: 'scale(1.05)',
},
})

View File

@ -0,0 +1,102 @@
'use client'
import { useGameMode } from '@/contexts/GameModeContext'
import { css } from '../../../../styled-system/css'
import { useYjsDemo } from '../Provider'
export function SetupPhase() {
const { activePlayers } = useGameMode()
const { startGame } = useYjsDemo()
const canStart = activePlayers.size >= 1
return (
<div className={containerStyle}>
<div className={titleStyle}>Collaborative Grid Demo</div>
<div className={descriptionStyle}>
Click on the grid to add colored cells. See other players&apos; clicks in real-time using
Yjs!
</div>
<div className={infoBoxStyle}>
<div className={infoTitleStyle}>How it works:</div>
<ul className={listStyle}>
<li>Each player gets a unique color</li>
<li>Click cells to claim them</li>
<li>State is synchronized with Yjs CRDTs</li>
<li>No traditional server validation - Yjs handles conflicts</li>
<li>All players see updates in real-time</li>
</ul>
</div>
<button
type="button"
onClick={startGame}
disabled={!canStart}
className={css({
padding: '16px 32px',
fontSize: '18px',
fontWeight: 'bold',
backgroundColor: canStart ? 'blue.500' : 'gray.300',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: canStart ? 'pointer' : 'not-allowed',
transition: 'all 0.2s',
_hover: canStart ? { backgroundColor: 'blue.600', transform: 'scale(1.05)' } : {},
})}
>
{canStart ? 'Start Demo' : 'Select at least 1 player'}
</button>
</div>
)
}
const containerStyle = css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: { base: '20px', md: '40px' },
gap: '24px',
minHeight: '60vh',
})
const titleStyle = css({
fontSize: { base: '28px', md: '36px' },
fontWeight: 'bold',
color: 'blue.600',
textAlign: 'center',
})
const descriptionStyle = css({
fontSize: { base: '16px', md: '18px' },
color: 'gray.700',
textAlign: 'center',
maxWidth: '600px',
})
const infoBoxStyle = css({
backgroundColor: 'blue.50',
borderRadius: '12px',
padding: '20px',
maxWidth: '500px',
border: '2px solid',
borderColor: 'blue.200',
})
const infoTitleStyle = css({
fontSize: '18px',
fontWeight: 'bold',
color: 'blue.700',
marginBottom: '12px',
})
const listStyle = css({
fontSize: '14px',
color: 'gray.700',
paddingLeft: '20px',
'& li': {
marginBottom: '8px',
},
})

View File

@ -0,0 +1,31 @@
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useYjsDemo } from '../Provider'
import { SetupPhase } from './SetupPhase'
import { PlayingPhase } from './PlayingPhase'
import { ResultsPhase } from './ResultsPhase'
export function YjsDemoGame() {
const router = useRouter()
const { state, exitSession, goToSetup } = useYjsDemo()
return (
<PageWithNav
navTitle="Yjs Demo"
navEmoji="🔄"
emphasizePlayerSelection={state.gamePhase === 'setup'}
playerScores={state.playerScores}
onExitSession={() => {
exitSession()
router.push('/arcade')
}}
onSetup={state.gamePhase !== 'setup' ? () => goToSetup() : undefined}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</PageWithNav>
)
}

View File

@ -0,0 +1,4 @@
export { YjsDemoGame } from './YjsDemoGame'
export { SetupPhase } from './SetupPhase'
export { PlayingPhase } from './PlayingPhase'
export { ResultsPhase } from './ResultsPhase'

View File

@ -0,0 +1,64 @@
/**
* Yjs Demo Game Definition
*
* A demonstration of real-time multiplayer synchronization using Yjs CRDTs.
* Players collaborate on a shared grid, with state synchronized via Yjs WebSockets.
*/
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { YjsDemoGame } from './components/YjsDemoGame'
import { YjsDemoProvider } from './Provider'
import type { YjsDemoConfig, YjsDemoMove, YjsDemoState } from './types'
import { yjsDemoValidator } from './Validator'
const manifest: GameManifest = {
name: 'yjs-demo',
displayName: 'Yjs Sync Demo',
icon: '🔄',
description: 'Real-time collaboration demo with Yjs',
longDescription:
'Experience the power of Yjs CRDTs in action! This demo shows how multiple players can interact with a shared grid in real-time. ' +
'Click on cells to claim them, and watch as other players do the same. Yjs handles all the conflict resolution automatically, ' +
'ensuring everyone sees a consistent view of the game state without traditional server validation.',
maxPlayers: 8,
difficulty: 'Beginner',
chips: ['🤝 Collaborative', '⚡ Real-time', '🔬 Demo'],
...getGameTheme('teal'),
available: true,
}
const defaultConfig: YjsDemoConfig = {
gridSize: 8,
duration: 60,
}
// Config validation function
function validateYjsDemoConfig(config: unknown): config is YjsDemoConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as any
// Validate gridSize
if (!('gridSize' in c) || ![8, 12, 16].includes(c.gridSize)) {
return false
}
// Validate duration
if (!('duration' in c) || ![60, 120, 180].includes(c.duration)) {
return false
}
return true
}
export const yjsDemoGame = defineGame<YjsDemoConfig, YjsDemoState, YjsDemoMove>({
manifest,
Provider: YjsDemoProvider,
GameComponent: YjsDemoGame,
validator: yjsDemoValidator,
defaultConfig,
validateConfig: validateYjsDemoConfig,
})

View File

@ -0,0 +1,54 @@
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
export interface YjsDemoConfig extends GameConfig {
gridSize: 8 | 12 | 16
duration: 60 | 120 | 180
[key: string]: unknown
}
export interface YjsDemoState extends GameState {
gamePhase: 'setup' | 'playing' | 'results'
gridSize: number
duration: number
startTime?: number
endTime?: number
activePlayers: string[]
playerScores: Record<string, number>
// Cells array for persistence (synced from Y.Doc)
cells?: GridCell[]
}
// For Yjs synchronization
export interface GridCell {
id: string
x: number
y: number
playerId: string
timestamp: number
color: string
}
// Moves are not used in Yjs demo (everything goes through Y.Doc)
// but we need this for arcade compatibility
export type YjsDemoMove =
| {
type: 'START_GAME'
playerId: string
userId: string
timestamp: number
data: { activePlayers: string[] }
}
| {
type: 'END_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'GO_TO_SETUP'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}

View File

@ -0,0 +1,67 @@
/**
* Yjs persistence helpers
* Sync Y.Doc state with arcade sessions database
*/
import type * as Y from 'yjs'
import type { GridCell } from '@/arcade-games/yjs-demo/types'
/**
* Extract grid cells from a Y.Doc for persistence
* @param doc - The Yjs document
* @param arrayName - Name of the Y.Array containing cells (default: 'cells')
* @returns Array of grid cells
*/
export function extractCellsFromDoc(doc: any, arrayName = 'cells'): GridCell[] {
const cellsArray = doc.getArray(arrayName)
if (!cellsArray) return []
const cells: GridCell[] = []
cellsArray.forEach((cell: GridCell) => {
cells.push(cell)
})
return cells
}
/**
* Populate a Y.Doc with cells from persisted state
* @param doc - The Yjs document
* @param cells - Array of grid cells to restore
* @param arrayName - Name of the Y.Array to populate (default: 'cells')
*/
export function populateDocWithCells(doc: any, cells: GridCell[], arrayName = 'cells'): void {
const cellsArray = doc.getArray(arrayName)
// Clear existing cells first
cellsArray.delete(0, cellsArray.length)
// Add persisted cells
if (cells.length > 0) {
doc.transact(() => {
cellsArray.push(cells)
})
}
}
/**
* Serialize Y.Doc to a compact format for database storage
* Uses Yjs's built-in state vector encoding
* @param doc - The Yjs document
* @returns Base64-encoded document state
*/
export function serializeDoc(doc: any): string {
const Y = require('yjs')
const state = Y.encodeStateAsUpdate(doc)
return Buffer.from(state).toString('base64')
}
/**
* Restore Y.Doc from serialized state
* @param doc - The Yjs document to populate
* @param serialized - Base64-encoded document state
*/
export function deserializeDoc(doc: any, serialized: string): void {
const Y = require('yjs')
const state = Buffer.from(serialized, 'base64')
Y.applyUpdate(doc, state)
}

View File

@ -1,6 +1,7 @@
import type { Server as HTTPServer } from 'http'
import type { Server as HTTPServer, IncomingMessage } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Server as SocketIOServerType } from 'socket.io'
import { WebSocketServer, type WebSocket } from 'ws'
import {
applyGameMove,
createArcadeSession,
@ -17,10 +18,18 @@ import { getValidator, type GameName } from './lib/arcade/validators'
import type { GameMove } from './lib/arcade/validation/types'
import { getGameConfig } from './lib/arcade/game-config-helpers'
// Yjs server-side imports
import * as Y from 'yjs'
import * as awarenessProtocol from 'y-protocols/awareness'
import * as syncProtocol from 'y-protocols/sync'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
// Use globalThis to store socket.io instance to avoid module isolation issues
// This ensures the same instance is accessible across dynamic imports
declare global {
var __socketIO: SocketIOServerType | undefined
var __yjsRooms: Map<string, any> | undefined // Map<roomId, RoomState>
}
/**
@ -31,6 +40,283 @@ export function getSocketIO(): SocketIOServerType | null {
return globalThis.__socketIO || null
}
/**
* Initialize Yjs WebSocket server for real-time collaboration
* Server-authoritative approach - maintains Y.Doc per arcade room, handles sync protocol
*
* IMPORTANT: Yjs rooms map 1:1 with arcade rooms (roomId === arcade room ID)
*/
function initializeYjsServer(io: SocketIOServerType) {
// Room state storage (keyed by arcade room ID)
interface RoomState {
doc: Y.Doc
awareness: awarenessProtocol.Awareness
connections: Set<string> // Socket IDs
}
const rooms = new Map<string, RoomState>() // Map<arcadeRoomId, RoomState>
const socketToRoom = new Map<string, string>() // Map<socketId, roomId>
// Store rooms globally for persistence access
globalThis.__yjsRooms = rooms
function getOrCreateRoom(roomName: string): RoomState {
if (!rooms.has(roomName)) {
const doc = new Y.Doc()
const awareness = new awarenessProtocol.Awareness(doc)
// Broadcast document updates to all clients via Socket.IO
doc.on('update', (update: Uint8Array, origin: any) => {
// Origin is the socket ID that sent the update, don't echo back to sender
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 0) // messageSync
syncProtocol.writeUpdate(encoder, update)
const message = encoding.toUint8Array(encoder)
// Broadcast to all sockets in this room except origin
io.to(`yjs:${roomName}`)
.except(origin as string)
.emit('yjs-update', Array.from(message))
})
// Broadcast awareness updates to all clients via Socket.IO
awareness.on('update', ({ added, updated, removed }: any, origin: any) => {
const changedClients = added.concat(updated).concat(removed)
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 1) // messageAwareness
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
)
const message = encoding.toUint8Array(encoder)
// Broadcast to all sockets in this room except origin
io.to(`yjs:${roomName}`)
.except(origin as string)
.emit('yjs-awareness', Array.from(message))
})
const roomState: RoomState = {
doc,
awareness,
connections: new Set(),
}
rooms.set(roomName, roomState)
console.log(`✅ Created Y.Doc for room: ${roomName}`)
// Load persisted state asynchronously (don't block connection)
void loadPersistedYjsState(roomName).catch((err) => {
console.error(`Failed to load persisted state for room ${roomName}:`, err)
})
}
return rooms.get(roomName)!
}
// Handle Yjs connections via Socket.IO
io.on('connection', (socket) => {
// Join Yjs room
socket.on('yjs-join', async (roomId: string) => {
const room = getOrCreateRoom(roomId)
// Join Socket.IO room
await socket.join(`yjs:${roomId}`)
room.connections.add(socket.id)
socketToRoom.set(socket.id, roomId)
console.log(`🔗 Client connected to Yjs room: ${roomId} (${room.connections.size} clients)`)
// Send initial sync (SyncStep1)
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 0) // messageSync
syncProtocol.writeSyncStep1(encoder, room.doc)
socket.emit('yjs-sync', Array.from(encoding.toUint8Array(encoder)))
// Send current awareness state
const awarenessStates = room.awareness.getStates()
if (awarenessStates.size > 0) {
const awarenessEncoder = encoding.createEncoder()
encoding.writeVarUint(awarenessEncoder, 1) // messageAwareness
encoding.writeVarUint8Array(
awarenessEncoder,
awarenessProtocol.encodeAwarenessUpdate(
room.awareness,
Array.from(awarenessStates.keys())
)
)
socket.emit('yjs-awareness', Array.from(encoding.toUint8Array(awarenessEncoder)))
}
})
// Handle Yjs sync messages
socket.on('yjs-update', (data: number[]) => {
const roomId = socketToRoom.get(socket.id)
if (!roomId) return
const room = rooms.get(roomId)
if (!room) return
const uint8Data = new Uint8Array(data)
const decoder = decoding.createDecoder(uint8Data)
const messageType = decoding.readVarUint(decoder)
if (messageType === 0) {
// Sync protocol
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 0)
syncProtocol.readSyncMessage(decoder, encoder, room.doc, socket.id)
// Send response if there's content
if (encoding.length(encoder) > 1) {
socket.emit('yjs-sync', Array.from(encoding.toUint8Array(encoder)))
}
}
})
// Handle awareness updates
socket.on('yjs-awareness', (data: number[]) => {
const roomId = socketToRoom.get(socket.id)
if (!roomId) return
const room = rooms.get(roomId)
if (!room) return
const uint8Data = new Uint8Array(data)
const decoder = decoding.createDecoder(uint8Data)
const messageType = decoding.readVarUint(decoder)
if (messageType === 1) {
awarenessProtocol.applyAwarenessUpdate(
room.awareness,
decoding.readVarUint8Array(decoder),
socket.id
)
}
})
// Cleanup on disconnect
socket.on('disconnect', () => {
const roomId = socketToRoom.get(socket.id)
if (roomId) {
const room = rooms.get(roomId)
if (room) {
room.connections.delete(socket.id)
console.log(
`🔌 Client disconnected from Yjs room: ${roomId} (${room.connections.size} remain)`
)
// Clean up empty rooms after grace period
if (room.connections.size === 0) {
setTimeout(() => {
if (room.connections.size === 0) {
room.awareness.destroy()
room.doc.destroy()
rooms.delete(roomId)
console.log(`🗑️ Cleaned up room: ${roomId}`)
}
}, 30000)
}
}
socketToRoom.delete(socket.id)
}
})
})
console.log('✅ Yjs over Socket.IO initialized')
// Periodic persistence: sync Y.Doc state to arcade_sessions every 30 seconds
setInterval(async () => {
await persistAllYjsRooms()
}, 30000)
}
/**
* Get Y.Doc for a specific room (for persistence)
* Returns null if room doesn't exist
*/
export function getYjsDoc(roomId: string): Y.Doc | null {
const rooms = globalThis.__yjsRooms
if (!rooms) return null
const room = rooms.get(roomId)
return room ? room.doc : null
}
/**
* Load persisted cells into a Y.Doc
* Should be called when creating a new room that has persisted state
*/
export async function loadPersistedYjsState(roomId: string): Promise<void> {
const { extractCellsFromDoc, populateDocWithCells } = await import('./lib/arcade/yjs-persistence')
const doc = getYjsDoc(roomId)
if (!doc) return
// Get the arcade session for this room
const session = await getArcadeSessionByRoom(roomId)
if (!session) return
const gameState = session.gameState as any
if (gameState.cells && Array.isArray(gameState.cells)) {
console.log(`📥 Loading ${gameState.cells.length} persisted cells for room: ${roomId}`)
populateDocWithCells(doc, gameState.cells)
}
}
/**
* Persist Y.Doc cells for a specific room to arcade_sessions
*/
export async function persistYjsRoom(roomId: string): Promise<void> {
const { extractCellsFromDoc } = await import('./lib/arcade/yjs-persistence')
const { db, schema } = await import('@/db')
const { eq } = await import('drizzle-orm')
const doc = getYjsDoc(roomId)
if (!doc) return
const session = await getArcadeSessionByRoom(roomId)
if (!session) return
// Extract cells from Y.Doc
const cells = extractCellsFromDoc(doc, 'cells')
// Update the gameState with current cells
const currentState = session.gameState as Record<string, any>
const updatedGameState = {
...currentState,
cells,
}
// Save to database
try {
await db
.update(schema.arcadeSessions)
.set({
gameState: updatedGameState as any,
lastActivityAt: new Date(),
})
.where(eq(schema.arcadeSessions.roomId, roomId))
} catch (error) {
console.error(`Error persisting Yjs room ${roomId}:`, error)
}
}
/**
* Persist all active Yjs rooms
*/
export async function persistAllYjsRooms(): Promise<void> {
const rooms = globalThis.__yjsRooms
if (!rooms || rooms.size === 0) return
const roomIds = Array.from(rooms.keys())
for (const roomId of roomIds) {
// Only persist rooms with active connections
const room = rooms.get(roomId)
if (room && room.connections.size > 0) {
await persistYjsRoom(roomId)
}
}
}
export function initializeSocketServer(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
path: '/api/socket',
@ -40,6 +326,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
},
})
// Initialize Yjs server over Socket.IO
initializeYjsServer(io)
io.on('connection', (socket) => {
let currentUserId: string | null = null

24
apps/web/src/types/yjs-cjs.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
/**
* Type declarations for Yjs CJS builds
* These packages provide ESM types but we need to import CJS builds for Node.js server compatibility
*/
declare module 'y-protocols/dist/sync.cjs' {
import type * as syncProtocol from 'y-protocols/sync'
export = syncProtocol
}
declare module 'y-protocols/dist/awareness.cjs' {
import type * as awarenessProtocol from 'y-protocols/awareness'
export = awarenessProtocol
}
declare module 'lib0/dist/encoding.cjs' {
import type * as encoding from 'lib0/encoding'
export = encoding
}
declare module 'lib0/dist/decoding.cjs' {
import type * as decoding from 'lib0/decoding'
export = decoding
}

View File

@ -110,7 +110,7 @@
</div>
<script>
let testStep = 0;
const testStep = 0;
function log(message) {
const logElement = document.getElementById("log");

View File

@ -155,6 +155,9 @@ importers:
js-yaml:
specifier: ^4.1.0
version: 4.1.0
lib0:
specifier: ^0.2.114
version: 0.2.114
lucide-react:
specifier: ^0.294.0
version: 0.294.0(react@18.3.1)
@ -191,6 +194,15 @@ importers:
socket.io-client:
specifier: ^4.8.1
version: 4.8.1
y-protocols:
specifier: ^1.0.6
version: 1.0.6(yjs@13.6.27)
y-websocket:
specifier: ^3.0.0
version: 3.0.0(yjs@13.6.27)
yjs:
specifier: ^13.6.27
version: 13.6.27
zod:
specifier: ^4.1.12
version: 4.1.12
@ -231,6 +243,9 @@ importers:
'@types/react-dom':
specifier: ^18.2.0
version: 18.3.7(@types/react@18.3.26)
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitejs/plugin-react':
specifier: ^5.0.2
version: 5.0.4(vite@5.4.20(@types/node@20.19.19)(terser@5.44.0))
@ -3735,6 +3750,9 @@ packages:
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@ -6353,6 +6371,9 @@ packages:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
issue-parser@6.0.0:
resolution: {integrity: sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==}
engines: {node: '>=10.13'}
@ -6576,6 +6597,11 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lib0@0.2.114:
resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==}
engines: {node: '>=16'}
hasBin: true
lil-fp@1.4.5:
resolution: {integrity: sha512-RrMQ2dB7SDXriFPZMMHEmroaSP6lFw3QEV7FOfSkf19kvJnDzHqKMc2P9HOf5uE8fOp5YxodSrq7XxWjdeC2sw==}
@ -9307,6 +9333,18 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y-protocols@1.0.6:
resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
yjs: ^13.0.0
y-websocket@3.0.0:
resolution: {integrity: sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
yjs: ^13.5.6
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -9337,6 +9375,10 @@ packages:
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yjs@13.6.27:
resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -13213,6 +13255,10 @@ snapshots:
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.19
'@types/yargs-parser@21.0.3': {}
'@types/yargs@16.0.9':
@ -16249,6 +16295,8 @@ snapshots:
isobject@3.0.1: {}
isomorphic.js@0.2.5: {}
issue-parser@6.0.0:
dependencies:
lodash.capitalize: 4.2.1
@ -16557,6 +16605,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lib0@0.2.114:
dependencies:
isomorphic.js: 0.2.5
lil-fp@1.4.5: {}
lilconfig@3.1.3: {}
@ -19330,6 +19382,17 @@ snapshots:
xtend@4.0.2: {}
y-protocols@1.0.6(yjs@13.6.27):
dependencies:
lib0: 0.2.114
yjs: 13.6.27
y-websocket@3.0.0(yjs@13.6.27):
dependencies:
lib0: 0.2.114
y-protocols: 1.0.6(yjs@13.6.27)
yjs: 13.6.27
y18n@5.0.8: {}
yallist@3.1.1: {}
@ -19357,6 +19420,10 @@ snapshots:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yjs@13.6.27:
dependencies:
lib0: 0.2.114
yocto-queue@0.1.0: {}
yocto-queue@1.2.1: {}