Files
SoundGame/web/harness/sessions/cipher.mjs
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

319 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗码》新手会话剧本(26 号重构版:叙事+解码型,radio 范式)。
*
* 剧本:
* - `session`(主线):一位夜班报务员的完整弧线——开场第一句「点一下屏幕。」就动手 →
* 「轮到你了」状态教学 → 第一封电文摩尔斯首播(BUSY 不可跳)→ 浏览候选
* (左右滑自动预听对照档 → 静默一拍 → 词义句)→ 单击进确认(电文原速重播)→
* 再单击定下;四封电文各做一次处置抉择(p2 报北风 / p4 呈报 / p6 留书 /
* p8 装傻 / p9 发最后一电),中途主动重听一次选项(「再说一遍:」)、
* 上滑问一次状态(第几封 → 密码本余页 → 暴露档)、走神一次听三档提示、
* 结局后逛一圈菜单(阶段 2 下滑此时才真解锁),以完整 E1「最后一电」收尾。
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、确认态反复进出、菜单里乱逛、
* 阶段 3 双击把电文听到慢放,一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 radio/storyline 剧本先例):
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
* 出手——BUSY 演出段(电文摩尔斯首播 / T4 danger 拍前置 2s / 确认电文重播)
* 真机里是 busy 回执,剧本同样等它走完;
* - 玩家不干晾自己:只在叙事等待态偶尔走神触发一次等待点提示;
* - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* - DECODE 里每步都是真输入:切候选听对照、进确认、定下,像真人一样先听后按
* (旧「双击重听/慢放、长按翻密码本」口径已废止:双击=阶段 3,翻本走菜单项);
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(有界循环),抉择则等提示句
* 播完武装后再滑(盲玩公平,不抢话)。
*/
const TEL_LABEL = ['一', '二', '三', '四'];
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
const atChoice = (game) => atWait(game) && wpId(game) === 'choice';
const atSettle = (game) => game.phase === 'ENDING' && game.awaitingRestart;
export function describe(game) {
if (game.leaving) return '已确认保存并回大厅';
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的夜班」的询问';
if (game.phase === 'MENU') {
const names = ['回到电波', '翻密码本', '重新开始今晚', '保存并回大厅'];
return `菜单里,当前项「${names[game.menuIdx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
}
if (game.phase === 'INTRO') {
if (game.floor.speaking) {
return game.runs <= 1 ? '开场指令正在播:「点一下屏幕。」' : '新夜班口播正在播';
}
return '等待声在响:点一下,接第一封电文';
}
if (game.phase === 'RX') {
return game._rxGuard
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳(手势只有 busy 回执)`
: `第 ${TEL_LABEL[game.tel - 1]} 封电文截获中`;
}
if (game.phase === 'DECODE') {
if (game.confirming) return '进确认了:电文正在原速重播,再单击就定下来,左右滑可以取消';
return game.floor.speaking
? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词(摩尔斯对照档 → 一拍 → 词义)`
: `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听对照档,单击进确认`;
}
if (atChoice(game)) {
return `抉择念完了(第 ${TEL_LABEL[game.tel - 1]} 封处置):向左或向右滑做出你的决定`;
}
if (game.phase === 'RELAY') {
return game.floor.speaking ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
}
if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封';
if (game.phase === 'ENDING') {
return atSettle(game)
? '结局和尾声点名都听完了。单击开新夜班,下滑开菜单'
: '结局播报中';
}
return '……';
}
// ---------------------------------------------------------------- 有界推进助手
/** RELAY 链推进到抉择武装:语音在播等它播完,链末等待态单击继续(不抢话)。 */
async function untilChoice(ctx, maxSteps = 40) {
const { game } = ctx;
for (let i = 0; i < maxSteps; i++) {
if (atChoice(game)) return;
if (game.phase === 'DECODE') throw new Error('untilChoice 遇 DECODE(需先解码)');
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('untilChoice 遇菜单/询问');
await ctx.waitCond(() => atChoice(game) || atWait(game));
if (atChoice(game)) return;
await ctx.gesture('tap'); // 链末 = 继续
}
throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`);
}
/**
* 从任意叙事态推进到 DECODE 浏览武装(穿越 RX 首播摩尔斯 / NIGHT 自动链 /
* RELAY 继续等待段;T4 danger 拍前置 2s 的 BUSY 演出窗也在这里等过去)。
*/
async function toDecode(ctx, maxSteps = 60) {
const { game } = ctx;
for (let i = 0; i < maxSteps && !(game.phase === 'DECODE' && game.browseArmed); i++) {
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('toDecode 遇菜单/询问');
if (game.phase === 'ENDING') throw new Error('toDecode 遇 ENDING');
if (game.phase === 'DECODE') { await ctx.waitCond(() => game.browseArmed); continue; }
if (atWait(game)) { await ctx.gesture('tap'); continue; }
// 自动流(摩尔斯首播/夜链/danger 前置):话轮不在手上,有界等推进或回到等待态
await ctx.waitCond(() => (game.phase === 'DECODE' && game.browseArmed) || atWait(game));
}
if (!(game.phase === 'DECODE' && game.browseArmed)) {
throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`);
}
}
/** 从 p9 抉择后推进到 ENDING 结算态(d9 结果链 → 结局链 → 尾声点名,自动流转)。 */
async function toEnding(ctx, maxSteps = 40) {
const { game } = ctx;
for (let i = 0; i < maxSteps && !atSettle(game); i++) {
if (game.phase === 'ENDING') { await ctx.waitCond(() => atSettle(game)); continue; }
if (atWait(game)) { await ctx.gesture('tap'); continue; }
await ctx.waitCond(() => atSettle(game) || atWait(game) || game.phase === 'ENDING');
}
if (!atSettle(game)) throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
}
/** 等候选词义句听完(自动预听链:候选摩尔斯 → 静默一拍 → 词义句;被让路则自然跳过)。 */
async function listenCand(ctx) {
const { game } = ctx;
await ctx.waitCond(() => game.floor.speaking || atWait(game));
await ctx.waitCond(() => atWait(game));
}
/**
* DECODE 小局(26 号口径):听完当前候选预听 → 左右滑把候选对到 idx(每切自动
* 预听对照档)→ 单击进确认(电文原速重播)→ 再单击定下。
*/
async function decodeWord(ctx, idx, why) {
const { game } = ctx;
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && atWait(game));
ctx.note(`${why}——先把候选一个一个对耳朵`);
await listenCand(ctx);
const delta = (idx - game.candIdx + 3) % 3;
for (let i = 0; i < delta; i++) {
await ctx.gesture('right'); // 切候选:候选摩尔斯对照档(620Hz)→ 一拍 → 词义句
await listenCand(ctx);
}
ctx.note('就这个词——单击,进确认(电文会再拍一遍原速)');
await ctx.gesture('tap');
await ctx.waitCond(() => wpId(game) === 'confirm' && atWait(game));
ctx.note('对上了。再单击,定下来');
await ctx.gesture('tap');
}
/** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */
async function relayChoice(ctx, dir, why) {
await untilChoice(ctx);
ctx.note(why);
await ctx.gesture(dir);
const { game } = ctx;
await ctx.waitCond(() => game.floor.speaking || !game.phase.startsWith('RELAY') || atWait(game));
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 风铃 + 等待声 → 第一下单击
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
ctx.status('开场第一句就是指令「点一下屏幕。」,没有规则念白');
ctx.note('等待声就是「轮到你了」——点一下,接第一封');
await ctx.gesture('tap');
ctx.status('「听到这个声音,就是轮到你了。」说完,调谐声里进来了第一封电文');
await toDecode(ctx);
ctx.status('第一封摩尔斯拍完了,教程只教一句:向右滑,听下一个词');
// T1 船期(教学封:BAY;译错=即时归因+无限重选零惩罚,本线一次译对)
await decodeWord(ctx, 0, '教学封 BAY:货船。B,一长三短');
ctx.status('「货船,译对了。」链尾还教了「想知道自己在哪,就向上滑」');
await untilChoice(ctx);
ctx.note('药品船丑时过江——先点一下,让它把选项再说一遍');
await ctx.gesture('tap'); // 抉择点空点 = 主动重听(「再说一遍:」+ 完整句)
await ctx.waitCond(() => atWait(game));
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
await ctx.waitCond(() => atChoice(game));
ctx.note('报给北风,江上有人等这船药');
await ctx.gesture('left');
ctx.status('第一封处置完了,夜结算把开场没讲的规矩补在链首');
// T2 名单(TOP)
await toDecode(ctx);
ctx.status('第二封电文:内部通报,手法很急');
ctx.note('上滑问一句「我在哪」——第几封、密码本还剩几页、今晚危不危险');
await ctx.gesture('up');
await ctx.waitCond(() => atWait(game) || game.phase !== 'DECODE');
await ctx.waitCond(() => atWait(game));
ctx.status('状态链念完:第二封 · 密码本三页 · 还没人看我第二眼');
await decodeWord(ctx, 0, '黑名单。头一个代号是码头——我的交通员');
await relayChoice(ctx, 'right', '名单按流程呈报处长——码头,自己多保重');
ctx.status('第二封处置完了,卷宗里落了笔');
// T3 交接(HAND):走神一次,听三档提示的第一档
await toDecode(ctx);
ctx.status('第三封电文:很短,手法有点抖');
await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答');
await untilChoice(ctx);
ctx.silence(15, '犹豫要不要亲自跑一趟,在想值班室空岗的后果');
await ctx.waitVirtual(15600); // continue 等待点 13s 档:「单击,继续。」
ctx.status('听到一声「单击,继续」,马上点');
await ctx.gesture('tap');
ctx.note('留书暗号,让雨替我跑这一趟');
await ctx.gesture('right');
ctx.status('第三封处置完了,交接赶上了');
// T4 处置令(SPY · 高潮:danger 拍前置 2s,脚步声渐强,手势全是 busy)
await toDecode(ctx);
ctx.status('第四封电文:快手七的手法——门外的脚步声都近了');
await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我');
ctx.note('揭晓前的两秒,只有渐强的脚步声:这是危机拍,什么手势都打断不了');
await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做');
ctx.status('处长的试探应付过去了,频道要收了');
// 签收:g9_in → g9_knot → p9
await untilChoice(ctx);
ctx.note('开机,发最后一电——三年前我译错了一封,这次不能错');
await ctx.gesture('left');
await toEnding(ctx);
ctx.status('结局播完了:尾声把今晚每一封都点了名');
// 第一局打完:下滑菜单此刻才真解锁(教学句挂在结局链尾),进去逛一圈
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
await ctx.gesture('down');
await ctx.waitCond(() => game.phase === 'MENU' && atWait(game));
ctx.status('菜单名和第一项「回到电波」报完了');
await ctx.gesture('right'); // 下一项报名:「翻密码本」(长按捷径废止后的正门)
await ctx.waitCond(() => atWait(game));
ctx.status('听完「翻密码本」的报名,不动它');
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
await ctx.waitCond(() => atSettle(game));
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
ctx.note('单击,收工——但下一班夜班,还想再来');
await ctx.gesture('tap');
await ctx.waitCond(() => game.floor.speaking || atWait(game));
ctx.status('新的夜班开始了(再来一局,不再教「点一下屏幕」)');
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
// 剧本特征:不读提示、快速跳句、确认态反复进出、菜单里乱逛、错手势试探,
// 但仍保留「最终能通关」的底线(菜单里绝不单击确认,避免误触「重开/回大厅」)。
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260902;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
let menuOpenCount = 0;
let choiceCount = 0;
let decodeCount = 0;
let noiseCount = 0;
let browseIdle = 0; // 浏览态连续没进展的步数(防乱按卡死在某一封)
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
await ctx.gesture('tap');
for (let i = 0; i < 400 && !game.leaving; i++) {
// 话轮不在手上(摩尔斯首播/danger 前置/自动链):有界等,绝不空转
await ctx.waitCond(() => atWait(game) || game.phase === 'ENDING');
if (game.leaving) break;
if (game.phase === 'MENU') {
menuOpenCount++;
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名)
await ctx.waitCond(() => atWait(game));
await ctx.gesture('down'); // 下滑关闭(不单击确认,绝不误触「重开/回大厅」)
continue;
}
if (atSettle(game)) break;
if (game.phase === 'ENDING') {
await ctx.waitCond(() => atSettle(game));
continue;
}
if (atChoice(game)) {
choiceCount++;
if (rng() < 0.35) {
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听选项/问状态/无效)
noiseCount++;
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
await ctx.waitCond(() => atWait(game));
}
await ctx.gesture(pick(['left', 'right'])); // 乱选也要选,剧情照走
continue;
}
if (game.phase === 'DECODE') {
// 浏览:切候选/问状态/阶段 3 双击重听电文原速→慢放,都可乱来;
// 确认态:单击=定下(乱按也会收敛——T1 译错即时归因无限重选)
decodeCount++;
if (game.confirming || browseIdle >= 4 || rng() < 0.4) {
browseIdle = 0;
await ctx.gesture('tap');
} else {
browseIdle++;
await ctx.gesture(pick(['left', 'right', 'up', 'doubletap', 'tap']));
}
// 电文重播/预听链是有界自动流:等它落回等待态或进下一阶段
await ctx.waitCond(() => atWait(game) || game.phase === 'RELAY' || game.phase === 'NIGHT');
continue;
}
// 叙事等待态 / RX 外的静默帧:乱划一下(无效手势也要有反馈,零静默)
noiseCount++;
await ctx.gesture(pick(['tap', 'left', 'right', 'up', 'down', 'doubletap']));
}
await ctx.waitCond(() => atSettle(game) || game.leaving);
ctx.status(`乱按剧本收尾:解码小局 ${decodeCount} 步、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
}