/** * 服务端入口:Express 托管前端静态资源 + Socket.IO 处理对局事件。 * 单进程内存态,因此部署时必须保持单副本(见 k8s/deployment.yaml)。 */ import { createServer } from 'node:http' import { fileURLToPath } from 'node:url' import path from 'node:path' import express from 'express' import { Server } from 'socket.io' import { EV, type ActionAck, type CreateAck, type JoinAck, type NoticePayload, type Player, } from '../shared/protocol.js' import { Room, RoomManager } from './room.js' const HERE = path.dirname(fileURLToPath(import.meta.url)) const PORT = Number(process.env.PORT ?? 3000) /** * 构建产物布局(因 server/ 与 shared/ 需一同编译,tsc 保留了目录层级): * dist-server/server/index.js ← 当前文件 * dist-server/shared/protocol.js * dist-web/ ← 前端静态资源 */ const WEB_DIR = process.env.WEB_DIR ?? path.resolve(HERE, '../../dist-web') const app = express() // 位于 Ingress / 反代之后,需要信任 X-Forwarded-* 才能拿到真实协议与 IP app.set('trust proxy', true) app.get('/healthz', (_req, res) => { res.json({ ok: true, rooms: manager.size, uptime: Math.round(process.uptime()) }) }) app.use(express.static(WEB_DIR, { index: 'index.html', maxAge: '1h' })) // SPA 兜底:非 /socket.io、非静态资源的 GET 一律返回 index.html app.get('*', (req, res, next) => { if (req.path.startsWith('/socket.io')) return next() res.sendFile(path.join(WEB_DIR, 'index.html')) }) const httpServer = createServer(app) const io = new Server(httpServer, { serveClient: false, pingInterval: 25_000, pingTimeout: 20_000, maxHttpBufferSize: 1e5, }) const manager = new RoomManager() declare module 'socket.io' { interface SocketData { roomId?: string seat?: Player } } /* ------------------------------------------------------------------ */ /* 工具 */ /* ------------------------------------------------------------------ */ /** 把房间最新状态按各自座位视角分别下发给两端 */ function broadcast(room: Room): void { for (const seat of [1, 2] as const) { const sid = room.socketIdOf(seat) if (sid) io.to(sid).emit(EV.state, room.snapshot(seat)) } } function notice(socketId: string, level: NoticePayload['level'], message: string): void { io.to(socketId).emit(EV.notice, { level, message } satisfies NoticePayload) } type Ctx = { room: Room; seat: Player } | null /** * 取 Socket.IO 的回调。 * 不能用可选链直接调用:客户端可能把载荷当 ack 传进来, * `ack?.()` 只判空不判可调用,会抛未捕获异常打挂整个进程。 */ type AnyAck = (r: unknown) => void function asAck(v: unknown): AnyAck | undefined { return typeof v === 'function' ? (v as AnyAck) : undefined } /** 取出当前 socket 所处的房间与座位,顺带校验房间是否已被回收 */ function ctxOf(socketId: string, socketData: { roomId?: string; seat?: Player }): Ctx { const { roomId, seat } = socketData if (!roomId || !seat) return null const room = manager.get(roomId) if (!room) return null if (room.socketIdOf(seat) !== socketId) return null return { room, seat } } /* ------------------------------------------------------------------ */ /* 事件 */ /* ------------------------------------------------------------------ */ io.on('connection', (socket) => { /** * 统一包装动作类事件:校验上下文 → 执行 → ack → 广播。 * 校验不通过时不广播,仅把错误回给发起方。 */ function withCtx( fn: (ctx: { room: Room; seat: Player }, payload: T) => ActionAck, ): (payload: T | undefined, ack?: unknown) => void { return (payload, ack) => { const reply = asAck(ack) const ctx = ctxOf(socket.id, socket.data) if (!ctx) { reply?.({ ok: false, error: '你已不在对局中,请重新进入房间' }) return } const result = fn(ctx, payload as T) if (result.ok) broadcast(ctx.room) reply?.(result) } } socket.on(EV.create, (_payload: unknown, ack?: unknown) => { const reply = asAck(ack) const room = manager.create() room.bind(1, socket.id) socket.data.roomId = room.id socket.data.seat = 1 void socket.join(room.id) reply?.({ ok: true, roomId: room.id, seat: 1, resumeToken: room.tokenOf(1) } satisfies CreateAck) broadcast(room) }) socket.on( EV.join, ( payload: { roomId?: string; resumeToken?: string } | undefined, ack?: unknown, ) => { const reply = asAck(ack) const raw = payload?.roomId ?? '' const room = manager.get(raw) if (!room) { reply?.({ ok: false, error: '房间不存在或已过期' }) return } // 优先用重连凭证找回原座位,其次占用空位 let seat: Player | null = null if (payload?.resumeToken) seat = room.seatByToken(payload.resumeToken) if (seat === null) seat = room.freeSeat() if (seat === null) { reply?.({ ok: false, error: '房间已满,无法加入' }) return } const staleSocketId = room.socketIdOf(seat) room.bind(seat, socket.id) socket.data.roomId = room.id socket.data.seat = seat void socket.join(room.id) // 顶掉该座位的旧连接(同一玩家在另一台设备/标签页重连) if (staleSocketId && staleSocketId !== socket.id) { const stale = io.sockets.sockets.get(staleSocketId) if (stale) { stale.data.roomId = undefined stale.data.seat = undefined stale.disconnect(true) } } reply?.({ ok: true, roomId: room.id, seat, resumeToken: room.tokenOf(seat), state: room.snapshot(seat), } satisfies JoinAck) broadcast(room) }, ) socket.on( EV.move, withCtx<{ x?: number; y?: number }>(({ room, seat }, p) => { if (!Number.isInteger(p?.x) || !Number.isInteger(p?.y)) { return { ok: false, error: '非法坐标' } } return room.place(p.x as number, p.y as number, seat) }), ) socket.on(EV.resign, withCtx(({ room, seat }) => room.resign(seat))) socket.on(EV.undoRequest, withCtx(({ room, seat }) => room.requestUndo(seat))) socket.on(EV.claimOffline, withCtx(({ room, seat }) => room.claimOffline(seat))) socket.on( EV.undoRespond, withCtx<{ accept?: boolean }>(({ room, seat }, p) => room.respondUndo(seat, p?.accept === true)), ) socket.on( EV.restart, (payload: { swap?: boolean } | undefined, ack?: unknown) => { const reply = asAck(ack) const ctx = ctxOf(socket.id, socket.data) if (!ctx) { reply?.({ ok: false, error: '你已不在对局中' }) return } const { swapped, mapping } = ctx.room.restart(payload?.swap === true) // 交换黑白后必须同步 socket ↔ 座位映射,否则后续落子校验会认错颜色 if (swapped) { for (const [sid, seat] of mapping) { const s = io.sockets.sockets.get(sid) if (s) s.data.seat = seat } } reply?.({ ok: true }) broadcast(ctx.room) }, ) socket.on('disconnect', () => { const ctx = ctxOf(socket.id, socket.data) if (!ctx) return ctx.room.unbind(socket.id) broadcast(ctx.room) }) }) /* ------------------------------------------------------------------ */ /* 周期任务:悔棋请求超时、房间回收 */ /* ------------------------------------------------------------------ */ const ticker = setInterval(() => { for (const room of manager.values()) { if (room.expireUndo()) { broadcast(room) for (const seat of [1, 2] as const) { const sid = room.socketIdOf(seat) if (sid) notice(sid, 'info', '悔棋请求已超时失效') } } } const removed = manager.gc() if (removed > 0) console.log(`[gc] 回收房间 ${removed} 个,当前 ${manager.size} 个`) }, 15_000) ticker.unref() httpServer.listen(PORT, '0.0.0.0', () => { console.log(`[wuziqi] listening on :${PORT}, static dir = ${WEB_DIR}`) }) for (const sig of ['SIGINT', 'SIGTERM'] as const) { process.on(sig, () => { console.log(`[wuziqi] ${sig} received, shutting down`) io.close(() => httpServer.close(() => process.exit(0))) setTimeout(() => process.exit(0), 3000).unref() }) }