Init multiplayer
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 28s

This commit is contained in:
Johnny322
2026-02-24 20:54:14 +01:00
parent 6dde7eedb6
commit 9945f8163e
14 changed files with 2027 additions and 53 deletions

205
server/realtime-server.ts Normal file
View File

@@ -0,0 +1,205 @@
import http from 'http'
import crypto from 'crypto'
import { URL } from 'url'
import { WebSocketServer, type WebSocket } from 'ws'
type SessionState = Record<string, unknown>
type Session = {
id: string
createdAt: number
updatedAt: number
state: SessionState | null
}
type ClientInfo = {
gameId: string
socket: WebSocket
}
const port = Number(process.env.REALTIME_PORT || 8787)
const sessionTtlMs = 1000 * 60 * 60 * 6
const sessions = new Map<string, Session>()
const clients = new Map<string, ClientInfo>()
const json = (res: http.ServerResponse, statusCode: number, body: unknown) => {
res.writeHead(statusCode, {
'content-type': 'application/json',
'cache-control': 'no-store'
})
res.end(JSON.stringify(body))
}
const createGameId = () => crypto.randomBytes(4).toString('hex').toUpperCase()
const normalizeGameId = (value: string) => value.toUpperCase().replace(/[^A-Z0-9]/g, '')
const ensureSession = (gameId: string) => {
const now = Date.now()
const existing = sessions.get(gameId)
if (existing) return existing
const session: Session = {
id: gameId,
createdAt: now,
updatedAt: now,
state: null
}
sessions.set(gameId, session)
return session
}
const broadcast = (gameId: string, payload: unknown, exceptSocket?: WebSocket) => {
const message = JSON.stringify(payload)
clients.forEach((client) => {
if (client.gameId !== gameId) return
if (client.socket.readyState !== client.socket.OPEN) return
if (exceptSocket && client.socket === exceptSocket) return
client.socket.send(message)
})
}
const server = http.createServer((req, res) => {
if (!req.url) {
json(res, 400, { error: 'Missing URL' })
return
}
const requestUrl = new URL(req.url, `http://localhost:${port}`)
const pathname = requestUrl.pathname
if (req.method === 'GET' && pathname === '/health') {
json(res, 200, { ok: true })
return
}
if (req.method === 'POST' && pathname === '/sessions') {
let body = ''
req.on('data', (chunk) => {
body += chunk.toString()
})
req.on('end', () => {
let requestedId = ''
if (body.trim()) {
try {
const parsed = JSON.parse(body) as { gameId?: string }
if (typeof parsed.gameId === 'string') {
requestedId = normalizeGameId(parsed.gameId)
}
} catch {
json(res, 400, { error: 'Invalid JSON payload' })
return
}
}
let gameId = requestedId
if (!gameId) {
gameId = createGameId()
while (sessions.has(gameId)) gameId = createGameId()
} else if (sessions.has(gameId)) {
json(res, 409, { error: 'Game ID already exists' })
return
}
const session = ensureSession(gameId)
json(res, 201, { gameId: session.id })
})
return
}
if (req.method === 'GET' && pathname.startsWith('/sessions/')) {
const gameId = pathname.split('/').filter(Boolean)[1]
if (!gameId) {
json(res, 400, { error: 'Missing gameId' })
return
}
const session = sessions.get(gameId.toUpperCase())
if (!session) {
json(res, 404, { error: 'Session not found' })
return
}
json(res, 200, { gameId: session.id, hasState: !!session.state })
return
}
json(res, 404, { error: 'Not found' })
})
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
if (!req.url) {
socket.destroy()
return
}
const requestUrl = new URL(req.url, `http://localhost:${port}`)
if (requestUrl.pathname !== '/ws') {
socket.destroy()
return
}
const gameId = requestUrl.searchParams.get('gameId')?.toUpperCase()
const clientId = requestUrl.searchParams.get('clientId') || crypto.randomUUID()
if (!gameId) {
socket.destroy()
return
}
const session = ensureSession(gameId)
session.updatedAt = Date.now()
wss.handleUpgrade(req, socket, head, (ws) => {
clients.set(clientId, { gameId, socket: ws })
ws.send(
JSON.stringify({
type: 'session:init',
gameId,
state: session.state
})
)
ws.on('message', (buffer) => {
let parsed: { type?: string; state?: SessionState } | null = null
try {
parsed = JSON.parse(buffer.toString()) as { type?: string; state?: SessionState }
} catch {
return
}
if (!parsed || parsed.type !== 'state:update' || !parsed.state) return
const targetSession = ensureSession(gameId)
targetSession.state = parsed.state
targetSession.updatedAt = Date.now()
broadcast(
gameId,
{
type: 'state:update',
gameId,
state: targetSession.state
},
ws
)
})
ws.on('close', () => {
clients.delete(clientId)
})
})
})
setInterval(() => {
const threshold = Date.now() - sessionTtlMs
sessions.forEach((session, id) => {
if (session.updatedAt >= threshold) return
const hasClient = Array.from(clients.values()).some((client) => client.gameId === id)
if (!hasClient) sessions.delete(id)
})
}, 1000 * 60)
server.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Realtime server listening on :${port}`)
})