feat: 在线双人五子棋(Socket.IO + Vue3 + Canvas)
- 服务端权威状态机:落子校验、四方向五连判胜、悔棋请求、认输、离线判负、交换黑白再来一局 - 断线重连:resumeToken 凭证 + localStorage,刷新/锁屏自动回到原座位 - 移动端 Canvas 棋盘:DPR 适配、触摸容差吸附、最后一手标记、胜利连子高亮 - Docker 多阶段构建,非 root + 只读根文件系统 - k8s 清单:单副本 Recreate(内存态房态)、Ingress WebSocket 超时与粘性会话注释
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 纯规则模块:坐标换算、落子合法性、胜负判定。
|
||||
* 不依赖任何 I/O,便于单独推演和测试。
|
||||
*/
|
||||
import { BOARD_SIZE, CELL_COUNT, WIN_COUNT, type Cell, type Player } from '../shared/protocol.js'
|
||||
|
||||
export function idx(x: number, y: number): number {
|
||||
return y * BOARD_SIZE + x
|
||||
}
|
||||
|
||||
export function inBounds(x: number, y: number): boolean {
|
||||
return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE
|
||||
}
|
||||
|
||||
export function createBoard(): Cell[] {
|
||||
return new Array<Cell>(CELL_COUNT).fill(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以最后落子点为中心,向横、竖、两条斜线四个方向统计同色连子。
|
||||
* 返回该方向上的完整连子下标数组(长度 >= 5 即为获胜),否则返回 null。
|
||||
*/
|
||||
export function findWinLine(board: Cell[], x: number, y: number, player: Player): number[] | null {
|
||||
const dirs: Array<[number, number]> = [
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
]
|
||||
|
||||
for (const [dx, dy] of dirs) {
|
||||
const line: number[] = [idx(x, y)]
|
||||
|
||||
// 正方向延伸
|
||||
for (let cx = x + dx, cy = y + dy; inBounds(cx, cy) && board[idx(cx, cy)] === player; cx += dx, cy += dy) {
|
||||
line.push(idx(cx, cy))
|
||||
}
|
||||
// 反方向延伸,插到数组头部以保持连线有序
|
||||
for (let cx = x - dx, cy = y - dy; inBounds(cx, cy) && board[idx(cx, cy)] === player; cx -= dx, cy -= dy) {
|
||||
line.unshift(idx(cx, cy))
|
||||
}
|
||||
|
||||
if (line.length >= WIN_COUNT) return line
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isEmptyCell(board: Cell[], x: number, y: number): boolean {
|
||||
return inBounds(x, y) && board[idx(x, y)] === 0
|
||||
}
|
||||
|
||||
export function opponent(p: Player): Player {
|
||||
return p === 1 ? 2 : 1
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 服务端入口: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<T>(
|
||||
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<void>(({ room, seat }) => room.resign(seat)))
|
||||
socket.on(EV.undoRequest, withCtx<void>(({ room, seat }) => room.requestUndo(seat)))
|
||||
socket.on(EV.claimOffline, withCtx<void>(({ 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()
|
||||
})
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 房间与对局状态机。
|
||||
* 服务端权威:所有合法性校验与胜负判定都在这里完成,客户端只发送意图。
|
||||
*/
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
CELL_COUNT,
|
||||
OFFLINE_CLAIM_MS,
|
||||
ROOM_GC_MS,
|
||||
UNDO_TIMEOUT_MS,
|
||||
type Cell,
|
||||
type EndReason,
|
||||
type GameStatus,
|
||||
type Player,
|
||||
type RoomState,
|
||||
} from '../shared/protocol.js'
|
||||
import { createBoard, findWinLine, idx, isEmptyCell, opponent } from './game.js'
|
||||
|
||||
/** 房间码字母表:剔除 O/0/I/1 等易混字符 */
|
||||
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
function generateRoomId(): string {
|
||||
const bytes = randomBytes(6)
|
||||
let out = ''
|
||||
for (let i = 0; i < 6; i++) out += ALPHABET[bytes[i]! % ALPHABET.length]
|
||||
return out
|
||||
}
|
||||
|
||||
interface Seat {
|
||||
/** 重连凭证,入座后固定不变 */
|
||||
resumeToken: string | null
|
||||
socketId: string | null
|
||||
online: boolean
|
||||
/** 掉线起始时间,用于判定"离线超时" */
|
||||
offlineSince: number | null
|
||||
}
|
||||
|
||||
function newSeat(): Seat {
|
||||
return { resumeToken: null, socketId: null, online: false, offlineSince: null }
|
||||
}
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
export class Room {
|
||||
readonly id: string
|
||||
board: Cell[] = createBoard()
|
||||
turn: Player = 1
|
||||
status: GameStatus = 'waiting'
|
||||
winner: Player | 0 | null = null
|
||||
endReason: EndReason | null = null
|
||||
winLine: number[] | null = null
|
||||
lastMove: number | null = null
|
||||
/** 落子顺序栈(棋盘下标),用于悔棋与"最后一手"标记 */
|
||||
history: number[] = []
|
||||
moveCount = 0
|
||||
undoRequestedBy: Player | null = null
|
||||
undoRequestedAt: number | null = null
|
||||
lastActiveAt = Date.now()
|
||||
|
||||
private seats: Record<Player, Seat> = { 1: newSeat(), 2: newSeat() }
|
||||
|
||||
constructor(id = generateRoomId()) {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
/* --------------------------- 座位管理 --------------------------- */
|
||||
|
||||
socketIdOf(seat: Player): string | null {
|
||||
return this.seats[seat].socketId
|
||||
}
|
||||
|
||||
seatOfSocket(socketId: string): Player | null {
|
||||
if (this.seats[1].socketId === socketId) return 1
|
||||
if (this.seats[2].socketId === socketId) return 2
|
||||
return null
|
||||
}
|
||||
|
||||
tokenOf(seat: Player): string {
|
||||
const s = this.seats[seat]
|
||||
if (!s.resumeToken) s.resumeToken = randomUUID()
|
||||
return s.resumeToken
|
||||
}
|
||||
|
||||
isFull(): boolean {
|
||||
return this.seats[1].resumeToken !== null && this.seats[2].resumeToken !== null
|
||||
}
|
||||
|
||||
freeSeat(): Player | null {
|
||||
if (this.seats[1].resumeToken === null) return 1
|
||||
if (this.seats[2].resumeToken === null) return 2
|
||||
return null
|
||||
}
|
||||
|
||||
/** 用 token 找回座位(重连) */
|
||||
seatByToken(token: string): Player | null {
|
||||
if (this.seats[1].resumeToken === token) return 1
|
||||
if (this.seats[2].resumeToken === token) return 2
|
||||
return null
|
||||
}
|
||||
|
||||
/** 把 socket 绑定到座位;若该座位已有旧 socket,则把旧 socket 顶掉 */
|
||||
bind(seat: Player, socketId: string): void {
|
||||
const s = this.seats[seat]
|
||||
this.tokenOf(seat)
|
||||
s.socketId = socketId
|
||||
s.online = true
|
||||
s.offlineSince = null
|
||||
this.touch()
|
||||
if (this.status === 'waiting' && this.isFull()) {
|
||||
this.status = 'playing'
|
||||
}
|
||||
}
|
||||
|
||||
/** socket 断开:标记座位离线 */
|
||||
unbind(socketId: string): Player | null {
|
||||
const seat = this.seatOfSocket(socketId)
|
||||
if (seat === null) return null
|
||||
const s = this.seats[seat]!
|
||||
s.socketId = null
|
||||
s.online = false
|
||||
s.offlineSince = Date.now()
|
||||
this.touch()
|
||||
return seat
|
||||
}
|
||||
|
||||
/** 双方均已离线且超过回收时长 → 可回收 */
|
||||
isExpired(now = Date.now()): boolean {
|
||||
const seats = [1, 2] as const
|
||||
const joined = seats.filter((p) => this.seats[p].resumeToken !== null)
|
||||
if (joined.length === 0) return now - this.lastActiveAt > ROOM_GC_MS
|
||||
// 全部离线:以最早掉线时间为准
|
||||
if (joined.every((p) => !this.seats[p].online)) {
|
||||
const since = Math.min(...joined.map((p) => this.seats[p].offlineSince ?? now))
|
||||
return now - since > ROOM_GC_MS
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
offlineFor(player: Player, now = Date.now()): number {
|
||||
const s = this.seats[player]
|
||||
if (s.online || s.offlineSince === null) return 0
|
||||
return now - s.offlineSince
|
||||
}
|
||||
|
||||
/* --------------------------- 对局动作 --------------------------- */
|
||||
|
||||
place(x: number, y: number, player: Player): ActionResult {
|
||||
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
||||
if (this.status === 'over') return { ok: false, error: '本局已结束,请开新局' }
|
||||
if (this.turn !== player) return { ok: false, error: '还没轮到你落子' }
|
||||
if (!isEmptyCell(this.board, x, y)) return { ok: false, error: '该位置已有棋子' }
|
||||
|
||||
const at = idx(x, y)
|
||||
this.board[at] = player
|
||||
this.history.push(at)
|
||||
this.moveCount += 1
|
||||
this.lastMove = at
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
|
||||
const line = findWinLine(this.board, x, y, player)
|
||||
if (line) {
|
||||
this.status = 'over'
|
||||
this.winner = player
|
||||
this.endReason = 'five'
|
||||
this.winLine = line
|
||||
} else if (this.moveCount >= CELL_COUNT) {
|
||||
this.status = 'over'
|
||||
this.winner = 0
|
||||
this.endReason = 'draw'
|
||||
} else {
|
||||
this.turn = opponent(player)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** 悔棋需要撤回的手数:轮到请求方时撤回 2 手,否则撤回 1 手 */
|
||||
private undoSteps(player: Player): number {
|
||||
return this.turn === player ? 2 : 1
|
||||
}
|
||||
|
||||
requestUndo(player: Player): ActionResult {
|
||||
if (this.status !== 'playing') return { ok: false, error: '当前无法悔棋' }
|
||||
if (this.undoRequestedBy !== null) return { ok: false, error: '已有悔棋请求待处理' }
|
||||
if (this.moveCount < this.undoSteps(player)) return { ok: false, error: '还没有可悔的棋' }
|
||||
|
||||
this.undoRequestedBy = player
|
||||
this.undoRequestedAt = Date.now()
|
||||
this.touch()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
respondUndo(player: Player, accept: boolean): ActionResult {
|
||||
const requester = this.undoRequestedBy
|
||||
if (requester === null) return { ok: false, error: '没有待处理的悔棋请求' }
|
||||
if (requester === player) return { ok: false, error: '不能响应自己的悔棋请求' }
|
||||
|
||||
const steps = this.undoSteps(requester)
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
if (!accept) return { ok: true }
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const at = this.history.pop()
|
||||
if (at === undefined) break
|
||||
this.board[at] = 0
|
||||
this.moveCount -= 1
|
||||
}
|
||||
this.lastMove = this.history.at(-1) ?? null
|
||||
// 撤回后回合归请求方
|
||||
this.turn = requester
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
expireUndo(now = Date.now()): boolean {
|
||||
if (this.undoRequestedAt === null) return false
|
||||
if (now - this.undoRequestedAt < UNDO_TIMEOUT_MS) return false
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
return true
|
||||
}
|
||||
|
||||
resign(player: Player): ActionResult {
|
||||
if (this.status === 'over') return { ok: false, error: '本局已结束' }
|
||||
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
||||
this.status = 'over'
|
||||
this.winner = opponent(player)
|
||||
this.endReason = 'resign'
|
||||
this.winLine = null
|
||||
this.touch()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
claimOffline(player: Player, now = Date.now()): ActionResult {
|
||||
if (this.status !== 'playing') return { ok: false, error: '当前无法判负' }
|
||||
const foe = opponent(player)
|
||||
if (this.seats[foe].resumeToken === null) return { ok: false, error: '对手还没有进入房间' }
|
||||
if (this.seats[foe].online) return { ok: false, error: '对手在线,无法判负' }
|
||||
if (this.offlineFor(foe, now) < OFFLINE_CLAIM_MS) {
|
||||
return { ok: false, error: `对手掉线未超过 ${Math.round(OFFLINE_CLAIM_MS / 1000)} 秒,请再等一下` }
|
||||
}
|
||||
this.status = 'over'
|
||||
this.winner = player
|
||||
this.endReason = 'offline'
|
||||
this.winLine = null
|
||||
this.touch()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 再来一局。swap 为 true 时交换双方座位(等价于交换黑白)。
|
||||
* 返回座位是否发生了交换,供调用方重新绑定 socket ↔ 座位的映射。
|
||||
*/
|
||||
restart(swap: boolean): { swapped: boolean; mapping: Array<[string, Player]> } {
|
||||
this.board = createBoard()
|
||||
this.turn = 1
|
||||
this.status = this.isFull() ? 'playing' : 'waiting'
|
||||
this.winner = null
|
||||
this.endReason = null
|
||||
this.winLine = null
|
||||
this.lastMove = null
|
||||
this.history = []
|
||||
this.moveCount = 0
|
||||
this.undoRequestedBy = null
|
||||
this.undoRequestedAt = null
|
||||
this.touch()
|
||||
|
||||
if (!swap) return { swapped: false, mapping: [] }
|
||||
|
||||
const a = this.seats[1]
|
||||
this.seats[1] = this.seats[2]
|
||||
this.seats[2] = a
|
||||
|
||||
const mapping: Array<[string, Player]> = []
|
||||
for (const p of [1, 2] as const) {
|
||||
const sid = this.seats[p].socketId
|
||||
if (sid) mapping.push([sid, p])
|
||||
}
|
||||
return { swapped: true, mapping }
|
||||
}
|
||||
|
||||
/* --------------------------- 状态快照 --------------------------- */
|
||||
|
||||
/** 生成下发给某个客户端的完整状态,seat 为该客户端执子颜色 */
|
||||
snapshot(seat: Player | null): RoomState {
|
||||
return {
|
||||
roomId: this.id,
|
||||
board: this.board.slice(),
|
||||
turn: this.turn,
|
||||
status: this.status,
|
||||
winner: this.winner,
|
||||
endReason: this.endReason,
|
||||
winLine: this.winLine,
|
||||
lastMove: this.lastMove,
|
||||
moveCount: this.moveCount,
|
||||
undoRequestedBy: this.undoRequestedBy,
|
||||
black: { joined: this.seats[1].resumeToken !== null, online: this.seats[1].online },
|
||||
white: { joined: this.seats[2].resumeToken !== null, online: this.seats[2].online },
|
||||
seat,
|
||||
}
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this.lastActiveAt = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
private rooms = new Map<string, Room>()
|
||||
|
||||
create(): Room {
|
||||
let id = generateRoomId()
|
||||
while (this.rooms.has(id)) id = generateRoomId()
|
||||
const room = new Room(id)
|
||||
this.rooms.set(id, room)
|
||||
return room
|
||||
}
|
||||
|
||||
get(id: string): Room | undefined {
|
||||
return this.rooms.get(id.trim().toUpperCase())
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.rooms.delete(id)
|
||||
}
|
||||
|
||||
/** 回收长期无人且双方离线的房间 */
|
||||
gc(now = Date.now()): number {
|
||||
let removed = 0
|
||||
for (const [id, room] of this.rooms) {
|
||||
if (room.isExpired(now)) {
|
||||
this.rooms.delete(id)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.rooms.size
|
||||
}
|
||||
|
||||
values(): Room[] {
|
||||
return [...this.rooms.values()]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user