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:
+93
-23
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user