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

381 lines
16 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',
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)
})