fix(hunt): v2 九修 S1-S9 落地——10 人并发模拟迭代裁定(同阵容复测:去留 6/10→10/10)

研究闭环:v1 三轮 28 局四死因(失衡/社交真空/交互摩擦/观光羞辱)→ 四视角分析 →
synthesis S1-S9 裁定 → 实施 → 同 10 人配置复测两轮 20 局。依据 research/hunt-sim/
(synthesis.md / metrics-v2.md / report.md),设计登记 23-hunt.md §16。

服务端 hunt-room.js:
- S5 协作人声包:真人座位角色接线(杀熊/三连杀/旋风斩≥3 播角色句、greet 全座位);
  满编真人房呼喊 2.5s 无 bot 应答 → 邻座角色代答 help_ack;喊话预算 10s 窗 2→3
- S7 难度重构:waveCounts 4 人房 ×4/3;alignPincers 第 3-5 波齐压拍(跨座位同种类
  同秒进内圈,种子确定性保持);mobYielded bot 对人类防区怪让位 2s(只约束 bot,
  不拦真人避免挥空真话说谎);gradeForEx 双轴评级(S=耐久≥9 且 ≥2 人各 6 杀,
  单人带飞局上限 A)

客户端 game.js:
- S1 WAVE 下滑恒出击(无怪=空挥真话),菜单收窄到波间休整窗(E3 修订)——
  v1 误触菜单单人单局最多 14 次清零
- S2 冷却首拦才震 + 就绪 beat 轻叮(v1 全场 666 次乱滑连震)
- S3 0 杀不播 kill_band0(「你还没开张」下架;复测 P08 0杀→12杀)
- S4 看门狗重定义:本波出过手一级语音不触发、语音闲置每局 ≤1、一级降纯震
- S5③ help_call/help_ack 永不超时丢弃
- S6 后半平面 loop 低通 750Hz 第二线索(N/S 判读 63/69%→75/81%)
- S8 教学作答步(T1-T3)下滑不再误跳
- S9 状态链按变化播(「固若金汤」复读归零)

测试同步:hunt-sim A1/A2/A12 断言更新(真人 greet/波次缩放/预算 3)、
playtest Block 3 按 S1 新语义改写(战斗中下滑=空挥 + 波间菜单)

