OCC 星球漫游 · 接口与协议
服务端对外只有两类接口:HTTP REST(16 条)和 WebSocket(客户端入站 34 种、服务端出站 50 种)。 这份文档是代码现状的完整清单,用来替代散落在代码注释里的口径。
核对基线:2026-10-15 23:53,server/server.js(2563 行)。
0. 通用约定
| 项 | 值 |
|---|---|
| 监听 | 127.0.0.1:8790(PORT 环境变量可覆盖;生产由 nginx 反代到 8090) |
| 鉴权头 | x-occ-token: [已隐藏];/api/auth/character 与 /api/auth/logout 也接受 body 里的 token` |
| WS 路径 | /ws,maxPayload = 8192 |
| 默认世界 | battle(老客户端的向后兼容默认值) |
| 数据目录 | _data/(OCC_DATA 环境变量可覆盖) |
三类容易记错的口径,先说清楚:
/api/auth/register是无Password访客注册(填名字就发身份牌);/api/auth/login是 email + password,Email不存在即注册(回包带created: true)。两条都过pubProfile()剔除passwordHash / passwordSalt。- 🔴
POST /api/shop/buy已删除,返回 404(不是 400,也不是 410)。积分商城整体下线,WS 的buy分支也已删除。 - 🔴
/api/me/wallet里没有score、没有ledger。字段是{upgrades, derived, stats, kills, deaths}。
1. REST 接口(16 条)
| # | 方法 路径 | 入参 | 返回要点 | 鉴权 |
|---|---|---|---|---|
| 1 | GET /api/health | — | {status, service, uptimeSec, players, store:{users,worlds}, ts} | 无 |
| 2 | GET /api/world/config | — | {config, avatars};Cache-Control: max-age=300 | 无 |
| 3 | GET /api/stats | — | {uptimeSec, players, store, memoryMB, node, serverTime} | 无 |
| 4 | GET /api/players | — | {players:[{id,name,dn,avatar,color,kills,streak,title,hp,maxHp,dead}]} | 无 |
| 5 | POST /api/auth/register | {name,avatar,vehicle,color} | {token, profile};访客账户,无Password | 无 |
| 6 | POST /api/auth/login | {email,password,...} | {token, profile, created, needsCharacter};Email不存在即注册 | 无(IP 限流) |
| 7 | POST /api/auth/character | token + {name,avatar,vehicle,color} | {ok, profile};失败 401 | 需要 |
| 8 | POST /api/auth/logout | token | {ok}(只销毁会话) | 需要 |
| 9 | GET /api/auth/me | header token | {profile};失败 401 invalid token | 需要 |
| 10 | POST /api/auth/profile | token + 字段补丁 | {profile};改色会同步在线 socket | 需要 |
| 11 | GET /api/me/wallet | token | {wallet:{upgrades,derived,stats,kills,deaths}} | 需要 |
| 12 | GET /api/world/destroyed | ?seed= | {seed, ids, respawnSeconds};顺带广播到期重生 | 无 |
| 13 | POST /api/world/destroy | {seed, ids[]} | {ok, added};广播 destroy;token 只用于播报 | 无(token 可选) |
| 14 | GET /api/world/npcs | — | {npcs:[{id,name,color,dir,hp,maxHp,elite}], speed} | 无 |
| 15 | GET /api/world/spawn | ?seed=、random=1 | {seed, random, spawn:{lat,lon,dir,elevation,biome,waterLevel,snowLevel}} | 无 |
| 16 | POST /api/world/spawn | {seed} | 同上(恒 random:true) | 无 |
补注:
- 登录限流:IP 令牌桶 10 次 / 分钟、容量 20、桶表上限 2048。scrypt 单次 100~300ms,不限流会被打满。
derived内容:damage / maxHp / speedMul / droneCount / regen,且是两源叠加(老profile.upgrades等级 + 科技树效果)。
1.1 静态资源路由与缓存策略
| 路径 | 缓存 | 说明 |
|---|---|---|
/ | no-cache(协商) | 作品入口 |
/public | 5 分钟 | |
/docs | 5 分钟 | 文档站 |
/assets/mecha/model | 带版本号 .glb 30 天 immutable,固定名(rig.js 等)降回 1 小时 | 只暴露这一层,避免整棵 assets 树可下载 |
/assets/icons | 1 小时 | |
/assets/buildings、/assets/resources、/assets/drones、/assets/models | 7 天(不加 immutable) |
🔴public/下的 JS/CSS 绝不能加immutable:文件名没有内容 hash,加了之后老用户会一直跑旧代码。
2. WebSocket:客户端 → 服务端(33 种)
消息统一用 {t: '<类型>', ...}。
2.1 会话与背包(18 种)
t | 关键字段 | 服务端处理 |
|---|---|---|
hello | token, worldId? | 校验 token(无效回 kicked 并 close 4001);落点续用规则(离线 < 2 小时且世界没变 → 回到上次位置;否则落基地门口/出生点);补发 worldSet/wallet/bag;广播 join |
state | p, n[3], h | 令牌桶限流 20/s,超限静默丢帧;经 relayState 按世界 + 距离分频转发 |
hpSync | hp | 只记账(clamp 到 [0,maxHp]),不广播 |
respawn | — | 服务端补满血,回 hp |
bagUse | slot | 装装备 / 装弹 / 用消耗品(回血与 buff 在这层施加);回 bagOp |
bagMove | from,to | 拖拽合并 / 交换 |
bagSplit | from,to,n | 拆分 |
bagDrop | slot,n | 主动丢弃 = 掉在自己脚下 |
bagTidy | — | 一键整理 |
bagExpand | — | +8 格(上限 64) |
bagSwap | dir | 背 ↔ 仓一键转移;必须先过 nearDepot 校验 |
bagStore | dir,slot,item,n | 单件存取;同样要 nearDepot;附带回 storage |
bagQuick | quick,slot | 快捷栏绑定 |
bagUnequip | equip | 卸装备(背包满时不卸) |
lootPick | ids[](≤24) | 球面距离 ≤22(按玩家所在世界半径换算);部分拾取也写入,回 lootGot,广播 lootDel |
travel | to | 星门切换世界;战场 → 家园需脱战 5s + 30s 冷却;回 traveled |
guideSignal | id | 新手引导的"客户端上报信号";白名单只有 drive / craft / crystal 三个,命中就回 guide。⚠️ 实际只有前两个由客户端发:crystal 已改为服务端在 lootPick 里自己记(客户端从未实现发送点,曾导致引导第 10 步永远卡死) |
site | — | 回 site + baseState |
2.2 战斗(5 种)
t | 关键字段 | 服务端处理 |
|---|---|---|
hit | target, damage | 玩家互击;damage clamp 1~9;目标死亡 → 背包掉 50%、掉落物落在尸体处、记杀 / 记死、广播 kill |
fire | action,dir,from,speed,ttl,graze | 表现透传(字段白名单),不参与结算 |
npcHit | id, damage | clamp 1~9;广播 npcHp;击毁 → npcDown(可能 npcRespawn)、记 npcKill、掉落 |
bossHit | damage | clamp 1~9;广播 bossHp;击毁 → bossDown、掉落、广播 kill |
flora | list[[x,y,z,r]…] | 客户端上报障碍物供 NPC 避障(按 6° 纬度带分桶);不结算、不落库 |
2.3 采集与基地(11 种)
t | 关键字段 | 服务端处理 |
|---|---|---|
gather | i, j | 采矿石;服务端自己算矿种与储量 × 堆内块数;回 gatherResult |
gatherFlora | kind, dir[3] | 采植被;服务端反解格、按格记账、距离 ≤46 |
resourceProbe | i,j | 只读诊断探针,回 resourceProbeResult |
buildPlace | itemId,gx,gy,yaw | 仅苗圃;科技门槛 + 背包扣除 + 重叠 + 限量 |
buildDel | id 或 gx,gy | 仅苗圃;建筑本体 + 库存全部放回背包(背包放不下则拒绝);星门不可拆 |
bldMine | id,kind | 设定采矿机的采集目标(copper / iron / tin / stone / oil;空串 = 取消 ⇒ 停机);要站在建筑旁;回 bldResult + baseState |
bldStock | id,dir,slot,item,n | 背包 ↔ 建筑库存;要站在建筑旁;回 bldResult + baseState |
bldRecipe | id,recipe | 点名该建筑的配方(空串 = 自动) |
ioSet | id,slot,mode,item,cap | 物流站槽位设置(可 clear) |
craft | recipe | 随身制造(J 键):材料从背包扣、产物进背包 |
tech | — | 回 techs 全树 |
techUnlock | id | 解锁;成功 → buildable + 刷新 wallet + 同步血量账本(hull* / t3_regen 会回满血) |
3. WebSocket:服务端 → 客户端(50 种)
3.1 会话与背包(17 种)
welcome(含 id/color/players/others/worldId/worlds/resumeDir,npcs 恒为空、boss 恒 null)、worldSet(hello 后按世界补发 npcs/boss/faceDir/dayCount)、wallet、bag、join、leave、kicked(先发消息再 close 4001)、toast、hp、hpSync、damaged、respawn(只含物件重生 ids)、buff、bagOp、bagFull、storage、guide
bag快照携带items/recipes/bldRecipes/site/techs—— 它是客户端获取全部静态定义清单的唯一入口。 🔴 客户端禁止自己按r.by === type推断配方归属,口径只有服务端下发的bldRecipes。
guide(新手引导):{steps[], done[], step, finished, total, justDone[]}。steps是 16 步的文案表(id / title / hint / goal / line / key),justDone是这一次刚完成的那几步 (客户端据此放完成动画)。进度由服务端推进(server/guide.js只前进不后退), 客户端只做两件事:渲染 + 上报drive/craft/crystal三个它看不到的信号。 推送时机:hello(补发)、每次tickGuide()后(采集 / 制造 / 建造 / 击杀 / 切星球 / 解锁科技…)、以及收到guideSignal时。
3.2 位姿与战斗(3 种)
t | 说明 |
|---|---|
state | 中继他人位姿,附带服务端 hp/maxHp/dead/kills/streak/title/dn |
fire | 别人开火的弹道表现 |
kill | 击杀播报,kind 取 player / npc / boss,带 killer/victim/streak/title(npc 带 elite) |
3.3 NPC 与 Boss(12 种)
npcMove(紧凑 [id,x,y,z] 数组、3 位小数、按 renderDist 裁剪、只发战场星、约 3.3Hz)、npcHp、npcDown、npcRespawn、shell(精英炮弹 p/v/up)、bossMove(仅 battle)、bossHp、bossDown、bossRespawn、bossFire(kind=cannon)、bossMortar(p/land/flight/delay)、bossBlast(p/r)
3.4 世界与基地(剩余 18 种)
destroy(ids + by)、lootAdd、lootDel、lootGot、gatherResult(带 i/j 与 code:'depleted')、resourceProbeResult、baseState(energy/speed/day/storage/craft/recipes/flights,1Hz,只回本人)、baseArrived、site、buildResult、buildable、bldResult、ioResult、techs、techResult、craftResult、travelResult、traveled
gatherResult的code:'depleted'是机器可读的关键字段:只回中文 error 客户端没法可靠识别,会出"幽灵矿"。
4. 出站频率与背压
| 机制 | 值 | 说明 |
|---|---|---|
state 入站限流 | 20/s | 超限静默丢帧,不回错误 |
relayState 降频 | 800 单位内每条都发,远处每 3 条发 1 条 | 🔴 降频值必须与外推窗口配套(EXTRAPOLATE_MAX = 0.45s) |
npcMove | 约 3.3Hz(100ms tick × 3) | 只发战场星 |
baseState | 1Hz | 只回本人 |
| 快照背压 | 缓冲 > 1MB 直接丢 | sendSnapshot 保护,避免慢客户端拖垮进程 |
| WS payload | 8192 | 超了直接断 |
5. 定时器全景
| 定时器 | 周期 | 内容 |
|---|---|---|
npcTimer | 100ms | NPC + Boss tick;每 3 拍广播 npcMove/bossMove;炮弹与 Boss 事件即时广播 |
simTimer | 1000ms(延迟 500ms 起,与 npcTimer 错相) | runBaseSim,遍历在线玩家的家园基地 |
regenTimer | 1000ms | 装甲自修复回血(只加不减),广播 hpSync |
heartbeat | 30s | ws ping/pong + sweepLoot + sweepDepleted |
respawnTimer | 15s | 物件重生清理 + 广播 respawn |
| store 落盘 | 10s | 强制 save(true) |
相位错开是刻意的:三个定时器分散在 0 / 500ms,避免周期性延迟尖刺叠在一起。
6. 存档结构(_data/state.json)
顶层三个 key:
| key | 结构 | 说明 |
|---|---|---|
users | { [Email或token]: profile } | 访客用 token 作 key,正式账号用Email |
sessions | { token: [已隐藏] } | 单账号单会话:同Email新登录会作废旧 token |
worlds | { [seed]: { destroyed: { [物件id]: 时间戳 } } } | 只记"被打掉的 id",不存坐标 |
profile 的字段分组:
| 分组 | 内容 |
|---|---|
| 身份 | id, email?, isGuest?, passwordSalt/Hash?, characterSet?, name, avatar, vehicle, color, createdAt |
| 冻结保留 | score、ledger[](积分系统下线后只读不写,为老存档兼容,一个字都不能删) |
| 能力 | upgrades{weapon,speed,hull,drones}(老字段,仍生效)、stats{kills,deaths,npcKills,bestStreak,streak}、kills[]/deaths[](上限 30 条) |
| 资产 | bag、storage |
| 引导 | guide{step, done[], signals[], seenWorlds[], at, finished}(只前进不后退;signals 只存 drive/craft/crystal 三个客户端上报信号) |
| 世界 | worlds.home(建筑与科技都在这里面)、starterGiven、gateGiven、lastWorld、lastSeenAt、lastPos |
子结构:
| 结构 | 形状 |
|---|---|
profile.bag | {size, slots[64], quick[10], equip{weapon,tool,armor,engine}, ammoPref} |
profile.storage | {slots[500]}(前端按物品聚合展示) |
profile.worlds.home | {v, seed, slotIndex, buildings[], nextId, inventory{}, energy{stored,capacity}, tech{unlocked[],points{}}} |
profile.guide | {step, done[], signals[], seenWorlds[], at, finished};老存档读时补默认值(ensureGuide),零迁移 |
buildings[] 条目 | {id, type, itemId, gx, gy, yaw, level, storage{}, io[], builtAt} |
6.1 落盘策略
- 改动走
markDirty()→save(),节流 10 秒(此前是 2 秒同步写,5000 用户 stringify 要 144ms)。 - 定时器每 10 秒强制落盘一次 ⇒ 崩溃最多丢 10 秒数据。
- 写入异步 + 原子:同步
JSON.stringify取快照 → 写.tmp→rename覆盖;同时只允许一条写在飞。 - Sign out(SIGINT / SIGTERM)走同步写 +
renameSync,让在飞的异步写放弃。 - 存档损坏:改名
state.json.broken-<时间戳>,从空状态启动。
7. 易错口径速查
| 场景 | 正确做法 |
|---|---|
8 种客户端的 p(位置) | 是方向不是坐标;距离一律算弧长 |
| 落点规则判据 | 看"世界有没有变"(ws.worldId === profile.lastWorld),不是客户端 claimed |
lastPos | 必须与 lastWorld 配对;换世界立刻作废;只有真拿到位置才覆盖 |
| 广播 | 必须按世界过滤(broadcast 与 relayState 是两条独立通道,都要过滤) |
| 苗圃是私有世界 | 服务端有三处要过 sameWorld():broadcastState、relayState、peersIn()。漏一处就会看到别人降落在你的苗圃(踩过) |
| NPC / Boss 的玩家名单 | npcTimer 里的 dirs 是它们判定"哪些玩家"的唯一来源,必须只收 NPC_WORLD(见「跨世界不能造成伤害」) |
| 掉落物 | 带 world 字段;travel / lootPick / sweepLoot 都要按世界过滤(否则苗圃丢的东西会画到战场同一坐标) |
| 建造 | buildPlace / buildDel 服务端强制 ws.worldId === 'home',否则回「不能在该星球进行建造物品和建筑」 |
| 引导进度 | 判定放服务端(server/guide.js),客户端只渲染 + 上报 drive/craft/crystal;白名单外的 id 一律忽略 |
| 跨世界不能造成伤害 | 三处都要挡:1 npcTimer 的玩家名单 dirs 只收 NPC_WORLD(喂给精英炮弹 / Boss 气炮 / 火榴弹 / NPC 追击目标)2 hit 过 sameWorld(ws, target) 3 npcHit/bossHit 校验 ws.worldId === NPC_WORLD。「客户端看不到对方」只等于可见性过滤,不等于发不出包 |
welcome | 不下发 npcs/boss,等 hello 后的 worldSet 补发 |
spawnLoot(at) | at 必须显式传掉落发生位置,不要退回 ws.lastDir |
| 单账号单会话 | 先发 kicked 再 close 4001,否则客户端重连互踢 |
| 异常处理 | uncaughtException / unhandledRejection 只记日志,不 process.exit(交给 PM2 健康检查) |