feat(new8/R3): 双游戏收口修复轮 + 验证档案(radio G1-G6 / dungeon E0-1..E1-2)

- radio:帮助链去重(同等待点二次=beat震+earcon,全量口令38→20)/rep_low警告震/空点轻震/状态链重排(状态在前)/「首播必完整、复播可即掐」显式规则登记/G3核查为设计使然转断言;221→246断言
- dungeon:看门狗双轨制(无操作轨+无进展轨,30/60/90s三级升级到导航接管)/跳教学90s自动滚出(2403s→95s内)/踩楼梯到达即报贴身铁器/band1新词「带回来一点,不多」全管线重录/逃跑长按900ms+提示句;1610→1642断言
- R3 验证(进行中已入库部分):全量回归 9/9 全绿;R3 转写×4;P2/P3/P4 终审×6份(radio 满分档首评/三审,dungeon 4/4.5/3-4 全票愿意玩,无P0);两处独立定址同一残余=漫游型迷路刷不响无进展轨(R4 第一条)
- 8套件+guide全绿;Mureka 累计 $0.87/$2.00
This commit is contained in:
xsl
2026-09-02 23:08:35 +08:00
parent 111dfed68a
commit f7509ad5d8
40 changed files with 9298 additions and 67 deletions
+261 -6
View File
@@ -692,6 +692,8 @@ async function scenario2() {
await walkPath(game, path);
assert(game._onStairs === true, '踏上楼梯格');
assert(plays(audio, mark).has('voice/tut_stairs_on'), '首次踏上楼梯格播 tut_stairs_on');
assert(plays(audio, mark).has('sfx/echo_1') && plays(audio, mark).has('sfx/mat_stair'),
'R3 E1-1 到达即报:踩上楼梯格瞬间自动播贴身铁器回声(echo_1+mat_stair,全程零玩家脉冲)');
mark = audio.log.length;
g(game, 'tap'); // 单击下潜
await tick(6000);
@@ -699,6 +701,7 @@ async function scenario2() {
assert(game.meta.drillDone === true && game._drill === null, '教学标志入元进度,DRILL 收尾');
p = plays(audio, mark);
assert(p.has('sfx/stairs_down') && p.orderOk('voice/evt_descend', 'voice/tut_done', 'voice/st_floor_2'), '下潜演出:stairs_down → evt_descend → tut_done → st_floor_2');
assert(p.has('sfx/stairs_down') && game.run.floor === 2, 'R3 E1-1: 到达即报不占输入通道——tap 仍是下潜确认(照常下潜)');
const f2 = game._generateFloor(42, 2);
assert(JSON.stringify(game._floor.grid) === JSON.stringify(f2.grid), '§12.6: 第 2 层 grid 由 floorSeed 重生成且复现');
return { game, audio };
@@ -715,7 +718,8 @@ async function scenario4({ game, audio }) {
await tick(6000);
const faceId = 'voice/st_face_' + game.run.face.toLowerCase();
p = plays(audio, mark);
assert(p.orderOk('voice/st_floor_2', 'voice/st_hp_1', 'voice/st_gold_1', faceId), '上滑链前 4 段有序(层/血/金/面朝)');
// R3 E1-2:此刻 meta.gold=3(教学蝠掉落,0<金<50)→ 档 1 播 gold_some 真话句(不再「口袋空空」)
assert(p.orderOk('voice/st_floor_2', 'voice/st_hp_1', 'voice/gold_some', faceId), '上滑链前 4 段有序(层/血/金/gold_some band1 真话/面朝)');
assert(p.countRe(/^voice\/st_threat_/) === 1, '上滑链第 5 段威胁句恰 1 句');
// 探索菜单冻结:菜单打开期间怪物游走停摆,关闭恢复。
@@ -973,6 +977,17 @@ async function scenario5() {
assert(game.run.hp === hpP - 2, `格挡 ×0.2(atk10 → 伤 ${hpP - game.run.hp},期望 2)`);
assert(plays(audio, mark).has('voice/btl_parried'), '格挡成功播 btl_parried(挡下了大半)');
assert(game.run.parries === 1, 'parries 只记"面对且挡下"的格挡(1 次)');
// R3 打磨:战斗长按未满 900ms 松手 = 取消逃跑(防走位肌肉记忆误逃)+ 区分提示一句
mark = audio.log.length;
vibA = vibrateLog.length;
g(game, 'longpress');
await tick(200);
g(game, 'longpressend');
await tick(600);
assert(game.state === 'BATTLE' && game._battle === b && b.state === 'player',
'战斗长按 ~650ms(<900ms)松手 = 不逃(R3 打磨)');
assert(vibratedError(vibA), '短长按取消伴随 error 震');
assert(plays(audio, mark).has('voice/tut_flee_hold'), '取消播区分提示 tut_flee_hold(每场 1 次)');
// 长按逃跑:必成 + 撤退挨一下(真实账目)
b.atk = 1; // 还原
await tick(1500);
@@ -1120,16 +1135,19 @@ async function scenario6a() {
assert(game.state === 'SETTLE', 'HP≤0 → 死亡结算 SETTLE');
await tick(8000); // 死亡链 7 句播完
p = plays(audio, mark);
// R3 E1-2:settle 金币 40(0<金<50)→ 档 1 播新真话句 gold_some(不再是「没带回什么」)
assert(p.orderOk('voice/die_intro', 'voice/die_cause_2', 'voice/die_adv_2', 'voice/st_floor_1',
'voice/meta_gold_1', 'voice/settle_stronger', 'voice/settle_again'),
'死亡链序:die_intro → die_cause_2(Boss 重击未格挡)→ die_adv_2 → st_floor → meta_gold → settle_stronger → settle_again');
'voice/gold_some', 'voice/settle_stronger', 'voice/settle_again'),
'死亡链序:die_intro → die_cause_2(Boss 重击未格挡)→ die_adv_2 → st_floor → gold_some(E1-2 band1)→ settle_stronger → settle_again');
assert(game._deathInfo.lastHit.type === 'crab' && game._deathInfo.cause === 2,
`归因指认真凶:lastHit=${game._deathInfo.lastHit.type} → die_cause_2`);
assert(game.meta.gold === 40, `遗产自动购置 90+150+220(500→${game.meta.gold})`);
{
// R3 E1-2:settle 档位与 TITLE 同函数同账本;band1(0<金<50)= gold_some 真话句
const band = game._goldBand(game.meta.gold);
assert(plays(audio, mark).has('voice/meta_gold_' + band),
`R2 D1-5:SETTLE 金币档与 TITLE 同分档函数(_goldBand(meta.gold)=${band})`);
const want = band === 1 ? 'voice/gold_some' : 'voice/meta_gold_' + band;
assert(plays(audio, mark).has(want),
`R2 D1-5/R3 E1-2:SETTLE 金币档 = TITLE 同分档出口(_goldBand=${band} → ${want})`);
}
assert(JSON.stringify(game.meta.unlocks) === JSON.stringify(['potion', 'vigor', 'veil']), '解锁 potion/vigor/veil');
assert(game.meta.bestFloor === 1 && game.meta.runs === 1, 'bestFloor/runs 更新');
@@ -1706,6 +1724,242 @@ async function scenario12() {
}
}
// ================================ 场景 13:R3 修复回归(E0-1 看门狗双轨 / E0-2 跳教学 / E1-1 / E1-2 / 打磨) ================================
/** 找「死胡同」格(开放邻格最少)+ 一面朝向的墙:乱按零位移的保障。 */
function deadEndCell(floor) {
let best = null;
for (let y = 0; y < floor.size; y++) for (let x = 0; x < floor.size; x++) {
if (floor.grid[y][x] !== 1) continue;
let open = 0, wallDir = null;
for (const [dir, d] of Object.entries(DIRS)) {
const nx = x + d.dx, ny = y + d.dy;
if (nx < 0 || ny < 0 || nx >= floor.size || ny >= floor.size || floor.grid[ny][nx] === 0) { wallDir = dir; continue; }
open++;
}
if (!best || open < best.open) best = { x, y, open, wallDir };
}
return best;
}
/** 连续乱按:每 ~1s 一个随机手势;reface 非空时每拍先把脸按回墙(长按只会 bump)。 */
async function mash(game, set, ms, reface = null) {
const t0 = vnow;
while (vnow - t0 < ms) {
if (reface) game.run.face = reface; // 测试钩子:钉死朝墙,保证零位移(零进展)
g(game, set[Math.floor(Math.random() * set.length)]);
await tick(900 + Math.floor(Math.random() * 500));
}
}
async function scenario13() {
origConsoleLog('\n=== 场景 13:R3 修复回归(E0-1 双轨看门狗 / E0-2 跳教学 / E1-2 金币档 / E1-1 锁格到达即报) ===');
// ---- A. E0-1:连续乱按 96s 零进展 → earcon → 短句 → 导航接管(无操作轨被喂饱也升级) ----
{
storageMap.clear();
const { game, audio } = newGame({ seed: 11 });
game.start();
await tick(400);
g(game, 'tap');
await tick(6000);
g(game, 'down');
await tick(2000);
assert(game.state === 'EXPLORE', '场景13A: 跳过教学进探索');
game._mobStep = () => {}; // 静场:乱按不被接战打断
const de = deadEndCell(game._floor);
assert(de && de.wallDir, '场景13A: 找到临墙格(mash 每拍钉死朝墙 → 长按只 bump,零位移保障)');
game.run.pos = { x: de.x, y: de.y };
const m0 = audio.log.length;
await mash(game, ['tap', 'doubletap', 'left', 'right', 'longpress'], 96000, de.wallDir);
const seg = plays(audio, m0);
assert(game.state === 'EXPLORE' && game.run.pos.x === de.x && game.run.pos.y === de.y,
'E0-1: 乱按 96s 全程零位移(无操作轨持续被手势重置——R2 压力局正是被这条饿死)');
assert(seg.has('sfx/turn_tick'), 'E0-1: 无进展 30s 级 → earcon(复用合成回执音,乱按饿不死)');
assert(seg.has('voice/stair_hint_a'), 'E0-1: 无进展 60s 级 → 短句 stair_hint_a(抢救落到人话)');
assert(seg.hasRe(/^voice\/nav_[eswn]$/), 'E0-1: 无进展 90s 级 → 导航接管(播最短路首步方向)');
assert(game._stuck.nav === true, 'E0-1: 接管后进入导航模式(每步校正 + 60s 重报直至进展)');
{
const dir = game._navFirstStepDir(game._floor.stairs);
const nb = DIRS[dir];
assert(dir && game._floor.grid[game.run.pos.y + nb.dy]?.[game.run.pos.x + nb.dx] === 1,
'E0-1: 接管首步方向必可通行(楼梯指向,不指墙)');
}
const vn = seg.countRe(/^voice\//);
assert(vn <= 4, `E0-1: 乱按 96s 语音总量有上界(${vn} 句 ≤4;R1 风暴/2223s 空窗两极都不复现)`);
game.stop();
}
// ---- B. E0-1:有真实进展(格位置持续推进)则全程不触发 ----
{
storageMap.clear();
const { game, audio } = newGame({ seed: 23 });
game.start();
await tick(400);
g(game, 'tap');
await tick(6000);
g(game, 'down');
await tick(2000);
assert(game.state === 'EXPLORE', '场景13B: 跳过教学进探索');
game._mobStep = () => {};
const fl = game._floor;
const from = { ...fl.start };
const nb = Object.entries(DIRS).find(([, d]) => {
const nx = from.x + d.dx, ny = from.y + d.dy;
return fl.grid[ny]?.[nx] === 1 && !fl.items.has(nx + ',' + ny)
&& !(fl.stairs && nx === fl.stairs.x && ny === fl.stairs.y);
});
assert(nb, '场景13B: 起点有无宝无楼梯的可走邻格');
const d0 = nb[1];
const m0 = audio.log.length;
const t0 = vnow;
let legs = 0;
while (vnow - t0 < 100000) {
const back = legs % 2 === 1;
const dx = back ? -d0.dx : d0.dx, dy = back ? -d0.dy : d0.dy;
game.run.face = Object.entries(DIRS).find(([, d]) => d.dx === dx && d.dy === dy)[0];
g(game, 'longpress'); // 起步即走 1 格(真实进展:格位置变化重置无进展轨)
await tick(80);
g(game, 'longpressend');
await tick(560);
legs++;
}
const seg = plays(audio, m0);
assert(!seg.hasRe(/^voice\//) && !seg.has('sfx/turn_tick'),
`E0-1: 有真实进展(${legs} 次格移动 / 100s)零卡关语音零 earcon(不误伤正常玩家)`);
game.stop();
}
// ---- C. E0-2:跳教学种子局主路径密度不变式 + 120s 内必有指向语音(或遭遇) ----
{
storageMap.clear();
const { game, audio } = newGame({ seed: 77 });
game.start();
await tick(400);
g(game, 'tap');
await tick(6000);
g(game, 'down'); // D0 即下滑:跳教学路径
await tick(2000);
assert(game.state === 'EXPLORE' && game.run, '场景13C: 跳教学直达探索(drillDone)');
{
// 主路径密度不变式:对实测 _floor 由测试侧独立重算(生成器对跳教学局一视同仁)
const fl = game._floor;
const dist = bfsDist(fl);
const target = fl.stairs;
const content = new Set([fl.start.x + ',' + fl.start.y, target.x + ',' + target.y,
...fl.items.keys(), ...fl.mobs.map((m) => m.x + ',' + m.y)]);
const path2 = [target];
let cur = { ...target };
while (cur.x !== fl.start.x || cur.y !== fl.start.y) {
for (const d of Object.values(DIRS)) {
const nx = cur.x + d.dx, ny = cur.y + d.dy;
if (nx < 0 || ny < 0 || nx >= fl.size || ny >= fl.size || fl.grid[ny][nx] !== 1
|| dist[ny][nx] !== dist[cur.y][cur.x] - 1) continue;
cur = { x: nx, y: ny };
break;
}
path2.push(cur);
}
let maxGap = 0, run = 0;
for (const c of path2) {
if (content.has(c.x + ',' + c.y)) { maxGap = Math.max(maxGap, run); run = 0; } else run++;
}
maxGap = Math.max(maxGap, run);
assert(maxGap <= 12, `E0-2: 跳教学种子局主路径密度不变式(最长真空 ${maxGap} 格 ≤12)`);
}
game._mobStep = () => {}; // 静场:专验指向语音路径(遭遇路径由场景 12C 覆盖)
const de = deadEndCell(game._floor);
game.run.pos = { x: de.x, y: de.y };
const m0 = audio.log.length;
const t0 = vnow;
await mash(game, ['tap', 'doubletap', 'left', 'right', 'longpress'], 96000, de.wallDir);
const hit = audio.log.slice(m0).find((e) => e.id === 'voice/stair_hint_a' || /^voice\/nav_[eswn]$/.test(e.id));
assert(hit && hit.t - t0 <= 120000,
`E0-2: 跳教学后 ${Math.round((hit ? hit.t - t0 : 1e9) / 1000)}s 内必有指向语音(≤120s;R2 压力局 skip 后 79 分钟零指向)`);
game.stop();
}
// ---- D. E0-2:教学态乱按零进展 90s 级 → 自动滚出教学(R1 乱按局 ~75s 口径恢复) ----
{
storageMap.clear();
const { game, audio } = newGame({ seed: 11 });
game.start();
await tick(400);
g(game, 'tap');
await tick(6000);
assert(game.state === 'DRILL', '场景13D: 进教学');
game._drill.step = 9; // 测试钩子:直落 D9(脉冲等待步——压力局 39 分钟锁点;tap=脉冲零进展)
const m0 = audio.log.length;
const t0 = vnow;
await mash(game, ['tap', 'doubletap'], 98000);
const seg = plays(audio, m0);
assert(game.state === 'EXPLORE' && game.meta.drillDone === true,
`E0-2: 教学乱按零进展 ${Math.round((vnow - t0) / 1000)}s 内自动滚出教学(R2 压力局 2403s → ≤95s)`);
assert(seg.has('sfx/turn_tick'), 'E0-2: 教学乱按 30s 级 earcon 先到');
assert(seg.has('voice/stair_hint_a'), 'E0-2: D9 乱按 60s 级给方向话 stair_hint_a(看门狗长嘴)');
assert(seg.has('voice/tut_skip'), 'E0-2: 接管播 tut_skip(已跳过教学 → 探索态由导航链接管)');
game.stop();
}
// ---- E. E1-2 金币档真话(上滑 + SETTLE + =0 保留空手)+ E1-1 锁格到达即报 ----
{
storageMap.clear();
const { game, audio } = newGame({ seed: 11 });
game.start();
await tick(400);
g(game, 'tap');
await tick(6000);
g(game, 'down');
await tick(2000);
game._mobStep = () => {};
game.meta.gold = 13; // 钩子:0 < 金 < 50(教学拾取后的真实档位)
let mark = audio.log.length;
g(game, 'up');
await tick(6000);
let p = plays(audio, mark);
assert(p.has('voice/gold_some') && !p.has('voice/st_gold_1'),
'E1-2: 上滑金币档 0<金<50 播 gold_some(带回来一点,不多;不再谎报「口袋空空」)');
// E1-1 锁路径:窟主未清的楼梯格,到达即报铁器 + 锁句(死亡前做,run 仍在)
const seed13 = game.run.seed;
game._floor = game._generateFloor(seed13, 3); // 测试钩子:L3(窟主守楼梯)
game.run.floor = 3;
const st3 = game._floor.stairs;
game._onStairs = false;
game._stairsSpokenFloor = 0;
const e0 = audio.log.length;
game._onStairCell(st3.x, st3.y);
await tick(300); // echo_1 即刻、mat_stair = 到达 +120ms 材质尾音
{
const seg = plays(audio, e0);
assert(seg.has('sfx/echo_1') && seg.has('sfx/mat_stair'),
'E1-1: 踏上(被锁的)楼梯格自动播贴身铁器回声(echo_1 + mat_stair)');
assert(game._onStairs === true, 'E1-1: 到达置位 _onStairs(tap 下潜逻辑不受影响)');
}
assert(plays(audio, e0).has('voice/evt_stairs_lock'), 'E1-1: 窟主未清 → 到达即报铁器 + evt_stairs_lock');
// 还原 L1 后死于窟鼠:SETTLE band1 同步 gold_some
game._floor = game._generateFloor(seed13, 1);
game.run.floor = 1;
game.run.hp = 1;
game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 });
await tick(2500);
mark = audio.log.length;
g(game, 'tap'); // 挥击 → 敌前摇 → 反一刀致命
await tick(2200);
assert(game.state === 'SETTLE', '场景13E: 死亡进结算');
await tick(9000);
p = plays(audio, mark);
assert(p.orderOk('voice/die_intro', 'voice/die_cause_1', 'voice/st_floor_1', 'voice/gold_some'),
'E1-2: SETTLE band1(0<金<50)同步播 gold_some(真话:这趟带回了一点)');
// =0 保留空手措辞(SETTLE 上滑重听元进度段走 _titleUpChain 同一出口)
game.meta.gold = 0;
g(game, 'up');
await tick(4000);
assert(plays(audio, mark).has('voice/meta_gold_1'),
'E1-2: 金=0 保留 meta_gold_1 空手措辞(band0 口径不变)');
game.stop();
}
}
// ================================ 资产引用校验 ================================
function assetAudit() {
@@ -1725,7 +1979,7 @@ function assetAudit() {
const male = idsOf('male');
assert(fem.size === male.size && [...fem].every((id) => male.has(id)),
`资产校验: voice 双性别目录集合一致(female ${fem.size} = male ${male.size})`);
assert(fem.size === 125, `资产校验: 台词表 125 句齐全(实际 ${fem.size})`);
assert(fem.size === 127, `资产校验: 台词表 127 句齐全(125 + R3 gold_some/tut_flee_hold,实际 ${fem.size})`);
// 3) game.js 静态引用 id 全量展开校验(模板 id 按数据表展开)
const src = fs.readFileSync(path.join(DUNGEON_DIR, 'game.js'), 'utf8');
@@ -1790,6 +2044,7 @@ try {
await scenario10();
await scenario11();
await scenario12();
await scenario13();
} catch (e) {
failures.push(`未捕获异常: ${e.stack}`);
}