- 四套主题(雅致木纹/心动粉/暗夜紫/水墨江南):CSS 变量 + Canvas 棋盘配色联动 - 落子弹跳、最后一手呼吸光晕、五连扫光动画、终局爱心跳动卡片 - Web Audio 合成音效 + 手机震动反馈(落子/胜利/悔棋/表情/爱心) - 自定义昵称(服务端座位持久化)+ 房间战绩统计(局数/胜负/和棋/决胜手数) - 长按棋盘随机发送甜蜜表情,实时漂浮同步给对手(3 秒冷却防刷) - 爱心连珠彩蛋:服务端模板检测棋盘爱心形状(含旋转镜像),触发全场爱心雨 - 双击棋盘点燃小爱心;随机甜蜜结算文案;大厅双人剪影插画与浪漫渐变 - 首页暴露局域网地址(vite host: true)
381 lines
16 KiB
JavaScript
381 lines
16 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',
|
||
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)
|
||
})
|