// 临时端到端联调脚本:启动服务后用两个 socket 客户端模拟真实对局 import { io } from 'socket.io-client' const URL = process.env.URL ?? 'http://127.0.0.1:3000' const EV = { create: 'room:create', join: 'room:join', move: 'game:move', undoRequest: 'undo:request', undoRespond: 'undo:respond', resign: 'game:resign', claimOffline: 'game:claim-offline', restart: 'game:restart', emojiSend: 'emoji:send', emojiRecv: 'emoji:recv', state: 'game:state', } function waitEvent(s, event, ms = 3000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { s.off(event, h) reject(new Error(`${s.label} 等待事件 ${event} 超时`)) }, ms) function h(...args) { clearTimeout(timer) s.off(event, h) resolve(...args) } s.on(event, h) }) } let pass = 0 let fail = 0 function check(name, cond, extra = '') { if (cond) { pass++ console.log(` ok ${name}`) } else { fail++ console.log(` FAIL ${name} ${extra}`) } } const connect = (label) => new Promise((resolve, reject) => { const s = io(URL, { transports: ['websocket'], forceNew: true }) s.label = label s.on('connect', () => resolve(s)) s.on('connect_error', (e) => reject(new Error(`${label} 连接失败: ${e.message}`))) }) const emit = (s, ev, payload) => new Promise((resolve) => s.emit(ev, payload, resolve)) function waitState(s, pred = () => true, ms = 3000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { s.off(EV.state, h) reject(new Error(`${s.label} 等待 state 超时`)) }, ms) function h(st) { if (!pred(st)) return clearTimeout(timer) s.off(EV.state, h) resolve(st) } s.on(EV.state, h) }) } /** 先挂监听再发指令,避免广播早于监听到达 */ async function act(s, ev, payload, pred) { const wait = waitState(s, pred) const ack = await emit(s, ev, payload) const st = await wait return { ack, st } } const at = (x, y) => y * 15 + x async function main() { console.log(`\n== 连接 ${URL} ==`) const A = await connect('A') const B = await connect('B') check('两个客户端均已连接', A.connected && B.connected) console.log('\n== 1. 建房与加入 ==') const createAck = await emit(A, EV.create, { nickname: '宝宝' }) check('创建房间返回 ok', createAck?.ok === true, JSON.stringify(createAck)) const roomId = createAck.roomId check('房间码为 6 位', typeof roomId === 'string' && roomId.length === 6, roomId) const beforeJoin = await emit(B, EV.join, { roomId, nickname: '贝贝' }) check('B 加入成功且执白', beforeJoin?.ok === true && beforeJoin.seat === 2, JSON.stringify(beforeJoin).slice(0, 120)) check('B 加入后状态为 playing', beforeJoin.state.status === 'playing') check('黑棋先手', beforeJoin.state.turn === 1) check('B 拿到重连凭证', typeof beforeJoin.resumeToken === 'string' && beforeJoin.resumeToken.length > 10) check('昵称已生效:黑=宝宝', beforeJoin.state.black.nickname === '宝宝', JSON.stringify(beforeJoin.state.black)) check('昵称已生效:白=贝贝', beforeJoin.state.white.nickname === '贝贝', JSON.stringify(beforeJoin.state.white)) check('房间战绩初始 round=1', beforeJoin.state.stats.round === 1 && beforeJoin.state.stats.blackWins === 0) check('爱心彩蛋初始未触发', beforeJoin.state.heartBy === null) const third = await connect('C') const thirdJoin = await emit(third, EV.join, { roomId }) check('第三人加入被拒(房间已满)', thirdJoin?.ok === false, JSON.stringify(thirdJoin)) third.disconnect() console.log('\n== 2. 落子与轮次校验 ==') const bad = await emit(B, EV.move, { x: 7, y: 7 }) check('白棋抢先落子被拒', bad?.ok === false && /轮/.test(bad.error), JSON.stringify(bad)) const m1 = await act(A, EV.move, { x: 7, y: 7 }, (s) => s.board[at(7, 7)] === 1) check('黑棋 (7,7) 落子成功', m1.ack.ok === true) check('落子后轮到白棋', m1.st.turn === 2) check('B 同步收到该手', m1.st.board[at(7, 7)] === 1 && m1.st.lastMove === at(7, 7)) check('A 视角座位为黑', m1.st.seat === 1) check('B 视角座位为白', beforeJoin.state.seat === 2) const dup = await emit(B, EV.move, { x: 7, y: 7 }) check('重复落子被拒', dup?.ok === false && /棋子/.test(dup.error), JSON.stringify(dup)) const oob = await emit(B, EV.move, { x: 99, y: -1 }) check('越界坐标被拒', oob?.ok === false, JSON.stringify(oob)) const m2 = await act(B, EV.move, { x: 0, y: 0 }, (s) => s.board[at(0, 0)] === 2) check('白棋 (0,0) 落子成功', m2.ack.ok === true && m2.st.moveCount === 2) console.log('\n== 3. 悔棋 ==') // 此时轮到黑,A 请求悔棋 → 撤回 2 手(白 1 手 + 黑 1 手) const undoReq = await act(A, EV.undoRequest, {}, (s) => s.undoRequestedBy === 1) check('A 发起悔棋成功', undoReq.ack.ok === true) check('B 看到悔棋请求来自黑棋', undoReq.st.undoRequestedBy === 1) const selfRespond = await emit(A, EV.undoRespond, { accept: true }) check('请求方不能自己同意', selfRespond?.ok === false, JSON.stringify(selfRespond)) const undoDone = await act(B, EV.undoRespond, { accept: true }, (s) => s.moveCount === 0) check('B 同意后悔棋生效', undoDone.ack.ok === true) check('撤回 2 手后棋盘清空', undoDone.st.board.every((c) => c === 0)) check('撤回后轮到黑棋', undoDone.st.turn === 1) check('撤回后无 lastMove', undoDone.st.lastMove === null) check('悔棋请求已清空', undoDone.st.undoRequestedBy === null) console.log('\n== 4. 五连判胜 ==') // 黑棋沿 y=7 走 (3..7,7),白棋走 y=0;黑棋第 5 手成五连 let last = null for (let i = 0; i < 5; i++) { last = await act(A, EV.move, { x: 3 + i, y: 7 }, (s) => s.moveCount === 2 * i + 1) if (i < 4) await act(B, EV.move, { x: i, y: 0 }, (s) => s.moveCount === 2 * i + 2) } const won = last.st check( '黑棋五连获胜', won.status === 'over' && won.winner === 1, JSON.stringify({ status: won.status, winner: won.winner }), ) check('终局原因 five', won.endReason === 'five', String(won.endReason)) check('winLine 长度为 5', won.winLine?.length === 5, JSON.stringify(won.winLine)) check( 'winLine 内容正确', JSON.stringify(won.winLine) === JSON.stringify([3, 4, 5, 6, 7].map((x) => at(x, 7))), ) check('战绩:黑棋胜场 +1', won.stats.blackWins === 1 && won.stats.whiteWins === 0, JSON.stringify(won.stats)) check('直线五连不误触发爱心彩蛋', won.heartBy === null, String(won.heartBy)) const afterOver = await emit(B, EV.move, { x: 10, y: 10 }) check('终局后落子被拒', afterOver?.ok === false, JSON.stringify(afterOver)) console.log('\n== 5. 再来一局与交换黑白 ==') const restarted = await act(A, EV.restart, { swap: false }, (s) => s.status === 'playing' && s.moveCount === 0) check('再来一局重置棋盘', restarted.ack.ok === true && restarted.st.board.every((c) => c === 0)) check('A 仍执黑', restarted.st.seat === 1) check('重置后黑棋先手', restarted.st.turn === 1) const swappedB = waitState(B, (s) => s.seat === 1, 3000) const swappedA = waitState(A, (s) => s.seat === 2, 3000) const swapAck = await emit(A, EV.restart, { swap: true }) const [sB, sA] = await Promise.all([swappedB, swappedA]) check('交换黑白生效:A 变白', swapAck.ok === true && sA.seat === 2) check('交换黑白生效:B 变黑', sB.seat === 1) check('交换后仍是黑棋先手', sA.turn === 1) check('再来一局(两次 restart)后 round 为 3', sA.stats.round === 3, JSON.stringify(sA.stats)) const blackNowIsB = await emit(B, EV.move, { x: 7, y: 7 }) check('交换后 B 可以执黑落子', blackNowIsB?.ok === true, JSON.stringify(blackNowIsB)) const whiteNowIsA = await emit(A, EV.move, { x: 8, y: 8 }) check('交换后 A 执白可正常轮次落子', whiteNowIsA?.ok === true, JSON.stringify(whiteNowIsA)) console.log('\n== 6. 掉线与重连 ==') const offlineSeen = waitState(A, (s) => s.black.online === false, 3000) B.disconnect() const off = await offlineSeen check('A 看到对手离线', off.black.online === false && off.white.online === true) const claim = await emit(A, EV.claimOffline, {}) check('离线未满 60 秒不允许判负', claim?.ok === false && /秒/.test(claim.error), JSON.stringify(claim)) const B2 = await connect('B2') const resumed = await act(B2, EV.join, { roomId, resumeToken: beforeJoin.resumeToken }, (s) => s.black.online === true) check('重连成功并夺回原座位', resumed.ack.ok === true && resumed.ack.seat === 1, JSON.stringify(resumed.ack).slice(0, 120)) check('重连后棋局完整保留', resumed.st.board[at(7, 7)] === 1 && resumed.st.board[at(8, 8)] === 2) check('重连后线上状态恢复', resumed.st.black.online === true) console.log('\n== 7. 认输与房间校验 ==') const resignWait = waitState(B2, (s) => s.status === 'over', 3000) const resignAck = await emit(A, EV.resign, {}) const resigned = await resignWait check('A 认输成功', resignAck.ok === true) check('认输后黑棋(B)获胜', resigned.winner === 1 && resigned.endReason === 'resign', JSON.stringify({ w: resigned.winner, r: resigned.endReason })) const notFound = await emit(A, EV.join, { roomId: 'ZZZZZZ' }) check('加入不存在的房间被拒', notFound?.ok === false, JSON.stringify(notFound)) console.log('\n== 7.5 表情互动 ==') const recvWait = waitEvent(B2, EV.emojiRecv, 3000) const sendAck = await emit(A, EV.emojiSend, { emoji: '❤️' }) check('发送表情成功', sendAck?.ok === true, JSON.stringify(sendAck)) const recv = await recvWait check('对手收到表情且来源正确', recv.emoji === '❤️' && recv.from === 2, JSON.stringify(recv)) const invalidEmoji = await emit(A, EV.emojiSend, { emoji: '🚫' }) check('非法表情被拒', invalidEmoji?.ok === false, JSON.stringify(invalidEmoji)) const flood = await emit(A, EV.emojiSend, { emoji: '💕' }) check('3 秒内重复发送被限流', flood?.ok === false, JSON.stringify(flood)) const health = await fetch(`${URL}/healthz`).then((r) => r.json()) check('健康检查可用', health.ok === true && typeof health.rooms === 'number', JSON.stringify(health)) check('房间统计可用(≥1)', typeof health.rooms === 'number' && health.rooms >= 1, String(health.rooms)) const indexRes = await fetch(`${URL}/`) const html = await indexRes.text() check('首页可访问', indexRes.ok && html.includes('id="app"')) const spaRes = await fetch(`${URL}/some/deep/link`) check('SPA 兜底返回 index.html', spaRes.ok && (await spaRes.text()).includes('id="app"')) A.disconnect() B2.disconnect() console.log('\n== 8. 状态机边界(直接驱动 Room,注入时间戳)==') const { Room, RoomManager } = await import('../dist-server/server/room.js') const MIN = 60_000 /** 构造一个双方就座的房间,并按 [x, y, player] 顺序落子 */ function build(id, seq) { const r = new Room(id) r.bind(1, `${id}-s1`) r.bind(2, `${id}-s2`) for (const [x, y, p] of seq) { const res = r.place(x, y, p) if (!res.ok) throw new Error(`${id} 落子失败: ${res.error}`) } return r } // 8.1 四方向连珠 const horiz = build('T-H', [ [3, 7, 1], [0, 0, 2], [4, 7, 1], [0, 1, 2], [5, 7, 1], [0, 2, 2], [6, 7, 1], [0, 3, 2], [7, 7, 1], ]) check('横向五连判胜', horiz.winner === 1 && horiz.winLine.length === 5) const vert = build('T-V', [ [5, 0, 1], [0, 0, 2], [5, 1, 1], [0, 1, 2], [5, 2, 1], [0, 2, 2], [5, 3, 1], [0, 3, 2], [5, 4, 1], ]) check('纵向五连判胜', vert.winner === 1 && vert.winLine.length === 5) const diag = build('T-D', [ [0, 0, 1], [14, 0, 2], [1, 1, 1], [14, 1, 2], [2, 2, 1], [14, 2, 2], [3, 3, 1], [14, 3, 2], [4, 4, 1], ]) check('斜向(↘)五连判胜', diag.winner === 1 && diag.winLine.length === 5) const anti = build('T-A', [ [0, 4, 1], [14, 0, 2], [1, 3, 1], [14, 1, 2], [2, 2, 1], [14, 2, 2], [3, 1, 1], [14, 3, 2], [4, 0, 1], ]) check('斜向(↗)五连判胜', anti.winner === 1 && anti.winLine.length === 5) const four = build('T-4', [[3, 7, 1], [0, 0, 2], [4, 7, 1], [0, 1, 2], [5, 7, 1], [0, 2, 2], [6, 7, 1]]) check('四连不判胜', four.status === 'playing' && four.winner === null) // 8.2 和棋:把 moveCount 构造到剩最后一格 const drawRoom = build('T-DR', []) drawRoom.moveCount = 224 const drawRes = drawRoom.place(0, 0, 1) check( '第 225 手触发和棋', drawRes.ok === true && drawRoom.status === 'over' && drawRoom.winner === 0 && drawRoom.endReason === 'draw', ) // 8.3 离线判负的成功路径(用注入的 now 跨越 60 秒阈值) const offRoom = build('T-OFF', [[7, 7, 1]]) offRoom.unbind('T-OFF-s2') const t0 = Date.now() check('刚掉线不允许判负', offRoom.claimOffline(1, t0).ok === false) check('掉线满 60 秒允许判负', offRoom.claimOffline(1, t0 + MIN + 1000).ok === true) check('判负后黑胜且原因为 offline', offRoom.winner === 1 && offRoom.endReason === 'offline') // 8.4 悔棋请求超时 const undoRoom = build('T-UN', [[0, 0, 1], [1, 0, 2]]) check('悔棋请求可发起', undoRoom.requestUndo(1).ok === true) check('未满 60 秒不失效', undoRoom.expireUndo(t0 + MIN - 1000) === false) check('满 60 秒自动失效', undoRoom.expireUndo(t0 + MIN + 1000) === true) check('失效后请求清空', undoRoom.undoRequestedBy === null) // 8.5 房间回收 const mgr = new RoomManager() const gcRoom = mgr.create() gcRoom.bind(1, 'gc-s1') check('有人在线时不回收', gcRoom.isExpired(t0 + 10 * MIN) === false) gcRoom.unbind('gc-s1') check('离线未超时不回收', gcRoom.isExpired(t0 + 1000) === false) check('全员离线超 30 分钟回收', mgr.gc(t0 + 31 * MIN) === 1 && mgr.size === 0) // 8.6 房间码分布 const codeMgr = new RoomManager() const codes = new Set() for (let i = 0; i < 200; i++) codes.add(codeMgr.create().id) check('房间码不含易混字符 O/0/I/1', [...codes].every((c) => !/[O0I1]/.test(c)), [...codes].slice(0, 3).join(',')) check('200 次生成无重复', codes.size === 200, String(codes.size)) // 8.7 爱心连珠彩蛋(模板检测 + 房间级触发) const { findHeartLine } = await import('../dist-server/server/game.js') const fill = (coords) => { const b = new Array(225).fill(0) for (const [x, y] of coords) b[y * 15 + x] = 1 return b } // 4x4 模板(.代表空格,# 代表落子位)在偏移 (5,5): // .##. // #### // #### // .##. const heartCoords = [ [6, 5], [7, 5], [5, 6], [6, 6], [7, 6], [8, 6], [5, 7], [6, 7], [7, 7], [8, 7], [6, 8], [7, 8], ] check( 'findHeartLine 命中 4x4 模板(12 格)', (() => { const line = findHeartLine(fill(heartCoords), 1) return line !== null && line.length === 12 })(), ) // 旋转 90° 后仍能命中 const rotCoords = [ [5, 6], [5, 7], [6, 5], [6, 6], [6, 7], [6, 8], [7, 5], [7, 6], [7, 7], [7, 8], [8, 6], [8, 7], ] check('旋转后模板仍可命中', findHeartLine(fill(rotCoords), 1) !== null) // 直线不触发 const lineOnly = fill([[3, 7], [4, 7], [5, 7], [6, 7], [7, 7]]) check('直线不触发爱心', findHeartLine(lineOnly, 1) === null) // 房间级触发:黑棋交替落模板格,白棋落分散角落,12 手后触发 const heartRoom = new Room('T-HRT') heartRoom.bind(1, 'hr-s1') heartRoom.bind(2, 'hr-s2') const whiteFallback = [ [0, 0], [0, 2], [1, 1], [1, 3], [2, 0], [2, 2], [0, 6], [1, 5], [2, 4], [0, 4], [1, 7], [2, 6], ] for (let i = 0; i < 12; i++) { const br = heartRoom.place(heartCoords[i][0], heartCoords[i][1], 1) if (!br.ok) throw new Error(`黑棋落子失败 (${heartCoords[i]}): ${br.error}`) if (i < 11) { const [wx, wy] = whiteFallback[i] const wr = heartRoom.place(wx, wy, 2) if (!wr.ok) throw new Error(`白棋落子失败 (${wx},${wy}): ${wr.error}`) } } check('房间级触发爱心彩蛋(黑方)', heartRoom.heartBy === 1, String(heartRoom.heartBy)) console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`) process.exit(fail === 0 ? 0 : 1) } main().catch((e) => { console.error('\n联调脚本异常:', e) process.exit(1) })