Files
wuziqi/scripts/smoke.mjs
T
root 59bfc98afa feat: 在线双人五子棋(Socket.IO + Vue3 + Canvas)
- 服务端权威状态机:落子校验、四方向五连判胜、悔棋请求、认输、离线判负、交换黑白再来一局
- 断线重连:resumeToken 凭证 + localStorage,刷新/锁屏自动回到原座位
- 移动端 Canvas 棋盘:DPR 适配、触摸容差吸附、最后一手标记、胜利连子高亮
- Docker 多阶段构建,非 root + 只读根文件系统
- k8s 清单:单副本 Recreate(内存态房态)、Ingress WebSocket 超时与粘性会话注释
2026-09-10 18:17:10 +08:00

294 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 临时端到端联调脚本:启动服务后用两个 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',
state: 'game:state',
}
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms))
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, {})
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 })
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)
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))),
)
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)
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))
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))
console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`)
process.exit(fail === 0 ? 0 : 1)
}
main().catch((e) => {
console.error('\n联调脚本异常:', e)
process.exit(1)
})