OCC 星球漫游 · 测试指南
测试全部是 Playwright(node 版)无头浏览器脚本,放在 tools/e2e/,共 58 个。 这份文档说明怎么跑、每个脚本管什么、以及哪些坑会让测试给出错误结论。
核对基线:2026-10-15,tools/e2e/目录实测 57 个.cjs+ 1 个.mjs+_auth.cjs助手。
0. 总体情况
| 项 | 值 |
|---|---|
| 脚本总数 | 58(含公共助手 _auth.cjs) |
| 位置 | tools/e2e/,有独立 package.json(type: commonjs) |
| 依赖 | playwright(node 版),复用 %LOCALAPPDATA%\ms-playwright 里的 Chromium |
| 需要服务 | 除文件级 QA 与纯逻辑单测外,都要求 127.0.0.1:8790 已运行(脚本不负责启停) |
| 身份牌 | 45 个走 _auth.cjs;5 个自注册;8 个无需登录 |
| 截图 | 38 个脚本会截图,多数落 _logs/shots/ |
| 基址覆盖 | 环境变量 OCC_BASE |
跑之前先起服务:
python scripts/svc.py start
node tools/e2e/smoke.cjs # 先跑这条确认链路通
1. 环境(本机三条硬约束)
| 约束 | 说明 |
|---|---|
| 必须用 node 版 playwright | 本机 python 版会卡死在 sync_playwright().start(),永不返回 |
| Chromium 必须带特定参数 | --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader(常配 --use-gl=angle --ignore-gpu-blocklist --disable-gpu-sandbox),否则 GPU 进程起不来,报 FATAL: GPU process isn't usable |
| 绝不能并发跑 | 后启动的脚本可能重启服务,掐断前一个正在加载的页面,表现为莫名其妙的 page.screenshot 超时 |
SwiftShader 软渲染只有 1~2 FPS,所以测试开场会:关阴影、降 pixelRatio、整体隐藏植被。 注意物理障碍物独立于渲染,所以撞树类测试照样有效 —— 但任何依赖帧率的断言都会失真(见 §5)。
2. 身份牌助手 _auth.cjs
所有需要登录的脚本都靠它。它做四件事:
| 函数 | 签名与作用 |
|---|---|
freshToken(name?) | 走 POST /api/auth/register 拿一张新身份牌 |
primeAuth(context, token) | 用 addInitScript 把 token 写进 localStorage['occ.token'],页面加载即"老玩家回归",跳过注册界面 |
preheatWorld(token, worldId) | 两步裸 WS hello(先声明 lastWorld='home',250ms 后再声明目标世界),绕过服务端落点续用规则 —— 否则新号一律醒在家园星,一大批战场断言会全灭 |
authedPage(browser, viewport) | 一步到位:建 context + 注入身份牌 + 开页,返回 { page, context, token } |
模块导出:{ BASE, freshToken, primeAuth, authedPage, preheatWorld } —— 没有 closeAll 之类的收尾助手,收尾自己写。
⚠️README.md里"截图类脚本look/hpshot/shot不需要身份牌"的说法已过时:这三个脚本源码里都在用authedPage。
3. 脚本清单
3.1 基础设施 / 冒烟 / 诊断
| 脚本 | 测什么 | 身份牌 | 截图 |
|---|---|---|---|
smoke.cjs | 无头冒烟:无 JS 报错、WebGL 可用、跑动 / 转视角 / 缩放 | 是 | 是 |
diag.cjs | 物理诊断:按 W 前进逐秒打印车体状态(排查"车不动") | 无 | 否 |
api.cjs | 后端 REST 回归(账户 / 世界状态 / 随机出生 / 落盘),不开浏览器 | 自注册 | 否 |
load.cjs | 并发压测:裸 WS 傀儡(默认 20 个 / 15s)+ HTTP 探活延迟 | 自注册 | 否 |
perf.cjs | 性能标尺:draw calls / triangles 等指标 | 是 | 否 |
3.2 账户 / 会话 / 世界规则
| 脚本 | 测什么 |
|---|---|
account.cjs | 注册名片 → 进作品 → 打碎上报 → 刷新同步 → 牺牲重生 |
auth_split.cjs | 登录页(仅Email + Password)与角色创建页拆分 |
authdiag.cjs | 注册流程诊断 |
single_session.cjs | 单账号单会话:后登录踢前面的(纯 WS 协议) |
single_session_ui.cjs | 客户端被踢后的反应(不再自动重连) |
posrule.cjs | 位置保留 / 重置的落点续用规则 |
resume_pos.cjs | 落点续用回归(在线心跳刷 lastSeenAt / 强杀进程后仍续用坐标)—— 老大"玩一会儿被刷回原点"那条 |
warp.cjs | 客户端世界切换:加载页文案 + 家园星参数 + 星门过渡层 |
world.cjs | 双星球隔离:两玩家同服,家园互不可见、战场互相可见 |
3.3 战斗 / 物理 / 破坏
| 脚本 | 测什么 |
|---|---|
combat.cjs | 战斗物理冒烟:准星构图、弹药规则、撞树不穿模、刚体数 |
hp.cjs | 血量分档、血条计时、炸碎 |
hpshot.cjs | 血量系统外观实拍 |
break.cjs | 纸飞机真实撞击打碎树(覆盖 cannon collide 回调路径) |
crash.cjs | 穿模专项:全速撞树逐帧记录最小距离 |
damage_numbers.cjs | 伤害飘字 |
effects_debris.cjs | 击毁碎片 + 炮口火光 |
hit_flash_score.cjs | 被击中白闪 + 击杀播报契约(不再带积分) |
score.cjs | 击杀统计 / 连杀头衔(积分下线版回归) |
lat.cjs | 网格形状纬度对比(赤道 vs 高纬) |
planes_heading.cjs | 纸飞机机头朝向 vs 速度方向(dot ≈ +1) |
3.4 机甲 / 动作 / 远程表现
| 脚本 | 测什么 |
|---|---|
motion.cjs | 动作状态机 idle / walk / run / fly / attack + 骨角度 + 碰撞体 |
features.cjs | 偏色染色 / 物理碰撞 / 深水阻挡 / 默认镜头距离 |
thruster_feet.cjs | 飞行推进器粒子:左右脚各一股 |
look.cjs | 默认视角 / 网格层 / 攻击特效外观 |
shot.cjs | 攻击特效实拍图(满配画质) |
remote-view.cjs | 远程玩家表现:跑 / 飞 / 攻击、环绕小飞机、平滑度 |
remote_flying.cjs | 远程玩家飞行前倾等表现 |
3.5 世界 Boss「天罗」
| 脚本 | 测什么 |
|---|---|
boss.cjs | 契约:全星球唯一实例、警戒转姿态 |
boss_aim.cjs | 双炮塔各自瞄准 + 腿部支撑(膝角度) |
boss_diag.cjs | 可见性诊断(世界坐标 / 相机距离 / 视锥 / 脚落点) |
boss_preview.cjs | 模型独立预览页 |
boss_view.cjs | 视觉验收:近景 / 远景 / 技能齐射 / 爆炸 |
boss_actions.mjs | 纯逻辑单测:直接 import server/world-boss.js 喂假数据 tick,不需要服务 |
radar_boss.cjs | 雷达上 Boss 指示(紫色菱形) |
3.6 采集 / 建造 / 生产 / 科技 / 背包
| 脚本 | 测什么 |
|---|---|
gather.cjs | 采集链:资源场 → 节点 → 按 E → 服务端结算 → 背包 |
mining.cjs | 采矿机制:手臂前伸 + 光束 + 粒子 |
build.cjs | 建造模式:双击 → 网格吸附 → 放置扣物 → 重叠拒绝 → 拆除 |
sim.cjs | 基地结算(能源 / 生产 / 物流,纯 WS 观察 baseState) |
m3ui.cjs | 客户端可视化:无人机飞行 + 基地 HUD |
labui.cjs | 研究所 UI + 截图 |
tech.cjs | 科技树:解锁链路 + 建筑门槛 |
tech_battle.cjs | 战场科技线(节点总数 35 / 战场线 26);含 shop/buy 返 404 断言 |
bag.cjs | 背包协议:登录下发 items + 快照、容量堆叠、使用落点 |
guide_docs_check.cjs | 新手引导面板(16 步 / 当前步高亮)+ 「作品文档」按钮 + /docs/ 三个页面 200 |
guide_drive_check.cjs | 引导第 1 步 drive 全链路(Shift+W 跑满 50 米 + 拖视角 → 客户端上报 → 服务端推进到 chop) |
guide_drive_probe.cjs | 诊断:打印 moved 逐帧增量与单帧最大位移(确认传送闸门生效) |
tasks.cjs | 科技研究 / 随身制造的时间(纯 WS):开始即扣材料、progress.leftMs、同类不能并行、两槽可并行、到点自动结算、产物进背包 |
tasks_ui.cjs | 右上角任务面板(浏览器):挂在 #actions 里、初始"空闲"、开始后显示名字+进度条、条宽在走、到点回"空闲" |
guide_gate.cjs | 星门前置:没建星门时 travel battle 被拒(带 needGate)、放进基地后放行、回苗圃永不被挡、引导表新增 gate 步 |
guide_pos_check.cjs | 引导面板位置:在雷达下方、左边缘对齐、展开后整列仍在视口内、折叠三角点得动(出 _logs/guide_pos*.png) |
boss_crossworld.mjs | 跨世界伤害:战场/苗圃两组同时站在 Boss 同一点,对照组必须掉血、苗圃组一次都不许掉(纯 WS,不用浏览器) |
glb_probe.cjs | 瘦身版 GLB 的浏览器加载:4 个代表模型走 .opt.glb、尺寸正常、贴图在、复刻烘焙流程后包围盒一致(页面 public/_glb_probe.html) |
bag_ui.cjs | 背包 UI 截图 |
context_menu.cjs | 右键菜单屏蔽(canvas 拦截、输入框放行) |
3.7 社交 / UI / 载具 / NPC
| 脚本 | 测什么 |
|---|---|
social.cjs | 雷达 / 名称标签血条 / 战绩面板 / 在线列表 / 机甲 5 色 / 随机Nickname |
vehicles.cjs | NPC 战斗车 / 精英坦克真模型接入 |
vehicles_view.cjs | 载具视觉 + 炮塔随玩家方位转动 |
npc-respawn.cjs | NPC 补位机制回归(纯 WS) |
3.8 美术资产 QA(不开作品本体)
| 脚本 | 测什么 | |
|---|---|---|
mecha_visual_qa.cjs | 机甲设定集页面 QA(碎图 / 横向溢出 / 灯箱 / JS 报错,file://) | |
models_low_qa.cjs | 减面 GLB 截图对比 | |
models_parts_sheet.cjs | BANG 分件逐个渲染接触表(命名对照) | |
models_view_diag.cjs | GLB viewer 无法就绪的原因诊断 | |
models_view_qa.cjs | 分件 viewer:正常 / 爆炸 / 孤立视图 | |
theme_shot.cjs | 星球主题实拍(人工看图用):`node tools/e2e/theme_shot.cjs battle\ | home → _logs/theme_<world>_{ground,sky}.png`。它同时会把活的场景里的雾色 / 半球光 / 平行光 / 主题 id / 半径打出来,比只看图硬 |
3.9 星球视觉主题
theme_shot.cjs 的两个坑(都在脚本注释里写死了):
- 🔴 URL 必须带
?w=<worldId>。不带的话world.js会把"声明世界"兜底成battle,
而服务端 hello 的规则是"声明合法就听客户端的" ⇒ 不管怎么预热,浏览器一律落焦土。 拍 home 时中过这招:两张"苗圃"图其实还是焦土。
- 🔴 拍天空要先藏掉机甲整棵子树。相机是绕机身中段转的,只抬
rig.pitch只会变成
"从机甲两腿之间看天空";抬过头还会把机壳糊进画面左下角,看着像"天空变蓝了"(误判过一次)。
对生产环境实拍:加 OCC_BASE=http://<生产>:8090 即可(同一条命令,无需改脚本):
OCC_BASE=http://[服务器地址]:8090 node tools/e2e/theme_shot.cjs battle
⚠️ 三条只在这个场景才会遇到、但很容易误判成"生产坏了"的事:
_auth.cjs的BASE读的是OCC_BASE,页面地址也必须跟着它走。
脚本里曾把地址写死成 127.0.0.1:OCC_BASE=生产 时 token 注册在生产、 浏览器却开本地页 ⇒ 本地拿生产 token 去鉴权必然 401 ⇒ 停在登录界面、 __OCC__ 永不出现。看着像发布把生产搞坏了,其实只是地址写死。
- 用 curl 手测鉴权端点是
x-occ-token头、不是Authorization: Bearer
(写错会拿到 401 invalid token,同样是"假故障")。/api/me 不存在,是 /api/auth/me。
- 失败时脚本会把最后看到的状态打出来(
occ/keys/auth)——
auth:true 就是"停在登录界面",一眼能区分"没登录"和"页面没加载"。
4. 关键判据(挑几条代表性的)
combat.cjs:
| 断言 | 期望 |
|---|---|
| 主角屏幕位置 | y > 0.55(中心偏下) |
| 连点后最小弹药 | 0(三架同时在外) |
| 连开 5 枪 | [true, true, true, false, false] |
| 撞树全程最小距离 | > 树半径 + 车碰撞半径(不穿模) |
| 物理世界刚体数 | 300~900(证明流式加载生效,不是全部) |
采集 / 采空链(triple_check.cjs 一类):
| 断言 | 期望 |
|---|---|
| 提示条三态 | 开关 on / off / 移开后文案各不相同 |
| 采空 | 采空表有该格 · 爆炸次数恰为 1 · 光束熄灭 · aim 为 null |
| 随身制造 | 27 条配方 · 能造 1 条 · 材料扣对且产物进包 · 未解锁条数 = 需要科技门槛的建筑配方数 |
| 未解锁制造 | 提示正确 且没有发包 |
5. 写测试 / 判读结果时的坑(都是踩出来的)
| 坑 | 说明 |
|---|---|
| 软渲染导致假红 | 帧间隔 p50 可能到 900ms;按"每 170ms 采样 + 按帧号去重"的策略一次出击只能采到 3~5 个点,抓不到"最近距离"这类瞬时量。要改成页面内 rAF 记录状态迁移再取结果 |
| 合成 keydown 必须配对 keyup | dispatchEvent(new KeyboardEvent('keydown',{code})) 只在 held 里没有该 code 时算 justPressed。不发 keyup,code 会卡在 held 里,之后所有按键判断对它一律失效 → 误报"按键打不开" |
page.keyboard.press 是有效的 | 能驱动作品按键(实测按 E 翻转了采集开关)。曾经记的"本机不驱动"是错的 |
| 探针要"页面刚就绪就装" | 装在采集阶段时,客户端可能在 setup 那 2.5 秒里就把矿抹掉、爆炸早放完了 → 爆炸次数 恒为 0,假阴性 |
| 别用宽泛的子串匹配 WS 消息 | s.includes('"craft"') 会误判(site/baseState 里也有 craft 字段)。要卡在 t 上:/"t"\s:\s"craft"/ |
| 首屏后还会整页重载一次 | world.js 的"切世界 = 重载":客户端默认声明战场,服务端按落点规则给了别的世界,两边一不一致就重载对齐参数。只等 __OCC__ 就断言会撞 Execution context was destroyed, most likely because of a navigation。要等重载落地再 waitForFunction(__OCC__)(gather.cjs 之前一直挂在这上面) |
| 瘦身版 GLB 必须在浏览器里单独验一次 | 建筑/矿产/无人机是按需加载的(建到哪栋加载哪栋),普通 smoke 只加载机甲 ⇒ EXT_meshopt_compression(要挂解码器)与 KHR_mesh_quantization(要配 dequantizeGeometry)两条路径根本走不到。用 glb_probe.cjs |
量化过的几何不能直接 applyMatrix4 | KHR_mesh_quantization 下 position 是 Int16Array(normalized),而 setXYZ() 是裸写 ⇒ 顶点被截断到 ±1。改顶点前先 dequantizeGeometry()(实测不调的包围盒偏差:建筑 46% / 矿产 39% / 无人机 17%) |
| 比 GLB 包围盒要取场景空间 | quantize() 把反量化尺度烘进节点变换,accessor 里的值是"铺满 int16 全域"的 ±1 ⇒ 直接比会得出"偏差 26%~125%"的假警报。用 getBounds(scene) |
⚠️ travel 成功回的是 traveled,失败才回 travelResult | 等错消息名会一路等到超时(踩过)。traveled 里带新世界的 NPC / Boss / 掉落物快照 |
| ⚠️ 引导是按顺序推进的 | evalGuide 里"前一步没完成,后面一律不判"(if (!ok) break)。写用例时别指望"只做第 8 步"就能让第 8 步变成 done |
| ⚠️ 等消息的辅助函数别用数组下标做超时摘除 | 每条消息都会 splice 掉一个 waiter,下标立刻错位 ⇒ 某个已结算的旧定时器到点时会摘掉别人,那个等的人永远不返回(实测:脚本静默挂住)。要按对象引用摘 |
| ⚠️ 涉及世界 Boss 的用例要先确认 Boss 活着 | Boss 击毁后有 120 秒空窗,上一个用例刚把它打掉时,worldSet.boss.hp = 0 ⇒ applyHit 直接 ok:false,整套"打 Boss / 击毁 / 空窗倒计时"全红(boss.cjs 的 120 → 120 就是这个)。要像 tools/e2e/boss_crossworld.mjs 那样轮询等它活过来 |
⚠️ 新账号第一次 hello 必落家园星 | 服务端"落点续用规则"不接受新账号的世界声明。凡是要去战场/NPC/Boss 的用例(score.cjs / boss.cjs)都必须先 preheatWorld(token, 'battle') 并给 URL 带 ?w=battle。authedPage 不预热,用它等于把玩家丢在苗圃 —— boss.cjs 曾因此 9 项全红 |
⚠️ build.cjs 的"限量"三连断言不稳 | {c1,c2,commandLeft} 三项在原始代码上同样是红的(一次跑出 3 项失败),与后来改动无关;服务端扣物本身是正常的(WS 直连验过:放成功扣 1、被拒不扣)。别把它当回归信号 |
⚠️ gather.cjs 仍有 1 项红灯(其余已修) | 2026-10-16 修掉 3 项真问题:1探针与浏览器不在同一个世界(裸 hello 的新账号被落点规则按在苗圃 R=520,浏览器却在焦土 R=820 ⇒ 同一个 (i,j) 是两片地方);2探针步长 3 踩不到堆心(只有 i%5==0 && j%5==0 才有可见矿堆);3E 早在 10-15 就从"按住"改成了"按一下开关",用例还在 down/up;提示条还要先瞄准(把矿堆投影回屏幕再 mouse.move)。 |
剩下那条("采到的正是该矿种对应的物品")未定根因,已排除的:世界一致 ✓、堆心 ✓、瞄准 ✓(提示条会显示"连续采集中 · 再按 E 停止")、服务端探针有矿 ✓。插桩(把 net.send 包一层记 t)看到 gather 一次都没发出去、mining.hit 恒为 null ⇒ 问题在客户端"要不要采"这条判定上,不在服务端。线索:main.js 的提示条用自己那套 pickAimTarget,而"现在该不该采"用的是 mining.aimInfo() —— 两套瞄准计算,且 main.js 有句 if (input.mineToggle && !onRes) input.setMineToggle(false),onRes 为假时开关会被悄悄关掉。下次从这里查。 | 已知问题,与代码无关:服务端探针(新号落家园星)与浏览器(声明战场)不在同一颗星球,网格参数不同 ⇒ 客户端按自己那张表算不出服务端说的那格矿。断言项是"客户端算出的矿种/储量与服务端一致"及其下游 |
#toast 全局限定一条 | 等久了会读到别人的播报。要抢第一时间以 80ms 轮询抓目标那条 |
| 测"鼠标移开资源"必须先扫出无目标位置 | 随手移到屏幕上方,那里刚好还有一棵树命中,aim 仍是 flora → 看起来像"没断开"的 bug |
| 别在测试里另写坐标换算 | _logs/gather_tip_check.cjs 内联了一份 cellToDir,与资源Search对不上,导致扫不到矿。去 import 共享模块 |
补刀不要现场重读 aimInfo() | 那一瞬间 aim 可能是 null(相机稳一下 / 矿刚被摘),会静默不发包 → 看着像服务端问题。既然上一行已拿到确定的 (i,j),直接用 |
| 服务端矿脉记账是内存 Map | 同一堆跑第二次必然走 ok:false 路径;要测"正常采空"得先 python scripts/svc.py restart |
6. 写一条新测试的模板
// tools/e2e/yourfeature.cjs
const fs = require('fs');
const { chromium } = require('playwright');
const { authedPage } = require('./_auth.cjs');
const SHOTS = 'D:/OCC-Game/_logs/shots';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
fs.mkdirSync(SHOTS, { recursive: true });
const bad = [];
const browser = await chromium.launch({
headless: true,
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--disable-gpu-sandbox',
],
});
const { page, context } = await authedPage(browser, { width: 1440, height: 900 });
page.on('pageerror', (e) => bad.push(String(e)));
// 1) 等世界就绪(用 __OCC__ 轮询,别用固定 sleep)
// 2) 用 __OCC__ 读状态、构造输入
// 3) 断言 → console.log 结构化 JSON
// 4) 截图落 _logs/shots/
console.log(JSON.stringify({ BAD: bad.length }, null, 2));
await context.close();
await browser.close();
})();
要点:
- 断言要 print 成结构化 JSON,方便前后对比,而不是只 print PASS/FAIL。
- 失败要能区分"代码错"和"用例错":本项目反复出现的教训是"用例写错 → 报假 bug"。
- 关键路径做红-绿验证:故意把修复回退,确认用例真的会红(否则它可能一直在假绿)。