/** * 房间与对局状态机。 * 服务端权威:所有合法性校验与胜负判定都在这里完成,客户端只发送意图。 */ 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 RoomStats, type RoomState, } from '../shared/protocol.js' import { createBoard, findHeartLine, 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 /** 昵称,空串 = 未设置 */ nickname: string } function newSeat(): Seat { return { resumeToken: null, socketId: null, online: false, offlineSince: null, nickname: '' } } 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() /** 本局是否已触发爱心彩蛋(每局最多一次) */ heartBy: Player | null = null /** 房间累计战绩(进程内存) */ stats: RoomStats = { round: 1, blackWins: 0, whiteWins: 0, draws: 0 } private seats: Record = { 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 } nicknameOf(seat: Player): string { return this.seats[seat].nickname } /** 设置昵称,剔除全空/过长输入 */ setNickname(seat: Player, nickname: string): void { const name = nickname.trim().slice(0, 12) if (name.length > 0) { this.seats[seat].nickname = name this.touch() } } /** 把 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 } /* --------------------------- 对局动作 --------------------------- */ /** 统一终局结算:状态置 over 并累计战绩(仅转变时调用一次) */ private finish(winner: Player | 0, reason: EndReason, winLine: number[] | null): void { this.status = 'over' this.winner = winner this.endReason = reason this.winLine = winLine if (winner === 1) this.stats.blackWins += 1 else if (winner === 2) this.stats.whiteWins += 1 else this.stats.draws += 1 this.touch() } 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() // 爱心连珠彩蛋:本局未触发过且落子后成爱心 → 记录触发方 if (this.heartBy === null && findHeartLine(this.board, player)) { this.heartBy = player } const line = findWinLine(this.board, x, y, player) if (line) { this.finish(player, 'five', line) } else if (this.moveCount >= CELL_COUNT) { this.finish(0, 'draw', null) } 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.finish(opponent(player), 'resign', null) 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.finish(player, 'offline', null) 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.heartBy = null this.stats.round += 1 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, nickname: this.seats[1].nickname, }, white: { joined: this.seats[2].resumeToken !== null, online: this.seats[2].online, nickname: this.seats[2].nickname, }, heartBy: this.heartBy, stats: { ...this.stats }, seat, } } private touch(): void { this.lastActiveAt = Date.now() } } export class RoomManager { private rooms = new Map() 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()] } }