feat: 在线双人五子棋(Socket.IO + Vue3 + Canvas)

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