OCC 战斗与关卡 · 开发规格:数据与协议 v1
后续内容方案 的实现规格。设计稿回答"做什么、为什么", 本篇回答"字段叫什么、消息怎么发、函数签名长什么样、怎么断言它对"。
规矩:开发按本篇写代码,不按设计稿猜字段。设计稿里改了意图,先回本篇改字段,再动代码。 基线:2026-10-17 22:00 的代码。
0. 阅读顺序
| 你要做什么 | 先看 | 再看 |
|---|---|---|
| 排期 / 分工 | 开发实施方案 的 WBS | 本篇 §1 / §9 / §11 |
| 写服务端任务系统 | §2 节点表 → §9 协议 → §11 签名 | 方案 §5 / §6 |
| 写地貌 | §8 style 表 | 方案 §13(尤其是 §13.6 的五个坑) |
| 写装配面板 | §5 模块表 → §1.2 槽位 | 方案 §9 / §10 |
| 写测试 | §13 断言矩阵 | — |
1. 存档字段
1.1 新增到 profile(server/store.js)
读取时一律补默认值(沿用 ensureWorlds 那套自愈做法,不要求老玩家重来):
profile.missions = {
cleared: {}, // nodeId → { at:ms, bestThreat:int, bestMs:int }
threat: {}, // nodeId → 已解锁的最高威胁等级(默认 1)
active: null, // 进行中的远征,断线续用;null = 没有
diveBest: 0, // 深潜最高波次(展示用)
};
// active 的形状(只在远征期间非 null)
profile.missions.active = {
nodeId: 'battle_sweep_1',
worldId: 'battle',
threat: 1,
startedAt: 1792300000000,
goal: { kills: 12 }, // 起始目标快照(改表不影响进行中的远征)
progress: { kills: 4 },
bossHp: null, // 首领节点用
nestHp: [120, 120, 120], // 破袭节点用
extractOpen: false,
extractAt: 0, // 撤离窗口开始的服务器时间
};
profile.blueprints = []; // 已解锁的模块 id(永久凭据,拾取即写)
🔴 active 必须能断线续用:玩家在战场被踢下线,重连回来要看到同一个进度。 判据是"世界没变 + 未超时"(沿用 lastWorld 那套),不是"重新开始"。
1.2 模块槽挂到 bag.equip(不新开存档树)
bag.equip = {
weapon: null, // 已有
tool: null, // 已有
armor: null, // 已有(仍不开放)
engine: null, // 已有(仍不开放)
modules: [null, null, null, null, null], // 新增:4 类槽 + 1 超载槽,长度固定 5
};
| 索引 | 槽 | 只接受 |
|---|---|---|
| 0 | 武器 | slot:'weapon' 的模块 |
| 1 | 防御 | slot:'defense' |
| 2 | 机动 | slot:'mobility' |
| 3 | 支援 | slot:'support' |
| 4 | 超载 | 任意一类,但 cost ×2 |
🔴 为什么不放 profile.loadout:bagWire 已经有一条成型下发通道,且槽位合法性校验 (sanitizeSlot)已经在 bag.js 里,挂在 equip 上等于白拿这两个。新开一条同步路径是纯浪费。
1.3 容量计算(唯一口径:服务端算,客户端只展示)
capacity = 12
+ (tech 含 t3_armor ? 2 : 0)
+ (tech 含 t4_overload? 3 : 0)
+ (tech 含 pow_core1 ? 2 : 0)
+ (tech 含 pow_core2 ? 3 : 0)
+ (套装 防御×2 ? 2 : 0)
used = Σ 模块.cost (超载槽里的 ×2)
capacity >= used 才允许装。服务端拒装时回 code:'overCapacity',客户端不许自己算完就装。
2. 任务节点表 MISSION_DEFS
新文件 server/missions.js。一张纯数据表 + 若干纯函数(可单测,不 import DOM / three)。
2.1 字段
{
id: 'battle_sweep_1', // 全局唯一,存档 key
world: 'battle', // 所属 worldId(节点共用这个世界的地形)
kind: 'sweep', // sweep|hunt|hold|sabotage|salvage|prime|dive
name: '清扫',
threat: 1, // 基础威胁等级
land: { lat: 18, lon: -40 }, // 🔴 固定着陆点(方案 §1.3 一直缺的那个)
goal: { kills: 12 }, // 按 kind 解释,见 §2.2
enemySet: 'battle_t1', // → §4
timeLimitSec: 0, // 0 = 不限时
extractSec: 20, // 撤离窗口需要站住的秒数
next: ['battle_hunt_1'], // 通关后解锁的节点(图上连线)
unlock: { need: [], tech: [], gate: 1 }, // need=前置节点, tech=科技 id, gate=星门等级
reward: {
first: { blueprints: ['w_spread'], crystal: 6 },
repeat: { crystal: 4, alloy: 0 },
},
}
2.2 七种 kind 的 goal 与完成判定
| kind | goal | 完成判定 | 服务端在哪拿数 |
|---|---|---|---|
sweep | {kills:12} | progress.kills >= goal.kills | onKill 里 +1(只算 enemySet 内的) |
hunt | {hunt:3} | 指定 3 个敌人 id 全被击杀 | onKill 比对 active.huntIds |
hold | {sec:180} | 信标血量 >0 且计时到 | tick() 累加,信标被打爆 → 失败 |
sabotage | {nests:3} | nestHp 全部 ≤0 | 服务端管巢穴血量(新实体) |
salvage | {crates:5} | 拾取 5 个货箱 → 送回信标 | 拾取走 lootPick,交付走距离判定 |
prime | {boss:'p_boss_scorch'} | Boss 血量 ≤0 | 复用 world-boss.js 的结算钩子 |
dive | {wave:1} | 永不完成,只能撤离 | 波次计时器(每 90s +1,敌人 +15%) |
2.3 焦土(T1)的 5 个节点 —— 首发内容
'battle_sweep_1': { kind:'sweep', name:'清扫', goal:{kills:12},
land:{lat:18,lon:-40}, next:['battle_hunt_1'],
reward:{ first:{blueprints:['w_spread'], crystal:6}, repeat:{crystal:4} } },
'battle_hunt_1': { kind:'hunt', name:'猎杀', goal:{hunt:3},
land:{lat:-12,lon:62}, next:['battle_hold_1','battle_sabotage_1'],
reward:{ first:{blueprints:['s_mark'], crystal:8}, repeat:{crystal:5} } },
'battle_hold_1': { kind:'hold', name:'坚守', goal:{sec:180},
land:{lat:44,lon:120}, next:['battle_prime_1'],
reward:{ first:{blueprints:['d_barrier'], crystal:10}, repeat:{crystal:6} } },
'battle_sabotage_1': { kind:'sabotage', name:'破袭', goal:{nests:3},
land:{lat:-48,lon:-96}, next:['battle_prime_1'],
reward:{ first:{blueprints:['w_boomerang'], crystal:10}, repeat:{crystal:6} } },
'battle_prime_1': { kind:'prime', name:'天罗', goal:{boss:'p_boss_scorch'},
land:{lat:0,lon:0}, next:[],
unlock:{ need:['battle_hold_1','battle_sabotage_1'] },
reward:{ first:{parts:['boss_core_scorch'], crystal:20, alloy:4}, repeat:{alloy:3} } },
⚠️ 着陆点坐标必须两端口径一致:land 写的是 lat/lon 度数, 服务端 cellToDir、客户端 latLonToDir 必须用同一套换算(沿用现有出生点那套)。 落地后要再做一次"最近陆地吸附"(findSpawn 同款螺旋Search),免得落进水里。
⚠️ 一个节点一个 land,不许随机:方案 §1.3 早就要求"3 个固定着陆点避免落地即被打", 这一项一直没做。现在按节点给固定点,顺带把它补上。
2.4 深潜(dive)的波次规则
波次 n:敌人在场数 = 基础 × (1 + 0.15(n-1)),血量 × (1 + 0.35(n-1))
间隔:每波 90s 或"场上清空"二者先到
结算:撤离时按波次给料,crystal = 2 + 3n,alloy = n>=5 ? 1 + floor((n-5)/3) : 0
失败:死亡 → 本趟归零(背包掉 50%,不给任何结算)
2.5 进度存储与恢复
| 时机 | 动作 |
|---|---|
start() | 写 active,extractOpen=false,记 startedAt |
| 每完成一个子目标 | 只改内存 + 推 missionGoal;不每帧写盘 |
每 10s(随 markDirty 节流) | 把 active 落盘(断线续用要用) |
settle() | 清 active,写 cleared / threat,发奖励 |
| 死亡 | 清 active,走失败分支 |
| 断线重连 | 若 active 存在且 worldId 匹配且 now - startedAt < 2h → 恢复;否则丢弃并提示"远征已失效" |
3. 敌人 archetype 表 ENEMY_ARCH
新文件 server/enemies.js。替代 npc.js 里写死的"普通 / 精英"两档。
{ id, name, hp, shield, speed, fixed, fly,
dmg, atk:'ram'|'shell'|'flak'|'shotgun'|'kamikaze'|'spawn',
range, cd, burst,
weak: null|'back'|'overload', // 弱点(背面 / 需过载)
resist: { blast:0.6, pierce:0.4 }, // 抗性系数(缺省 1.0)
loot: { scrap:[1,2], crystal:[1,3], alloy:[0,1] },
threatMin: 1 }
| id | 名称 | hp | 其它 | 打法钩子 |
|---|---|---|---|---|
car | 侦察车 | 3 | speed 20, atk ram | 单发够用,教 AOE 的价值 |
tank | 精英坦克 | 12 | speed 14, shell, cd 5, weak:'back' | 绕后打背面(伤害 ×1.6) |
turret | 防空炮台 | 8 | fixed:true, flak, cd 2.5, range 120 | 禁悬停,逼贴地 |
shield | 护盾兵 | 6 + shield 10 | shotgun, weak:'overload', resist:{pierce:0.4} | 换爆破弹 |
drone | 骚扰无人机 | 2 | fly:true, speed 28, kamikaze | 需要散射 / 链弧清群 |
nest | 巢穴 | 120 | fixed:true, spawn car 每 4s(上限 6) | 优先级目标 |
🔴 threatMin 决定它从哪个威胁等级开始出现(tab 上第 5 列表): car 1 / tank 1 / turret 2 / drone 3 / shield 3 / nest 4。 同一张图在 T1 和 T7 刷出来的怪完全不同,这是"同图重打"不腻的物理原因。
4. 敌人配置集 ENEMY_SETS
{ npc:n, elite:n, extra:[{arch,n}], boss:id|null, threatScale:true }
| 集 | 用于 | 内容 |
|---|---|---|
battle_t1 | 焦土 T1 | npc 40, elite 8, extra:[] |
battle_t3 | 焦土 T3+ | npc 36, elite 10, extra:[turret×4] |
battle_t5 | 焦土 T5+ | npc 30, elite 12, extra:[turret×4, drone×6] |
battle_t7 | 焦土 T7+ | npc 26, elite 12, extra:[turret×6, drone×8, shield×6] |
wasteland_t2 | 铁锈带 | npc 30, elite 10, extra:[turret×4], boss:null |
ash_t3 | 灰烬环 | npc 28, elite 10, extra:[drone×6, shield×4] |
ice_t4 | 霜牙 | npc 26, elite 10, extra:[turret×6] |
ruin_t4 | 荒骨滩 | npc 24, elite 12, extra:[nest×3, shield×4] |
⚠️ count 是"全世界总量",不是"玩家附近"。现有实现是"固定 50 台铺满星球", 改集的时候别顺手改回收半径(recycleDist 1200 / renderDist 1100 已调过)。
5. 模块表 MODULES(22 条)
加在 server/items.js(或新建 server/modules.js 再 export,二选一;推荐后者,22 条会把这个文件撑大)。
{ id, name, cat:'module', slot:'weapon'|'defense'|'mobility'|'support',
cost, tier, effect:{...}, desc, from:{node:'battle_sweep_1'}|{tech:'w_chain'}|{boss:'boss_core_scorch'} }
5.1 武器系(slot:'weapon',改发射物行为)
| id | 名称 | cost | tier | effect | 来源 |
|---|---|---|---|---|---|
w_spread | 散花 | 3 | 1 | {emit:3, spreadDeg:8, dmgMul:0.6} | 首通 battle_sweep_1 |
w_boomerang | 回旋 | 4 | 2 | {return:true, dmgMul:0.85, maxHitsPerTarget:2} | 首通 battle_sabotage_1 |
w_cluster | 集束 | 4 | 2 | {drop:true, height:20, blastRadius:18} | 首通 ash_sweep_1 |
w_chain | 链弧 | 5 | 3 | {jump:2, jumpRange:12, jumpMul:0.7} | 科技 w_chain |
w_lance | 穿刺 | 5 | 4 | {charge:1.2, pierceLen:90, armorPen:0.5, dmgMul:1.6} | Boss 部件 boss_core_scorch |
w_sentry | 哨戒 | 4 | 3 | {deploy:true, life:30, dps:3, range:40} | 首通 ruin_sweep_1 |
5.2 防御系(slot:'defense')
| id | 名称 | cost | tier | effect | 来源 |
|---|---|---|---|---|---|
d_barrier | 屏障 | 3 | 1 | {cone:true, absorb:8, cd:12} | 首通 battle_hold_1 |
d_nano | 纳米修复 | 3 | 2 | {regen:1, delay:1.5} | 科技 d_nano |
d_phase | 相位闪避 | 4 | 3 | {iframe:0.35, cd:8} | 首通 ice_hunt_1 |
d_decoy | 诱饵 | 3 | 2 | {radius:40, seconds:6, cd:25} | 首通 wasteland_hold_1 |
d_aegis | 应急重组 | 5 | 4 | {cheatDeath:true, healPct:0.3, cd:120} | Boss 部件 boss_core_ice |
5.3 机动系(slot:'mobility')
| id | 名称 | cost | tier | effect | 来源 |
|---|---|---|---|---|---|
m_dash | 冲刺 | 2 | 0 | {dist:24, cd:5} | 新手引导第 17 步(免费) |
m_hover | 悬停 | 3 | 1 | {seconds:4, cd:10, steady:true} | 科技 m_hover |
m_glide | 滑翔 | 3 | 2 | {fallMul:0.4} | 首通 ice_sweep_1 |
m_grapple | 钩索 | 4 | 3 | {range:60, cd:8} | 首通 rift_sweep_1 |
m_warp | 短跃迁 | 5 | 4 | {dist:30, vertical:true, cd:15} | 科技 m_warp |
5.4 支援系(slot:'support')
| id | 名称 | cost | tier | effect | 来源 |
|---|---|---|---|---|---|
s_pulse | 雷达脉冲 | 2 | 1 | {revealAll:true, seconds:8, cd:30} | 科技 s_pulse |
s_mark | 弱点标记 | 3 | 2 | {mark:true, dmgTakenMul:1.25, seconds:15} | 首通 battle_hunt_1 |
s_supply | 补给信标 | 4 | 3 | {area:20, seconds:20, refillAmmo:true, heal:3} | 首通 ash_hold_1 |
s_shield | 护盾墙 | 4 | 3 | {wall:true, hp:60, seconds:20} | 首通 ruin_hold_1 |
s_scan | 资源扫描 | 2 | 2 | {ores:true, nests:true, seconds:60} | 科技 s_scan(生产线联动) |
s_revive | 应急核心 | 5 | 4 | {reviveCore:true, soloDisabled:true} | 多人节点通关 5 次 |
5.5 套装判定(服务端算,随 loadout 下发)
同系计数 = 5 个槽里该 slot 的数量(超载槽也计入)
>= 2 ⇒ 生效:
weapon → reloadMul 0.7
defense → capacity +2, speedMul 0.95
mobility → cdMul 0.7(只作用于 mobility 系模块)
support → durationMul 1.5
6. 物品与配方新增
6.1 物品(server/items.js)
| id | 名称 | cat | Purpose |
|---|---|---|---|
shard | 结晶碎片 | material | 节点掉的新材料,Mk1 模块的制造料 |
circuit_worn | 旧电路 | material | 同上,Mk1 模块 |
bp_w_spread … 等 | 图纸 | tech | ⚠️ 见 6.2:图纸不进背包,所以物品表里不需要它们 |
boss_core_scorch | 天罗核心 | tech | Boss 部件,不可丢弃 |
🔴 图纸不需要物品定义。拾取即登记 profile.blueprints,走 lootSend 的 "掉落物视觉 + 拾取动画",但 store.bagAdd 不接它。理由见方案 §12.2。
6.2 配方(server/sim.js 的 RECIPES + COMMAND_RECIPES)
// Mk1:指挥中心兼职(沿用零到一死锁的解法,玩家拿到第一张图纸就能造)
make_mod_w_spread: { name:'散花模组', by:'command', tier:1, sec:6, in:{shard:4, iron_ingot:3}, out:{mod_w_spread:1} },
make_mod_d_barrier: { name:'屏障模组', by:'command', tier:1, sec:6, in:{shard:4, copper_ingot:3}, out:{mod_d_barrier:1} },
make_mod_m_dash: { name:'冲刺模组', by:'command', tier:0, sec:4, in:{shard:2}, out:{mod_m_dash:1} },
// Mk2:装配机
make_mod_w_chain: { name:'链弧模组', by:'assembler', tier:3, sec:14, in:{steel_plate:4, circuit:3, shard:10}, out:{mod_w_chain:1} },
make_mod_d_aegis: { name:'应急重组', by:'assembler', tier:4, sec:20, in:{mech_part:3, circuit:6, boss_core_ice:1}, out:{mod_d_aegis:1} },
🔴 配方与图纸的关系只有一条:bldRecipes 下发时按 profile.blueprints 过滤。 已解锁的模块配方才出现在列表里,客户端一行判断都不用写(沿用 recipeIdsOf 那套)。
recipeIdsOf(type, profile) →
(BUILDING_RUNTIME[type].recipes ?? []).filter(rid =>
!MODULE_RECIPE_OWNER[rid] || profile.blueprints.includes(MODULE_RECIPE_OWNER[rid]))
⚠️ 改签名的连带影响:recipeIdsOf 现在只被 server.js 的 bldRecipes 用过一次, 加参数安全;但如果之后有人在 sim.js 里直接调,要一起传 profile。
7. 世界配置新增字段(server/world-config.js)
每个 worlds[id] 增加:
{
terrainStyle: 'plains', // → §8,缺省 'plains'
seedOffset: 0, // 🔴 新星域必须给独立值,禁止复用 0
threat: 1, // 世界基础威胁(节点可以覆盖)
enemySet: 'battle_t1', // → §4
bossId: 'p_boss_scorch', // null = 无 Boss
hazards: [], // ['cold','slippery','lightning','lava','dust']
nodes: ['battle_sweep_1', ...], // 这个星域挂哪些节点(顺序即图上顺序)
gateLevel: 1, // 进这个世界需要的星门等级
}
⚠️ 两侧同步:public/src/config.js 的默认值也要加同样的键, 否则客户端拿不到 terrainStyle 时 elevation 会走错分支(§8 的坑 3)。
8. 地貌 style 表(public/src/terrain-style.js,新)
8.1 接口
export const STYLES = {
plains: {
elevation(noise, d) { /* 🔴 现 terrain-field.js 的 elevation 函数体,逐字复制 */ },
liquid: 'water',
oceanRatio: 0.33,
snowLevelHint: null,
spawnPrefer: ['grass', 'forest'],
oreWeights: { copper: 1, iron: 1, tin: 1, oil: 1, stone: 1 },
slopeLimit: 30,
},
wasteland: { /* ... */ },
// ash / ice / volcanic / rift / ruin
};
terrain-field.js 只改两处:
const style = STYLES[cfg.terrainStyle ?? 'plains'] ?? STYLES.plains;
function elevation(d) { return style.elevation(noise, d); }
// findSpawn 的 prefer 默认值改成 style.spawnPrefer
8.2 六种新地貌的参数方向
| style | 怎么改 elevation | liquid | oceanRatio | spawnPrefer |
|---|---|---|---|---|
wasteland | cont 占比 0.58→0.44;mountain 换成高频小脊(freq 6.5、幅 0.10),保台地 | water | 0.08 | grass,rock |
ash | 起伏同 plains 但 det 权重 ×1.6(松软感);锥体用 ridged^3 且只取最高 20% | water | 0.10 | grass,rock |
ice | cont 0.58→0.34 + 大范围低洼(smoothstep 窗口加宽);mountain 换成低幅细脊 | coolant | 0.45 | snow,rock |
volcanic | mountain 换成 ridged 的 5 次幂(密集锥);锥间靠 det 拉平 | lava | 0.15 | rock,grass |
rift | 在 plains 结果上叠加 carve = 1 - smoothstep(0, 0.055, abs(n)),沿等值线开槽 | water | 0.12 | grass,rock |
ruin | plains + 坐标量化块(floor(d*24)/24 的台阶),台阶边缘 smoothstep 过渡 | water | 0.18 | grass,rock |
8.3 五个坑(每个都会真的出事)
| # | 坑 | 断言 |
|---|---|---|
| 1 | plains 不是数值等价 | terrain_equiv.cjs:200 个固定采样点,Math.abs(新 - 旧) === 0 |
| 2 | 新星域复用 seed | 断言 worlds[p].seedOffset !== 0(plains 两球除外) |
| 3 | 服务端没拿到 terrainStyle | terrain_new.cjs:两端 findSpawn 结果夹角 < 0.001 弧度 |
| 4 | 矿种随地貌消失 | oreWeights 全 0 的矿种要在"该 style 的矿物可采性"测试里显式报错 |
| 5 | 障碍物 id 错位 | 改 flora 参数前停服 + 清 _data/state.json 对应 world 条目 |
9. WS 协议
现有消息分发在 server/server.js 里是一串 if (msg.t === 'xxx'), 新消息加在 lootPick 分支之后(同一缩进层),回包统一 sendTo(ws, {t:'xxxResult', ...})。
9.1 客户端 → 服务端
| t | 字段 | 服务端回包 | 校验 |
|---|---|---|---|
missionList | — | {t:'missions', list:[...]} | 未登录直接 return |
missionStart | {nodeId, threat} | {t:'missionStartResult', ok, code?, active} | 节点存在 / 已解锁 / 威胁不超上限 / 人不在远征中 |
missionAbort | — | {t:'missionResult', ok:false, code:'abort'} | 有 active 才认 |
extract | — | {t:'missionResult', ok:true, rewards} | 在撤离圈内 + extractOpen |
moduleEquip | {index, id} | {t:'loadout', ok, code?, capacity, used} | 槽位匹配 / 容量够 / 已拥有 |
moduleUnequip | {index} | 同上 | — |
blueprintList | — | {t:'blueprints', list:[moduleId]} | — |
9.2 服务端 → 客户端(推送)
| t | 何时 | 字段 |
|---|---|---|
missions | missionList / 登录 | {list:[{id,world,kind,name,threat,cleared,bestThreat,unlocked,lockReason,next}]} |
missionGoal | 进度变化(节流 200ms) | {nodeId, progress, goal, extractOpen, extractAt} |
missionEnd | 成功 / 失败 / 中止 | {nodeId, ok, reason:'extract' or 'death' or 'abort' or 'timeout', rewards} |
blueprintGot | 拾取图纸 | {moduleId, name} |
loadout | 装配变化 | {modules:[...], capacity, used, sets:{weapon:false,...}} |
hazard | 环境危害事件 | {kind:'lightning', dir:[x,y,z], radius, inSec:2} |
9.3 登录下包扩展(bag 消息里加)
sendTo(ws, {
t: 'bag',
...store.bagWire(token), // 已含 bag.equip.modules
storage, items, recipes, bldRecipes, // 已有
site, techs, // 已有
missions: missions.listFor(profile), // 新增
blueprints: profile.blueprints, // 新增
loadout: missions.loadoutOf(profile), // 新增(含 capacity/used/sets)
});
⚠️ 三个新键都是小体积(节点 5~30 条、蓝图 ≤22 个字符串),一次发完不用分批。
9.4 一场远征的完整时序
客户端 服务端
│ missionList ────────────────▶│ 返回节点列表(含锁定原因)
│ missionStart{nodeId,threat}─▶│ 校验 → 写 profile.missions.active
│◀──── missionStartResult ok ──│
│ (客户端切世界:整页重载 ?w=battle&m=battle_sweep_1)
│ hello{worldId} ─────────────▶│ 恢复 active(断线也能恢复)
│◀──── missionGoal 初始进度 ────│
│ 击杀… │ onKill → progress.kills++
│◀──── missionGoal 4/12 ────────│ (节流 200ms)
│◀──── missionGoal 12/12 + extractOpen ──│
│ 走进撤离圈 │ canExtract(球面点积, R=820)
│ extract ────────────────────▶│ 校验圈内 + 站够 extractSec
│◀──── missionResult ok + rewards ──────│
│◀──── blueprintGot(若有首通)──│
🔴 切世界是整页重载(现有实现),所以 active 必须落盘 —— 否则重载即丢。 落盘节流 10s,且 missionStart 后立刻强制写一次(这是唯一一次"立刻写", 因为它后面紧跟重载)。
10. 服务端函数签名
10.1 server/missions.js
export const MISSION_DEFS; // §2
export function defOf(nodeId) // → def | null
export function listFor(profile, worlds) // → [{...}] 给客户端的节点态
export function canStart(profile, nodeId, threat, ctx) // ctx:{tech, gateLevel}
export function start(profile, nodeId, threat) // → {ok, active} | {ok:false, code}
export function resume(profile, worldId) // → active | null(断线续用)
export function onKill(active, enemy) // → {advanced:bool, done:bool}
export function onNestHp(active, index, hp) // → {done}
export function tick(active, dtSec, ctx) // → {advanced, done, fail?, hazard?}
export function canExtract(active, dir, R) // → bool(圈内 + 窗口开)
export function settle(profile, active, {why}) // → {ok, reason, rewards, blueprints}
export function loadoutOf(profile, tech) // → {modules, capacity, used, sets}
export function equipModule(profile, index, id, tech) // → {ok, code?}
export function attachBlueprint(profile, moduleId) // → {ok, already, converted}
10.2 server/enemies.js
export const ENEMY_ARCH; // §3
export const ENEMY_SETS; // §4
export function setOf(name) // → {npc, elite, extra, boss}
export function scaled(arch, threat) // → {hp, dmg, count}(按威胁曲线放大)
export function immuneMul(arch, ammoKind) // → 伤害系数(§3 的 resist)
10.3 store.js 新增(都走 markDirty())
missionsWire(token) // listFor + loadoutOf
missionStart(token, nodeId, threat)
missionKill(token, enemy) // 转 missions.onKill
missionExtract(token)
missionFail(token, reason)
loadoutWire(token)
moduleEquip(token, index, id)
blueprintGrant(token, moduleId) // 写 profile.blueprints
⚠️ 每个方法都要 ensureWorlds(profile) 之后再取数据 —— 现有 20 多个方法全是这个模式。
10.4 定时器相位
| 定时器 | 现状 | 新增职责 |
|---|---|---|
npcTimer 100ms | NPC + Boss | 顺带 missions.tick(dt)(撤离倒计时、hold 计时、dive 波次) |
| 基地结算 1Hz | 物流 / 生产 | 不改 |
| 存档节流 10s | markDirty | 把 active 一起落盘 |
🔴 missions.tick 必须包在现有的 safe() 里(主循环铁律:抛一次错整局冻结)。 且它不许每帧写盘,只改内存。
11. 客户端改动
| 文件 | 改什么 | 关键点 |
|---|---|---|
starmap.js | 星域详情面板里加节点列表(现在只有星域层) | 复用现有 detailHtml(),节点按钮走 data-node |
public/src/missions.js(新) | HUD 目标条 / 撤离提示 / 危害读数 / 结算弹窗 | 只读服务端下发的 progress,本地不算完成 |
public/src/modules.js(新) | 装配面板(5 格 + 容量条 + 模块列表 + 套装提示) | 容量以服务端 loadout 为准 |
paper-planes.js | 6 种 kind 分支 | 见 §12 的三种弹道约束 |
mecha.js | 应用模块数值(CD / 速度 / 摩擦) | 摩擦只在 ice 生效 |
hud.js | 目标条挂载点、CD 环 | 低风险 |
bag.js | equip.modules 的渲染与拖放 | 复用现有 sanitizeSlot |
main.js | 组装上面几个模块 + 处理新消息 | 注意 safe() |
12. 纸飞机 6 种 kind 的实现约束
现有状态机:orbit → climb → strike → spent,发射物无物理体(自积分 + ttl + 贴地飞), 碰撞走 physics.pickAlong() 与 onStrikeSegment()。
| kind | 状态机改动 | 必须同步的字段 |
|---|---|---|
spread | 一次 emit 3,方向各偏 ±8° | 每发独立 ttl,别共用 spent 标志 |
boomerang | strike 到点后进 return(反向再飞一次) | 回程要重置 hitSet,否则同一目标不判定第二次 |
cluster | strike 改为从目标上方垂直下落 | 落地要出预警圈(玩家得看得见) |
chain | 命中回调里递归生成 2 条短弹道 | 递归深度 ≤2,防无限弹跳 |
lance | strike 不结束,沿直线继续 | 贯穿要带 pierceLen 上限,且不穿地形 |
sentry | 不进状态机,原地生成炮塔实体 | 炮塔走Server-authoritative(客户端只表现) |
🔴 三条铁律(都踩过):
grazing标记必须跟onLaunch广播,新弹种别漏(否则远端看到"从头顶飞过")。- 命中半径按体积归一化(
d²/r²),新弹种的爆炸半径别用固定值。 - 弹药必须在服务端扣。现在的实现里客户端攻击不扣弹(未完成清单 §3 #2),
不修这个,爆破弹就变成免费 AOE,经济直接崩。
13. 测试断言矩阵
| 脚本 | 断言 | 类型 |
|---|---|---|
tools/e2e/ammo_consume.cjs | 装爆破弹攻击 3 次 → 背包 ammo_blast −3;爆炸产生 AoE 伤害;标准弹不扣 | 新 |
tools/e2e/enemy_arch.cjs | 同一 world 在 T1 / T7 刷出的敌人种类不同;tank 背面伤害 ×1.6;shield 吃穿甲 ×0.4 | 新 |
tools/e2e/mission.cjs | 接单→击杀 12→撤离→cleared 有记录→首通拿到 blueprints:['w_spread'];再打一次不给图纸、只给结晶 | 新 |
tools/e2e/mission_abort.cjs | 中途 missionAbort → 无首通奖励、cleared 不变 | 新 |
tools/e2e/mission_death.cjs | 死亡 → active 清空、背包掉 50%、无结算 | 新 |
tools/e2e/mission_resume.cjs | 接单后断开 WS → 重连 → active 还在、进度不重置 | 新 |
tools/e2e/extract_circle.cjs | 圈外 extract 被拒;窗口没开 extract 被拒;圈内站够 20s 才成功 | 新 |
tools/e2e/modules.cjs | 容量 12 装不下 cost 5+5+5;defense×2 容量 +2 且速度 ×0.95;超载槽 cost ×2 | 新 |
tools/e2e/terrain_equiv.cjs | 200 采样点 elevation 与旧实现误差 0 | 🔴 必做 |
tools/e2e/terrain_new.cjs | 新地貌 findSpawn 不落水;两端夹角 <0.001;oreWeights 生效 | 新 |
tools/e2e/blueprint_recipe.cjs | 没有图纸时 bldRecipes 里没有该模块配方;拾取后出现 | 新 |
tools/e2e/hazard_readable.cjs | 落雷前 2s 必须下发 hazard 消息(不可读 = bug) | 新 |
⚠️ 测试基建的老问题:多个 e2e 共用 _data/state.json,且其中 8 个会 stop/start 服务, 必须顺序跑。服务端已支持 OCC_DATA 覆盖存档路径,新增脚本一律用它(建独立存档), 别再往共享档上撞(未完成清单 §5 挂了很久)。