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:
parent
b0b0891c1a
commit
d568955d6a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)',
|
||||
},
|
||||
})
|
||||
|
|
@ -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)',
|
||||
},
|
||||
})
|
||||
|
|
@ -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' 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',
|
||||
},
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { YjsDemoGame } from './YjsDemoGame'
|
||||
export { SetupPhase } from './SetupPhase'
|
||||
export { PlayingPhase } from './PlayingPhase'
|
||||
export { ResultsPhase } from './ResultsPhase'
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
let testStep = 0;
|
||||
const testStep = 0;
|
||||
|
||||
function log(message) {
|
||||
const logElement = document.getElementById("log");
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue