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
+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();
});