- 服务端权威状态机:落子校验、四方向五连判胜、悔棋请求、认输、离线判负、交换黑白再来一局 - 断线重连:resumeToken 凭证 + localStorage,刷新/锁屏自动回到原座位 - 移动端 Canvas 棋盘:DPR 适配、触摸容差吸附、最后一手标记、胜利连子高亮 - Docker 多阶段构建,非 root + 只读根文件系统 - k8s 清单:单副本 Recreate(内存态房态)、Ingress WebSocket 超时与粘性会话注释
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
// 临时端到端联调脚本:启动服务后用两个 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)
|
||
})
|