回归:hunt-sim 16/16 + playtest 213 + guide 132 + engine-voice 19 全绿;
balance-scan:挂机局 5/8 落 C/失守带(v1 恒满血)、optimal 单人带飞封顶 A
遗留:营地零损耗未破(18/20 局余10)——v3 头号,参数方案 report.md §5
This commit is contained in:
xsl
2026-09-19 11:06:18 +08:00
parent 0652c2f0e6
commit d78df35b5c
6 changed files with 299 additions and 52 deletions
+18
View File
@@ -837,3 +837,21 @@ R1 起每轮的增量断言(修复回归)在各自设计文档的轮次小
- 服务器不可达:TITLE 单击 → net_lost → net_gone → LOST 回大厅(明示失败,不静默死屏)。
**失败即不通过**:评级与 dur 不符(归因假话);服务器接受违规 act(冷却/方位/近战窗外命中);ev 与 snap 矛盾不收敛;bot 喊话压过/打断旁白(R1/R2 破);loop >3 路;教学狼扣耐久;失守局写入 best 排行榜。
### §24 补遗 · hunt v2 修订断言(2026-09-19,设计依据 [23-hunt.md](23-hunt.md) §16 / research/hunt-sim/)
- **S1 下滑语义**:WAVE 战斗态下滑(含弧内无怪)→ 恒发 act attack dir:S(空挥真话),
菜单绝不打开;waveclear 后 8s 休整窗内下滑 → 菜单打开。(playtest Block 3 已按此改写)
- **S2 冷却反馈**:同一冷却窗内第二次起的被拦滑动手势 → 无 error 震(仅首拦震);
本窗被拦过 → 冷却就绪时刻恰一记 beat 震。
- **S3/S9 语音纪律**:个人击杀 0 的局,kill_band0 在状态链与结算链均不出现;状态链
连续两次上滑(无任何分档变化)→ 第二次不重复播报。
- **S4 看门狗**:WAVE 内本波已有出手/击杀后闲置 10s → 无 gd_idle 语音(纯震);
语音闲置每局 ≤1 次。
- **S5 协作人声**:真人杀熊/三连杀 → 该座角色 kill 喊话广播;满编真人房(无 bot)
callout 后 2.5s → 邻座角色 help_ack(priority);help_call/help_ack 待播不被
2s 超时丢弃。
- **S7 难度**:4 人房每波 spawn 数 = 3 人基准 ×4/3(waveCounts 口径);第 3-5 波
存在同秒进内圈的跨座位齐压对;bot 在人类座位怪进内圈头 2s 内不出手该怪;
ev end.grade 按 gradeForEx 双轴(S 需 ≥2 人各 6 杀)。
- **S8 教学**:T1/T2/T3 作答步内下滑 → 不触发 tut_skip(T1 按 miss 答错处理)。
+41
View File
@@ -498,3 +498,44 @@ WAVE ──WS 断开──> 本地静默重连 ≤30s(net_lost) ──joined(res
8. **巨兽 -2**:任务书"突入=耐久-1"为默认口径;第 3 波起加入暴熊(-2)作为重威胁,给击杀优先级一个明确信号(重 loop/重倒地音可辨),数值与播报(breach_heavy)配套,不改核心形态。
9. **失守与入榜**:任务书"评级 S~C"隐含必完 5 波。裁定:耐久波内归零=失守终局(end_lose,无评级、不写 best,runs/totalKills 照记);胜利局才按 §3.6 评级写入 best——排行榜不被败局污染,失守有真话结算。
10. **bot 强度调参(§7.2 数值修订,协议字段不变)**:初版严格按 §7.2 公式(反应带 eta≤2.2+rng×1.2、无空转、旋风斩 eta≤3≥3 必放)实测**零泄漏**——多种子仿真 3 bot 水准局余耐久恒 10(S),甚至真人全程挂机 2 bot 独守也恒 10,§3.4"3 bot 水准余 6-9"与"协作必要性"(§1.6 风险③)落空。根因是吞吐超配:3 猎人 1.2 杀/s vs 峰值到达 0.67 杀/s,冷却 2.5s 不构成约束。裁定修订为:反应带 **eta ≤ 2.0+rng×1.0**;出手决策加 **80% 空转概率**(500ms tick 内愣神,等效出手周期 ~4.5s);旋风斩 **eta≤2.5 ≥3 只且 60% 概率想起**。调后 24 种子扫描(`web/server/test/hunt-balance-scan.mjs`):挂机局 17/24 失守或余 ≤3(6 场失守,mean 3.3,C/失守口径 ✓);3 bot 水准局 17/24 余 6-9(mean 7.5,A/B 为主 ✓);真人最优仍 23/24 满耐久 S("比 bot 强"空间保留 ✓)。bot"不失误"语义保留(只打有目标的弧),弱化的是**反应速度与出手频率**(拟人迟钝),不是命中率。
## 16. v2 修订记录(2026-09-19,10 人并发模拟迭代;依据与数据见 research/hunt-sim/)
> 迭代章程 research/hunt-sim/00-plan.md;v1 诊断 analysis/01-04;裁定 synthesis.md(S1-S9);
> 复测对比 metrics-v2.md(去留 6/10→10/10,弧错 22%→11%,N/S 可命中 63/69%→75/81%)。
1. **E3 修订(S1)**:WAVE 战斗态下滑**恒出击**(弧内无怪=空挥真话 atk_swish);
模态菜单入口收窄到**波间休整窗**(waveclear 后 8s)与 LOBBY/END。原「无目标下滑=
菜单」的语境折中废止——v1 实测多人战斗中误触菜单(最多单局 14 次),弹菜单+语音
播报双重打断是退游死因之一。上滑无目标=状态链维持不变。
2. **冷却反馈(S2)**:冷却内被拦只在**每个冷却窗首次**给 error 震(连拦静默);
本窗被拦过则就绪时刻补一记 `beat` 轻震(无语音占用)。v1 实测全场 666 次乱滑连震。
3. **kill_band0 下架(S3)**:个人击杀 0 时状态链/结算链**跳过**「你还没开张」
(沉默优于羞辱;正向改写句列 TTS 批次)。0 杀玩家的去留反转(v2 P08:0杀→12杀)
是本项最直接证据。
4. **看门狗重定义(S4)**:WAVE 内本波有出手/击杀 → 一级语音永不触发;一级降纯震;
语音闲置每局 ≤1 次;重教句只留在教学局。判定线=「耳朵在场」(等待是合法玩法)。
5. **协作人声包(S5)**:①真人「扮演座位角色」(§3.3 既有设定)正式接线——杀熊/
三连杀/旋风斩清 ≥3 时由该座角色播 kill/skill 句(服务端 creditKill/adjudicateSkill
放开 slot.bot 限制);开局 greet 覆盖全部有角色座位;②满编真人房呼喊无 bot 可应 →
2.5s 后由邻座真人角色代答 help_ack(服务端 pendingLines 兜底,v3 若加真人应答手势
则替换);③help_call/help_ack 客户端**永不超时丢弃**(氛围句才受 R2 ≤2s 上限);
④喊话全局预算 10s 窗 2→**3** 条。
6. **后半平面第二线索(S6)**:身后 loop 在 ×0.6 响度之上叠加 **750Hz 低通**(音色
发闷 ≠ 响度发闷)。N/S 轴从单线索变双线索,模拟实测可命中 63/69%→75/81%,新手
自述「破案」。§8.3 的 pan 红线不变(方位仍由声像承载)。
7. **难度与评级重构(S7)**:①波次量按座位缩放 `waveCounts`:4 人房 ×4/3(3 人房
原值,§3.4 表为 3 人基准);②第 3-5 波插入**齐压拍** `alignPincers`:1-2 组跨座位
同种类怪重定时到同秒进内圈(数量与 eta 不变、种子确定性保持;齐压在 startWave
层应用,buildWaveScript 纯函数不动);③**bot 让位窗**:怪进内圈头 2s,bot 不与
该防区人类座位抢(`mobYielded`,只约束 bot——对真人不可见不拦截,避免「挥空
真话说谎」的暗墙;真人房的参与真空 v3 用分层匹配解决);④**评级双轴**
`gradeForEx`:S = 耐久 ≥9 **且** ≥2 名猎人各 ≥6 个人击杀(单人带飞局上限 A)。
8. **教学跳过保护(S8)**:作答步(T1/T2/T3)内下滑=答错处理,不再跳过教学;
跳过仅在 T0/T4/完成等待态(v1 实测 P04 教学局手滑跳过、记恨三局)。
9. **状态链按变化播(S9)**:耐久档/波次档/猎人数/击杀档与上次播报一致的分段跳过;
全部无变化=仅 confirm 轻震(「营地固若金汤」复读投诉归零)。
**v3 头号遗留(未修,如实登记)**:营地零损耗未破(v2 复测 18/20 局余 10)——
4 人房 ×4/3 + 齐压拍不足以穿透专家房吞吐(单人最多 34/51 杀)。下一档参数与
分层匹配/保底猎物方案见 research/hunt-sim/report.md §5。
+93 -23
View File
@@ -156,6 +156,8 @@ export class HuntGame extends Game {
// 本地冷却预扣(服务端 err 为准;冷却内手势本地先拦)
this._atkReadyAt = 0;
this._atkBlockedThisCd = false; // v2 S2:每冷却窗首拦才震
this._atkReadyCueTimer = null;
this._skillReadyAt = 0;
this._skillAnnounced = true; // 每轮冷却完成只播一次 skill_ready
this._skillReadyTimer = null;
@@ -303,6 +305,8 @@ export class HuntGame extends Game {
this._tut = -1;
this._tutDoneWaiting = false;
this.wave = n;
this._actedThisWave = false; // v2 S4:每波重新计「在场证明」
this._restUntil = 0;
this.audio.play('bgm/hunt');
if (!wasInWave) {
this.guide.state('battle');
@@ -331,9 +335,10 @@ export class HuntGame extends Game {
this.guide.state('settle');
this._saveProgress(ev); // 结算 PUT(含 summary),end_rank 播到时已写入
const win = !!ev.win;
const myBand = kills > 0 ? [killBandId(kills)] : []; // v2 S3:0 杀不播「你还没开张」(沉默优于羞辱)
const chain = win
? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), killBandId(kills), 'end_rank', 'end_again']
: ['end_lose', killBandId(kills), 'end_again'];
? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), ...myBand, 'end_rank', 'end_again']
: ['end_lose', ...myBand, 'end_again'];
this._endChain = chain;
this._playChain(chain, { protect: 'settle', onDone: () => { this._endAwaiting = true; } });
}
@@ -391,7 +396,9 @@ export class HuntGame extends Game {
}
_onTeachGesture(g) {
if (g === 'down') { // 任意步下滑 = 跳过教学(§5)
// v2 S8:跳过保护——作答步(T1/T2/T3)内下滑 = 答错处理(不再误跳教学,
// P04 教学局手滑跳过记恨三局);只有 T0/T4 非作答态下滑才跳过
if (g === 'down' && (this._tut === 0 || this._tut === 4 || this._tut === -1)) { // 跳过教学(§5)
this._chainSeq++;
this.audio.stopVoice();
this._tut = -1;
@@ -437,12 +444,12 @@ export class HuntGame extends Game {
if (g === 'tap') { this._callout(); return; }
if (g === 'doubletap') { this._skill(); return; }
if (g in SWIPE_BASE) {
// 语境折中(见文件头裁决):上/下滑弧内有可打怪 → 出击;否则
// 上滑=状态链、下滑=菜单(三件套保留)。左/右滑恒=出击。
const arc = ARCS[g];
const hasTarget = this._arcHasTarget(arc);
if (!hasTarget && g === 'up') { this._statusChain(); return; }
if (!hasTarget && g === 'down') { this._openMenu(); return; }
// v2 S1(E3 修订):下滑恒出击(无怪=空挥真话 atk_swish),菜单只在波间
// 休整窗(waveclear 后 8s)开放——修复战斗中误触菜单(R1 三人中招 14 次/局)
const now = performance.now();
const inRest = now < this._restUntil;
if (g === 'down' && inRest) { this._openMenu(); return; }
if (g === 'up' && !this._arcHasTarget(ARCS.up)) { this._statusChain(); return; }
this._attackDir(SWIPE_BASE[g]);
return;
}
@@ -511,20 +518,43 @@ export class HuntGame extends Game {
// ================================================================ 战斗动作(客户端意图)
/** 出击:冷却内仅 error 震(无语音、不发 act,§2.1)。 */
/** 出击:冷却内仅每个冷却窗首拦震一次(v2 S2:连拦静默),就绪轻叮一记。 */
_attackDir(dir) {
const now = performance.now();
if (now < this._atkReadyAt) { vibrate('error'); return; }
if (now < this._atkReadyAt) {
if (!this._atkBlockedThisCd) {
this._atkBlockedThisCd = true; // 首拦才震:连滑不再连环 error 震(R1 全场 666 次)
vibrate('error');
this._armAtkReadyCue();
}
return;
}
this._atkBlockedThisCd = false;
this._atkReadyAt = now + ATTACK_CD_MS;
this._actedThisWave = true;
this._sendAct('attack', dir);
this._pos('sfx/atk_whoosh', { pan: panOfDir(dir), vol: 1, type: 'atk' });
}
/** 冷却就绪提示:本冷却窗内被拦过 → 就绪时刻轻叮一记(beat 震,无语音占用)。 */
_armAtkReadyCue() {
if (this._atkReadyCueTimer !== null) clearTimeout(this._atkReadyCueTimer);
const wait = Math.max(0, this._atkReadyAt - performance.now());
this._atkReadyCueTimer = setTimeout(() => {
this._atkReadyCueTimer = null;
if (this._atkBlockedThisCd && (this.phase === 'wave' || this.phase === 'teach')) {
this._atkBlockedThisCd = false;
vibrate('beat');
}
}, wait);
}
/** 呼喊支援:节流 5s/人;本机播自己座位角色 help_call(中央;座 3=哨音带 pan)。 */
_callout() {
const now = performance.now();
if (now < this._calloutReadyAt) { vibrate('error'); return; }
this._calloutReadyAt = now + CALLOUT_THROTTLE_MS;
this._actedThisWave = true;
this._sendAct('callout');
if (this._myChar) this._npcFire(`sfx/npc_${this._myChar}_help_call`, 0, null);
else this._pos('sfx/call_whistle', { pan: panOfAz(sectorCenterAz(this._mySector)), vol: 1, type: 'whistle' });
@@ -536,6 +566,7 @@ export class HuntGame extends Game {
if (now < this._skillReadyAt) { this._threatReport(); return; }
this._skillReadyAt = now + SKILL_CD_MS;
this._skillAnnounced = false;
this._actedThisWave = true;
this._sendAct('skill');
this._armSkillReady(SKILL_CD_MS);
}
@@ -558,18 +589,24 @@ export class HuntGame extends Game {
this._playChain([id], { queueFirst: true });
}
/** 状态链(D 组):耐久档 → 波次档 → 在场猎人数 → 自己击杀档(queue:true 链播)。 */
/** 状态链(D 组):v2 S9 按变化播——与上次播报一致的段跳过(「固若金汤」复读消除);
* 全部无变化 → 仅 confirm 轻震(沉默优于复读)。 */
_statusIds() {
return [
const ids = [
hpBandId(this.dur),
stWaveId(this.wave || 1),
this._teamCount() >= 4 ? 'team_4' : 'team_3',
killBandId(this._myKills),
];
].filter((id) => id !== 'kill_band0' || this._myKills > 0); // v2 S3:0 杀不播「你还没开张」
const changed = ids.filter((id) => !this._lastStatusSaid || !this._lastStatusSaid.includes(id));
this._lastStatusSaid = ids;
return changed;
}
_statusChain(onDone = null) {
this._playChain(this._statusIds(), { queueFirst: true, onDone });
const ids = this._statusIds();
if (!ids.length) { vibrate('confirm'); if (onDone) onDone(); return; }
this._playChain(ids, { queueFirst: true, onDone });
}
_arcHasTarget(arc) {
@@ -780,7 +817,10 @@ export class HuntGame extends Game {
case 'skill': this._onSkillEv(msg); break;
case 'breach': this._onBreach(msg); break;
case 'wave': this._srvPhase = 'wave'; this._enterWave(msg.wave, this.phase); break;
case 'waveclear': this._playChain(['wave_clear'], { queueFirst: true }); break;
case 'waveclear':
this._restUntil = performance.now() + 8000; // v2 S1:波间休整窗内下滑=菜单
this._playChain(['wave_clear'], { queueFirst: true });
break;
case 'callout': this._onCalloutEv(msg); break;
case 'botline': this._onBotline(msg); break;
case 'roster': this._applyRoster(msg.roster || [], false); break;
@@ -808,6 +848,7 @@ export class HuntGame extends Game {
this._pos('sfx/kill_' + ev.mkind, { pan: panOfDir(ev.dir), vol: 1, type: 'kill' });
if (ev.slot === this.slot) {
this._myKills++;
this._actedThisWave = true;
vibrate('pickup'); // 命中
}
// 教学 T1:首杀 → tut_kill_ok → T2
@@ -967,7 +1008,10 @@ export class HuntGame extends Game {
if (h.panner) h.panner.pan.value = pan;
return;
}
const handle = { want: true, src: null, gain: null, panner: null, vol, eta, mkind: m.mkind };
const handle = {
want: true, src: null, gain: null, panner: null, vol, eta, mkind: m.mkind,
muffled: isBackAz(az), // v2 S6:身后怪标记(低通第二线索)
};
this._loops.set(m.id, handle);
this.audio._load('sfx/loop_' + m.mkind).then((buf) => {
if (!buf || !handle.want || this._loops.get(m.id) !== handle) return;
@@ -979,14 +1023,24 @@ export class HuntGame extends Game {
const gain = ctx.createGain();
gain.gain.value = this.audio.sfxVolume * vol;
src.connect(gain);
let tail = gain;
if (handle.muffled && typeof ctx.createBiquadFilter === 'function') {
// v2 S6:后半平面低通 750Hz——音色发闷叠加响度 ×0.6,N/S 轴判读从
// 「纯响度猜」变「响度+音色双线索」(R1 实测 N/S 可命中仅 63/69%)
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 750;
tail.connect(lp);
tail = lp;
}
if (typeof ctx.createStereoPanner === 'function') {
const panner = ctx.createStereoPanner();
panner.pan.value = pan;
gain.connect(panner);
tail.connect(panner);
panner.connect(this.audio._sfxBus);
handle.panner = panner;
} else {
gain.connect(this.audio._sfxBus); // 老 Safari 静默降级
tail.connect(this.audio._sfxBus); // 老 Safari 静默降级
}
src.start();
handle.src = src;
@@ -1074,7 +1128,8 @@ export class HuntGame extends Game {
}
this._npcPending = {
id, pan, onDone,
expireAt: now + (priority ? NPC_PRIO_TTL_MS : NPC_VOICE_DELAY_MS),
// v2 S5③:呼救/应答语义永不超时丢弃(等待投递);氛围句才受 ≤2s 延迟上限
expireAt: priority ? Infinity : now + NPC_VOICE_DELAY_MS,
};
this._armNpcPump();
}
@@ -1172,14 +1227,24 @@ export class HuntGame extends Game {
this.guide.error({ voice: 'gd_invalid', onceKey: 'hunt_invalid', skipHaptic: true });
}
/** 看门狗(§11):WAVE/LOBBY 无操作 [10s,25s] → gd_idle / null;TEACH 逐步 [8s] 纯 nudge。 */
/** 看门狗(§11 + v2 S4 重定义):WAVE 内本波出过手 → 一级语音永不触发(等待是
* 合法玩法,不是挂机);一级降纯震;语音闲置每局 ≤1 次;重教句只留在教学局。
* LOBBY 照旧(room_wait 语境合理)。 */
_rearmWatchdog() {
if (this._menuOpen) return;
if (this.phase === 'wave' || this.phase === 'lobby') {
if (this.phase === 'wave') {
const sayIdle = !this._actedThisWave && !this._idleVoiceSaid;
if (sayIdle) this._idleVoiceSaid = true; // 每局最多一次语音闲置提醒
this.guide.watchdog({
at: [10000, 25000],
say: [sayIdle ? 'gd_idle' : null, null],
cond: () => this.phase === 'wave' && !this._menuOpen,
});
} else if (this.phase === 'lobby') {
this.guide.watchdog({
at: [10000, 25000],
say: ['gd_idle', null],
cond: () => (this.phase === 'wave' || this.phase === 'lobby') && !this._menuOpen,
cond: () => this.phase === 'lobby' && !this._menuOpen,
});
} else if (this.phase === 'teach' && this._tut >= 0) {
this._armTutWatchdog(this._tut);
@@ -1363,6 +1428,11 @@ export class HuntGame extends Game {
this._posQueue.length = 0;
this._posActive = [];
this._dropAllNpc();
this._actedThisWave = false; // v2 S4/S9:每局复位(在场证明/状态链去重/闲置语音额度)
this._idleVoiceSaid = false;
this._lastStatusSaid = null;
this._restUntil = 0;
this._atkBlockedThisCd = false;
}
_teardownRound() {
+20 -3
View File
@@ -278,6 +278,7 @@ const evMiss = (slot, dir, ts = now) => ({ t: 'ev', kind: 'miss', ts, slot, dir
const evSkill = (slot, cleared, ts = now) => ({ t: 'ev', kind: 'skill', ts, slot, cleared });
const evBreach = (id, mkind, dir, dur, ts = now) => ({ t: 'ev', kind: 'breach', ts, id, mkind, dir, dur });
const evWave = (wave, ts = now) => ({ t: 'ev', kind: 'wave', ts, wave });
const evWaveclear = (wave, ts = now) => ({ t: 'ev', kind: 'waveclear', ts, wave });
const evCallout = (slot, ts = now) => ({ t: 'ev', kind: 'callout', ts, slot });
const evBotline = (slot, line, ts = now) => ({ t: 'ev', kind: 'botline', ts, slot, line });
const evRoster = (roster, ts = now) => ({ t: 'ev', kind: 'roster', ts, roster });
@@ -841,9 +842,21 @@ realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 ==');
ws.inject(conn, evKill('s1', 2, 'S')); // bot 补刀清场(kills 归 slot 2)
await flush();
// 无 S 弧目标时下滑 = 菜单(三件套保留):sys_menu + 首项 + settings_hint
// v2 S1:战斗中下滑恒出击(无怪 = 空挥真话 atk_swish),不再弹菜单
const cdT0 = performance.now();
await advance(() => performance.now() - cdT0 >= 2600); // 过 2.5s 出击冷却
const atkBefore = ws.acts(conn, 'attack').length;
await g(game, 'down');
assert(game._menuOpen === true, '无目标下滑 → 模态菜单打开');
assert(ws.acts(conn, 'attack').length === atkBefore + 1
&& ws.acts(conn, 'attack')[atkBefore].dir === 'S',
'v2 S1:战斗中无怪下滑 = 空挥出击(act attack dir:S)');
assert(game._menuOpen === false, 'v2 S1:战斗中下滑绝不打开菜单');
// v2 S1:菜单只在波间休整窗(waveclear 后 8s)开放
ws.inject(conn, evWaveclear(1));
await flush();
await g(game, 'down');
assert(game._menuOpen === true, '波间休整窗下滑 → 模态菜单打开');
await advance(() => playsOf(playLog, 'voice/settings_hint') === 1);
assert(chainOrderOk(playLog, ['sys_menu', 'sys_menu_resume', 'settings_hint']),
'菜单打开即播 sys_menu + 首项 + settings_hint(设置只读、回大厅修改)');
@@ -874,8 +887,10 @@ realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 ==');
assert(game._menuOpen === false, '菜单内下滑 = 关闭');
// 双击确认:选中"重新听帮助"→ 播 help_main 且菜单不收
ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗
await flush();
await g(game, 'down');
assert(game._menuOpen === true, '菜单再次打开');
assert(game._menuOpen === true, '菜单再次打开(休整窗内)');
await advance(() => !audio.isVoicePlaying());
await g(game, 'tap'); // idx 1 = sys_menu_help
await g(game, 'doubletap');
@@ -886,6 +901,8 @@ realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 ==');
assert(game._menuOpen === false, '菜单再次关闭');
// 双击确认"退出房间回大厅":发 leave + 跳转大厅
ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗
await flush();
await g(game, 'down');
await advance(() => !audio.isVoicePlaying());
await g(game, 'tap');
+116 -19
View File
@@ -72,12 +72,14 @@ const BOT_TICK_MS = 500;
const SNAP_MS = 500; // 2Hz
const ROOM_CODE_CHARS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'; // §6.2 无歧义字符集
const BOTLINE_WINDOW_MS = 10000; // §7.4 全局节流窗口
const BOTLINE_BUDGET = 2; // 窗口内氛围句上限
const BOTLINE_BUDGET = 3; // 窗口内氛围句上限(v2 S5① 真人座位角色接线后 2→3;help_* 恒优先)
const GREET_FIRST_MS = 1000;
const GREET_GAP_MS = 1600; // ≥1.5s(§7.4 greet 间隔)
const COVER_MS = 10000; // §7.3 补位偏置持续 10s
const RATE_PER_SEC = 20; // §6.1 应用消息速率帽
const RATE_STRIKE_MS = 10000; // 再犯判定窗
const SECTOR_YIELD_MS = 2000; // v2 S7③:bot 对人类防区怪的让位窗
const CALLOUT_ACK_DELAY_MS = 2500; // v2 S5②:无 bot 应答时邻座角色代答延迟
// §7.2 bot 难度校准(§15 裁决 10 调参;初版 2.2+1.2r 无犹豫实测零泄漏过强):
// 反应带更迟、出手决策带空转概率(500ms tick 内愣神)、旋风斩节制。
@@ -115,11 +117,12 @@ function waveRng(seed, wave) {
*/
function buildWaveScript(seed, wave, seatCount) {
const spec = WAVE_TABLE[wave];
const counts = waveCounts(spec, seatCount);
const rng = waveRng(seed, wave);
const layout = LAYOUTS[seatCount];
const mkinds = [];
for (let i = 0; i < spec.wolf; i++) mkinds.push('wolf');
for (let i = 0; i < spec.bear; i++) mkinds.push('bear');
for (let i = 0; i < counts.wolf; i++) mkinds.push('wolf');
for (let i = 0; i < counts.bear; i++) mkinds.push('bear');
for (let i = mkinds.length - 1; i > 0; i--) { // Fisher-Yates,走波次 rng
const j = Math.floor(rng() * (i + 1));
[mkinds[i], mkinds[j]] = [mkinds[j], mkinds[i]];
@@ -138,6 +141,67 @@ function buildWaveScript(seed, wave, seatCount) {
});
}
/**
* 波次量按座位缩放(v2 修复 S7①:3 人房维持原值;4 人房 ×4/3——
* 6 局真人快速局全部零损耗满血 S 的根因之一是吞吐冗余,见 research/hunt-sim/analysis/01)。
*/
function waveCounts(spec, seatCount) {
const f = seatCount / 3;
return {
wolf: Math.round(spec.wolf * f),
bear: spec.bear ? Math.max(1, Math.round(spec.bear * f)) : 0,
};
}
/**
* 齐压拍(v2 修复 S7②):第 3-5 波把既有剧本里 1-2 组「不同座位」的怪
* 重定时到同一毫秒进内圈(数量不变、种子确定性)。单人 2.5s 出击冷却在
* 齐压拍数学上必漏 → 呼喊/分工/旋风斩分配第一次成为唯一解。
* 纯函数:不动 buildWaveScript(A2 单测口径)。
*/
function alignPincers(script, seed, wave) {
if (wave < 3) return script;
const rng = waveRng((seed ^ 0x91CE) >>> 0, wave);
const groups = wave === 3 ? 1 : 2;
const used = new Set();
for (let g = 0; g < groups; g++) {
const avail = script.map((e, i) => ({ e, i }))
.filter(({ e, i }) => !used.has(i));
// 找一对不同座位、同种类的条目(齐压必须跨防区才有强制分工意义;
// 同种类保证 eta 原值不动——狼仍是狼速,只是同秒抵达)
let pair = null;
for (let a = 0; a < avail.length && !pair; a++) {
for (let b = avail.length - 1; b > a && !pair; b--) {
if (avail[a].e.seat !== avail[b].e.seat && avail[a].e.mkind === avail[b].e.mkind) {
pair = [avail[a], avail[b]];
}
}
}
if (!pair) break;
// 对齐到较晚时刻(同秒进内圈),加 ≤300ms 微差防叠声
const late = Math.max(pair[0].e.at, pair[1].e.at);
pair[0].e.at = late;
pair[1].e.at = late + Math.floor(rng() * 300);
used.add(pair[0].i);
used.add(pair[1].i);
}
return script.sort((a, b) => a.at - b.at);
}
/**
* 评级(v2 修复 S7④ 双轴):S = 耐久≥9 且 至少两名猎人均有 ≥6 个人击杀
* (零损耗 + 打了配合的房间才是 S;「1 高手带飞局」上限 A)。
* gradeFor 保留原单轴口径供兼容引用。
*/
function gradeForEx(dur, killsBySlot) {
if (dur >= 9) {
const contributors = Object.values(killsBySlot || {}).filter((k) => k >= 6).length;
if (contributors >= 2) return 'S';
return 'A';
}
return gradeFor(dur);
}
/** §3.6 评级(服务端直发,客户端不算分) */
function gradeFor(dur) {
if (dur >= 9) return 'S';
@@ -349,8 +413,8 @@ class Room {
this.startedAt = this.now;
this.phase = this.teach ? 'teach' : 'wave';
this.broadcastRoster(); // 补位完成花名册
this.botSlots().sort((a, b) => a.slot - b.slot).forEach((b, i) => { // §7.4 greet 逐只 ≥1.5s
this.pendingLines.push({ at: GREET_FIRST_MS + i * GREET_GAP_MS, slot: b.slot, line: 'greet' });
this.occupiedSlots().sort((a, b) => a.slot - b.slot).forEach((s, i) => { // v2 S5①:真人座位角色也打招呼(§7.4 间隔)
if (s.char) this.pendingLines.push({ at: GREET_FIRST_MS + i * GREET_GAP_MS, slot: s.slot, line: 'greet' });
});
if (this.teach) {
// §5 教学波:3 狼、逼近 12s、突入零惩罚。狼 1 至多补放 2 次(做错重教 ≤2 次)。
@@ -365,8 +429,10 @@ class Room {
this.wave = n;
this.broadcastEv({ kind: 'wave', wave: n });
const base = this.ts();
this.script = buildWaveScript(this.seed, n, this.seatCount)
.map((e) => ({ ...e, atTs: base + e.at }));
this.script = alignPincers(
buildWaveScript(this.seed, n, this.seatCount).map((e) => ({ ...e, atTs: base + e.at })),
this.seed, n,
);
this.scriptIdx = 0;
if (n >= 2) { // §7.4 wave 喊话:第 2 波起轮转一只 bot
const bots = this.botSlots().sort((a, b) => a.slot - b.slot);
@@ -387,12 +453,24 @@ class Room {
if (alt) d = alt;
}
const id = this.nextMobId++;
const mob = { id, mkind, dir: d, eta };
const mob = { id, mkind, dir: d, eta, seat, innerAt: null }; // seat/innerAt:bot 让位判定(S7③)
this.mobs.set(id, mob);
this.broadcastEv({ kind: 'spawn', id, mkind, dir: d, eta });
return mob;
}
/**
* bot 让位窗(v2 修复 S7③,bot 单侧礼貌版):怪进内圈头 2 秒,bot 不与
* 该防区的人类座位抢——弱者的保底出手窗。只约束 bot(对真人不可见、
* 不拦截真人出手,避免「挥空真话说谎」的暗墙)。
*/
mobYielded(mob) {
if (mob.innerAt === null || mob.seat === undefined) return false;
if (this.now - mob.innerAt >= SECTOR_YIELD_MS) return false;
const owner = this.slots[mob.seat];
return !!(owner && !owner.bot && owner.alive);
}
breachMob(mob) {
this.mobs.delete(mob.id);
mob.breached = true;
@@ -440,11 +518,12 @@ class Room {
}
slot.skillCdUntil = this.now + SKILL_CD_MS;
slot.lastActAt = this.now;
const clearedMobs = [...this.mobs.values()].filter((m) => m.eta <= MELEE_ETA);
const clearedMobs = [...this.mobs.values()]
.filter((m) => m.eta <= MELEE_ETA && !(slot.bot && this.mobYielded(m))); // bot 旋风斩也让位(S7③)
for (const mob of clearedMobs) this.mobs.delete(mob.id);
this.broadcastEv({ kind: 'skill', slot: slot.slot, cleared: clearedMobs.map((m) => m.id) });
for (const mob of clearedMobs) this.creditKill(slot, mob);
if (slot.bot && clearedMobs.length) this.tryBotline(slot, 'skill');
if (slot.char && clearedMobs.length >= 3) this.tryBotline(slot, 'skill'); // v2 S5①:真人大清场也报
}
adjudicateCallout(slot) {
@@ -468,6 +547,17 @@ class Room {
killDone: false,
settled: false,
};
} else {
// v2 S5②:满编真人房无 bot 可应——2.5s 后由邻座真人「扮演的角色」代答
// help_ack(呼喊-应答听觉闭环在真人房成立的兜底;v3 若加真人应答手势则替换)
const neighbor = this.occupiedSlots()
.filter((s) => s.slot !== slot.slot && s.char && s.alive)
.sort((a, b) => a.slot - b.slot)[0];
if (neighbor) {
this.pendingLines.push({
at: this.ts() + CALLOUT_ACK_DELAY_MS, slot: neighbor.slot, line: 'help_ack', priority: true,
});
}
}
}
@@ -477,7 +567,7 @@ class Room {
if (slot.cover && !slot.cover.settled && slot.cover.callerSector.includes(mob.dir)) {
slot.cover.killDone = true;
}
if (slot.bot && slot.char) { // §7.4 kill 喊话:杀熊或三连杀,15s/只节流
if (slot.char) { // v2 S5①:真人也「扮演座位角色」——杀熊/三连杀照播角色 kill 句(§3.3 声音身份接线)
slot.recentKills.push(this.now);
slot.recentKills = slot.recentKills.filter((t) => this.now - t <= 6000);
const triple = slot.recentKills.length >= 3;
@@ -513,7 +603,7 @@ class Room {
// ---------------- bot AI(§7.2,500ms tick,确定性) ----------------
/** 方向压力 P(d)=Σ(1/eta)(仅计 eta≤4);主防区 ×1.5;补位期呼喊者防区 ×2.0(覆盖主防区偏置)。 */
/** 方向压力 P(d)=Σ(1/eta)(仅计 eta≤4 且未被让位的怪);主防区 ×1.5;补位期呼喊者防区 ×2.0(覆盖主防区偏置)。 */
botPickDir(bot) {
const cov = bot.cover && this.now < bot.cover.until ? bot.cover : null;
let bestDir = null;
@@ -521,7 +611,7 @@ class Room {
for (let d = 0; d < 8; d++) {
let p = 0;
for (const m of this.mobs.values()) {
if (DIR_IDX[m.dir] === d && m.eta <= MELEE_ETA) p += 1 / Math.max(m.eta, 0.1);
if (DIR_IDX[m.dir] === d && m.eta <= MELEE_ETA && !this.mobYielded(m)) p += 1 / Math.max(m.eta, 0.1);
}
if (p <= 0) continue;
if (cov ? cov.callerSector.includes(DIRS[d]) : bot.sector.includes(DIRS[d])) {
@@ -540,14 +630,14 @@ class Room {
const close = [...this.mobs.values()].filter((m) => m.eta <= tune.skillEta).length;
if (close >= tune.skillMin && this.botRng() < tune.skillP) this.adjudicateSkill(bot);
}
// 出击:冷却就绪 + max P 方向 + 反应带 + 犹豫空转(裁决 10)
// 出击:冷却就绪 + max P 方向 + 反应带 + 犹豫空转(裁决 10);让位怪不抢(S7③)
if (this.now >= bot.cdUntil) {
const dir = this.botPickDir(bot);
if (dir) {
const arc = arcOf(dir);
let best = null;
for (const m of this.mobs.values()) {
if (!arc.includes(DIR_IDX[m.dir]) || m.eta > MELEE_ETA) continue;
if (!arc.includes(DIR_IDX[m.dir]) || m.eta > MELEE_ETA || this.mobYielded(m)) continue;
if (!best || m.eta < best.eta) best = m;
}
if (best
@@ -621,10 +711,10 @@ class Room {
this.mobs.clear();
this.script = null;
this.pendingWave = null;
const grade = win ? gradeFor(this.dur) : null; // 失守无评级(§3.6/§15 裁决 9)
const kills = {};
for (const [k, v] of Object.entries(this.killsBySlot)) kills[k] = v;
for (const s of this.occupiedSlots()) kills[String(s.slot)] = s.kills;
const grade = win ? gradeForEx(this.dur, kills) : null; // v2 S7④ 双轴评级(失守无评级,§3.6/§15 裁决 9)
this.broadcastEv({ kind: 'end', win, dur: this.dur, grade, kills });
this.endAt = this.now;
}
@@ -699,9 +789,10 @@ class Room {
}
// 教学波推进
if (this.phase === 'teach') this.tickTeach();
// 怪物推进 + 突入结算
// 怪物推进 + 突入结算(进内圈打让位时间戳,S7③)
for (const mob of [...this.mobs.values()]) {
mob.eta -= dt / 1000;
if (mob.innerAt === null && mob.eta <= MELEE_ETA) mob.innerAt = this.now;
if (mob.eta <= 0) {
this.breachMob(mob);
if (this.phase === 'end' || this.destroyed) return;
@@ -722,11 +813,13 @@ class Room {
this.lastBotAt = this.now;
this.botTick();
}
// 喊话调度(greet 等)
// 喊话调度(greet 等;v2 S5② 真人代答 help_ack 也走此队列)
while (this.pendingLines.length && this.pendingLines[0].at <= ts) {
const l = this.pendingLines.shift();
const s = this.slots[l.slot];
if (s && s.bot) this.tryBotline(s, l.line);
if (s && s.char && s.alive !== false) {
this.emitBotline(s, l.line, { priority: !!l.priority });
}
}
// 快照兜底(2Hz)
if (this.now - this.lastSnapAt >= SNAP_MS) {
@@ -1000,7 +1093,10 @@ module.exports = {
createManager,
Room,
buildWaveScript,
waveCounts,
alignPincers,
gradeFor,
gradeForEx,
mulberry32,
DIRS,
LAYOUTS,
@@ -1018,4 +1114,5 @@ module.exports = {
REST_MS,
ROOM_CODE_CHARS,
BOT_TUNING_DEFAULT,
SECTOR_YIELD_MS,
};
+11 -7
View File
@@ -29,7 +29,7 @@ import { fileURLToPath } from 'node:url';
import hunt from '../hunt-room.js';
const {
createManager, buildWaveScript, gradeFor, DIRS, LAYOUTS, WAVE_TABLE,
createManager, buildWaveScript, waveCounts, gradeFor, gradeForEx, DIRS, LAYOUTS, WAVE_TABLE,
TICK_MS, GRACE_MS, REST_MS, LOBBY_MS, END_KEEP_MS,
} = hunt;
@@ -189,7 +189,7 @@ test('A1 全流程:匹配→开局 bot 补位→5 波→胜利结算,事件
const end = lastEv(conn, 'end');
assert.equal(end.win, true, '1 真人+2 bot 应能守过 5 波');
assert.ok(end.dur >= 1 && end.dur <= 10, `余耐久 ${end.dur} 在 1-10`);
assert.equal(end.grade, gradeFor(end.dur), '评级与耐久一致(§3.6,归因真话)');
assert.equal(end.grade, gradeForEx(end.dur, end.kills), '评级双轴一致(v2 S7④:耐久档 × 参与度,归因真话)');
let killSum = 0;
for (const [k, v] of Object.entries(end.kills)) {
assert.ok(['0', '1', '2'].includes(k));
@@ -211,7 +211,7 @@ test('A1 全流程:匹配→开局 bot 补位→5 波→胜利结算,事件
assert.ok(snaps[snaps.length - 1].ts <= endTs, '终局后 snap 停发');
// ---- botline greet:逐只间隔 ≥1.5s(§7.4) ----
const greets = evs(conn, 'botline').filter((e) => e.line === 'greet');
assert.equal(greets.length, 2);
assert.equal(greets.length, 3, 'greet:2 bot + 真人座位角色(v2 S5① 真人接线)');
assert.ok(greets[1].ts - greets[0].ts >= 1500, 'greet 间隔 ≥1.5s');
// ---- 失守不写榜(服务器侧口径):本局为胜局,反向用 A9 覆盖 ----
@@ -228,11 +228,15 @@ test('A2 刷怪剧本 seed 复现:同 seed 同剧本,方位 ∈ 座位防区
assert.deepEqual(a, b, `seat=${seatCount} wave=${w} 同 seed 同剧本`);
const c = buildWaveScript(43, w, seatCount);
assert.notDeepEqual(a, c, '异 seed 异剧本');
const spec = WAVE_TABLE[w];
const counts = waveCounts(spec, seatCount); // v2 S7①:波次量按座位缩放
assert.equal(a.length, counts.wolf + counts.bear, `seat=${seatCount} w=${w} 波次量 = 缩放后狼+熊`);
assert.equal(a.filter((e) => e.mkind === 'bear').length, counts.bear, `seat=${seatCount} w=${w} 熊数`);
const layout = LAYOUTS[seatCount];
a.forEach((e, i) => {
assert.equal(e.seat, i % seatCount, '按座位数轮转发牌');
assert.ok(layout[e.seat].sector.includes(e.dir), '方位落在该座位防区内');
assert.equal(e.at, (i + 1) * WAVE_TABLE[w].gap * 1000, '刷怪间隔 §3.4');
assert.equal(e.at, (i + 1) * spec.gap * 1000, '刷怪间隔 §3.4(齐压拍在 startWave 层,纯函数不动)');
});
}
}
@@ -628,17 +632,17 @@ test('A12 botline 全局节流:10s 窗氛围 ≤2 条;help_call/help_ack 优
const before = evs(conn, 'botline').length;
for (let i = 0; i < 5; i++) room.emitBotline(bot, 'greet'); // 连发 5 条氛围句
assert.equal(evs(conn, 'botline').length - before, 2, '10s 窗氛围句 ≤2,超出丢弃');
assert.equal(evs(conn, 'botline').length - before, 3, '10s 窗氛围句 ≤3(v2 S5① 预算 2→3),超出丢弃');
// 预算耗尽时呼救语义仍必达
room.emitBotline(bot, 'help_ack', { priority: true });
assert.equal(evs(conn, 'botline').length - before, 3, 'help_ack 优先不被氛围顶掉');
assert.equal(evs(conn, 'botline').length - before, 4, 'help_ack 优先不被氛围顶掉(3 氛围 + 1 优先)');
assert.equal(evs(conn, 'botline').filter((e) => e.line === 'help_ack').length, 1);
// 窗口滑过后恢复
fastForward(mgr, 10.5);
room.emitBotline(bot, 'greet');
assert.equal(evs(conn, 'botline').length - before, 4, '10s 窗滑过后氛围恢复投递');
assert.equal(evs(conn, 'botline').length - before, 5, '10s 窗滑过后氛围恢复投递');
room.destroy();
});