From d78df35b5c839f3d6a16d1ded23b1a12d2c45b55 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Sat, 19 Sep 2026 11:06:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(hunt):=20v2=20=E4=B9=9D=E4=BF=AE=20S1-S9=20?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=E2=80=94=E2=80=9410=20=E4=BA=BA=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E6=A8=A1=E6=8B=9F=E8=BF=AD=E4=BB=A3=E8=A3=81=E5=AE=9A?= =?UTF-8?q?=EF=BC=88=E5=90=8C=E9=98=B5=E5=AE=B9=E5=A4=8D=E6=B5=8B=EF=BC=9A?= =?UTF-8?q?=E5=8E=BB=E7=95=99=206/10=E2=86=9210/10=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 研究闭环: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 --- docs/design/13-playtest-criteria.md | 18 ++++ docs/design/23-hunt.md | 41 +++++++++ web/hunt/game.js | 116 +++++++++++++++++++----- web/hunt/test/playtest.mjs | 23 ++++- web/server/hunt-room.js | 135 ++++++++++++++++++++++++---- web/server/test/hunt-sim.test.mjs | 18 ++-- 6 files changed, 299 insertions(+), 52 deletions(-) diff --git a/docs/design/13-playtest-criteria.md b/docs/design/13-playtest-criteria.md index 52ce2a5c..ad43d28c 100644 --- a/docs/design/13-playtest-criteria.md +++ b/docs/design/13-playtest-criteria.md @@ -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 答错处理)。 diff --git a/docs/design/23-hunt.md b/docs/design/23-hunt.md index d7cecd29..32505e55 100644 --- a/docs/design/23-hunt.md +++ b/docs/design/23-hunt.md @@ -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。 diff --git a/web/hunt/game.js b/web/hunt/game.js index d98bb642..75bd7661 100644 --- a/web/hunt/game.js +++ b/web/hunt/game.js @@ -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() { diff --git a/web/hunt/test/playtest.mjs b/web/hunt/test/playtest.mjs index 1ac62a5f..fcfe40fa 100644 --- a/web/hunt/test/playtest.mjs +++ b/web/hunt/test/playtest.mjs @@ -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'); diff --git a/web/server/hunt-room.js b/web/server/hunt-room.js index 80e482e1..1c3afa8d 100644 --- a/web/server/hunt-room.js +++ b/web/server/hunt-room.js @@ -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, }; diff --git a/web/server/test/hunt-sim.test.mjs b/web/server/test/hunt-sim.test.mjs index f3bb8ef7..0450cffa 100644 --- a/web/server/test/hunt-sim.test.mjs +++ b/web/server/test/hunt-sim.test.mjs @@ -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(); });