Files
SoundGame/web/harness/sessions/cipher.mjs
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

275 lines
12 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.
/**
* 《暗码》新手会话剧本。
*
* 剧本:
* - `session`(主线):一位夜班报务员的完整弧线——听完开场白与规矩,逐封截获
* 四封密电(听摩尔斯 → 切候选听词义 → 双击重听/慢放 → 两段确认定词),
* 处置抉择(p2 报北风 / p4 呈报 / p6 留书 / p8 装傻 / p9 发最后一电),
* 听完尾声逐封点名,以完整 E1「最后一电」收尾。
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、菜单里乱逛、死命翻密码本,
* 一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 radio/abyss 剧本先例):
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
* - 玩家不干晾自己:只在浏览/链末等待态偶尔走神触发一次看门狗;
* - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* - DECODE 里每步都是真输入:切候选、重听、慢放、确认,像真人一样先听后按;
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(像 tapUntil 一样有界循环),
* 抉择则等提示句播完武装后再滑(盲玩公平,不抢话)。
*/
const TEL_LABEL = ['一', '二', '三', '四'];
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') {
return game.voicePlaying ? '开场白正在播(今晚的目标和处长的夜令)' : '开场白播完了,等第一封电文';
}
if (game.phase === 'RX') {
return game._rxGuard
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳过`
: `第 ${TEL_LABEL[game.tel - 1]} 封电文截获中`;
}
if (game.phase === 'DECODE') {
if (game.confirming) return '进确认了:再单击就定下来,左右滑可以取消';
return game.voicePlaying
? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词/电文`
: `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听候选词,双击重听电文,长按翻密码本`;
}
if (game.choiceArmed) {
return '抉择念完了:向左或向右滑动做出你的决定';
}
if (game.phase === 'RELAY') {
return game.voicePlaying ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
}
if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封';
if (game.phase === 'ENDING') {
return game.awaitingRestart
? '结局和尾声点名都听完了。单击再来一局,下滑开菜单'
: '结局播报中';
}
return '……';
}
// ---------------------------------------------------------------- 有界推进助手
/** 等当前语音链播完(有界等待,绝不裸空转)。 */
async function waitVoice(ctx) {
await ctx.waitCond(() => !ctx.game.voicePlaying);
}
/** RELAY 链推进到抉择武装:语音在播等它播完,链末等待态单击继续(不抢话)。 */
async function untilChoice(ctx, maxSteps = 40) {
const { game } = ctx;
for (let i = 0; i < maxSteps; i++) {
if (game.choiceArmed) return;
if (game.phase === 'DECODE') throw new Error('untilChoice 遇 DECODE(需先解码)');
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('untilChoice 遇菜单/询问');
if (game.voicePlaying) { await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying); continue; }
await ctx.gesture('tap'); // 链末 = 继续
await ctx.waitCond(() => game.choiceArmed || game.voicePlaying || game.phase !== 'RELAY');
}
throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`);
}
/** 从任意叙事态单击推进到 DECODE 浏览武装(穿越 RX/NIGHT 自动链与 RELAY 等待段)。 */
async function toDecode(ctx, maxSteps = 60) {
const { game } = ctx;
for (let i = 0; i < maxSteps; i++) {
if (game.phase === 'DECODE' && game.browseArmed) return;
if (game.choiceArmed) throw new Error('toDecode 遇未应答抉择');
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('toDecode 遇菜单/询问');
if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; }
await ctx.gesture('tap');
await ctx.waitCond(() => game.voicePlaying || game.phase !== 'RX'); // 首播保护窗内 tap 被吞,等真实推进
}
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; i++) {
if (game.phase === 'ENDING' && game.awaitingRestart) return;
if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; }
if (game.phase !== 'RELAY' && game.phase !== 'ENDING') {
await ctx.waitCond(() => game.phase === 'ENDING' || game.voicePlaying);
continue;
}
await ctx.gesture('tap');
await ctx.waitCond(() => game.voicePlaying || game.phase === 'ENDING');
}
throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
}
// ---------------------------------------------------------------- 主线剧本
/** DECODE 小局:慢放听清 → 切候选听词义 → 选 idx → 两段确认。 */
async function decodeWord(ctx, idx, why) {
const { game } = ctx;
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying);
ctx.note(`${why}——先双击听电文,再双击放慢逐字母听`);
await ctx.gesture('doubletap');
await waitVoice(ctx);
await ctx.gesture('doubletap'); // 慢放 0.5×
await ctx.waitVirtual(400); // 慢放播一会(摩尔斯走合成不占语音时钟)
// 逐候选听词义(从当前候选循环到目标)
const cur = game.candIdx;
const delta = (idx - cur + 3) % 3;
for (let i = 0; i < delta; i++) {
ctx.note('左右滑,听密码本上的候选词');
await ctx.gesture('right');
await ctx.waitCond(() => !game.voicePlaying);
}
ctx.note('就这个词——单击选定');
await ctx.gesture('tap'); // 进确认
await waitVoice(ctx);
ctx.note('再单击,定下来');
await ctx.gesture('tap'); // 定下判定
await waitVoice(ctx);
}
/** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */
async function relayChoice(ctx, dir, why) {
const { game } = ctx;
await untilChoice(ctx);
ctx.note(why);
await ctx.gesture(dir);
await waitVoice(ctx);
}
export async function session(ctx, mock) {
const { game } = ctx;
// 开场:INTRO 自动流(夜班开场 + 规矩 + 目标 + 处长夜令)→ RX(1) → DECODE
await ctx.waitCond(() => game.phase === 'RX');
ctx.status('开场白播完了,第一封电文进频道了');
ctx.note('电文滴答声进来了,这是摩尔斯,首播不可跳,听完它');
await toDecode(ctx);
ctx.status('轮到我选词了:双击重听、左右滑听候选');
// T1 船期(教学封:BAY)
await decodeWord(ctx, 0, '教学封:先学规矩——听不清就再听一遍');
// 教学封若误选会回浏览(剧本内不会,保险循环)
for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重新选');
await relayChoice(ctx, 'left', '药品船丑时过江——报给北风,江上有人等这船药');
ctx.status('第一封处置完了,夜里结算');
// T2 名单(TOP)
await toDecode(ctx);
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 relayChoice(ctx, 'right', '留书暗号,让雨替我跑这一趟');
ctx.status('第三封处置完了');
// T4 处置令(SPY · 高潮)
await toDecode(ctx);
ctx.status('第四封电文:快手七的手法——门外的脚步声都近了');
await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我');
await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做');
ctx.status('处长的试探应付过去了,频道要收了');
// 签收:g9_in → g9_knot → p9
await untilChoice(ctx);
await relayChoice(ctx, 'left', '开机,发最后一电——三年前我译错了一封,这次不能错');
await toEnding(ctx);
ctx.status('结局播完了:尾声把今晚每一封都点了名');
ctx.note('单击,收工——但下一班夜班,还想再来');
await ctx.gesture('tap');
await waitVoice(ctx);
ctx.status('新的夜班开始了(再来一局)');
}
// ---------------------------------------------------------------- 乱按压力剧本
export async function sessionStress(ctx, mock) {
const { game } = ctx;
// 开场就乱按:tap/doubletap/方向全来一遍(RX 首播保护窗内也应零死锁)
await ctx.waitCond(() => game.phase === 'RX' && game._rxGuard);
ctx.note('好奇宝宝:首播中乱点乱滑——都应该是轻震,不判无效');
for (const gg of ['tap', 'left', 'right', 'doubletap', 'tap']) {
await ctx.gesture(gg);
await ctx.waitVirtual(300);
}
await toDecode(ctx);
// T1:翻密码本翻到手软(免费页 + 3 页 + 翻空),再译对
ctx.note('先把密码本翻烂:长按翻页,翻到一页不剩');
for (let i = 0; i < 5; i++) {
await ctx.gesture('longpress');
await waitVoice(ctx);
await ctx.waitVirtual(200);
}
ctx.note('乱切候选二十次');
for (let i = 0; i < 20; i++) {
await ctx.gesture(i % 3 === 0 ? 'left' : 'right');
await ctx.waitVirtual(250);
}
ctx.note('双击重听连打到慢放');
for (let i = 0; i < 4; i++) {
await ctx.gesture('doubletap');
await ctx.waitVirtual(300);
}
// 菜单乱逛
ctx.note('下滑开菜单,乱切几项再关掉');
await ctx.gesture('down');
await ctx.waitCond(() => game.phase === 'MENU');
for (let i = 0; i < 6; i++) {
await ctx.gesture('tap');
await ctx.waitVirtual(250);
}
await ctx.gesture('up'); // 菜单内帮助
await ctx.waitCond(() => !game.voicePlaying);
await ctx.gesture('down'); // 关闭(不管停在哪个项,下滑永远安全)
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed);
// 两段确认中途取消再重选
ctx.note('单击进确认,再左滑取消,折腾两回');
for (let i = 0; i < 2; i++) {
await ctx.gesture('tap');
await waitVoice(ctx);
await ctx.gesture('left');
await ctx.waitCond(() => !game.voicePlaying);
}
// 译对(免费提示早用过了,凭耳朵+慢放兜底)
await decodeWord(ctx, 0, '翻到提示也用完了,凭慢放听出来的:货船');
for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重选');
await relayChoice(ctx, 'right', '压进值机日志——这船的事得看清楚再说');
ctx.status('第一封处置完,压力测试过了第一道');
// 剩下三封:正常打但保留一次乱按(每封浏览期乱滑 10 次)
const picks = [0, 0, 0];
const dirs = ['left', 'right', 'right'];
for (let tel = 2; tel <= 4; tel++) {
await toDecode(ctx);
for (let i = 0; i < 10; i++) {
await ctx.gesture('right');
await ctx.waitVirtual(200);
}
await decodeWord(ctx, picks[tel - 2], `第 ${tel} 封:慢放听清再定`);
await relayChoice(ctx, dirs[tel - 2], ['报北风/呈报/留书'][0] || '处置掉');
}
// 签收 p9(stress 也发最后一电)
await untilChoice(ctx);
ctx.note('开机,把今晚发出去——乱按一晚,正经事不含糊');
await ctx.gesture('left');
await waitVoice(ctx);
await toEnding(ctx);
ctx.status('乱按一整晚也走到了结局,零死锁零静默死局');
await ctx.gesture('tap'); // 再来一局
await waitVoice(ctx);
}