Files
SoundGame/web/harness/sessions/keeper.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

239 lines
11 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`(主线):一位守塔人的完整风暴夜——听完开场(塔名/规矩/目标)、
* 三个幕内接满电台抉择(全走心线:接住人→翻暗格→口诀→真话→堵漏→读完缺页
* →出海接人→循渔歌→说出真相),三段回忆插叙全听,以完整 E1「灯与归人」收尾。
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
* 一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 radio/abyss 剧本先例):
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
* - 玩家不干晾自己:只在 WATCH 链末等待态偶尔走神触发一次看门狗,链一停就接话;
* - CALM 幕间有喘息定时器、MEMORY 有进入拍定时器,禁止裸 waitIdle 空等,
* 必须 waitCond 有界等待;
* - 叙事链每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
*/
const ACT_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 === 'MEMORY') {
const era = game.timeline === 'mem_f' ? '父亲的最后一晚(十二年前)' : '祖父的年代(四十年前)';
return game.voicePlaying
? `回忆插叙中(${era}):唱针落下,老唱片底噪,听完自动回到现在`
: `回忆插叙的静默拍(${era})`;
}
if (game.phase === 'CALM') {
return `第${ACT_LABEL[(game.act || 1) - 1]}幕结束,幕间喘息:雾角 + 岸台通报${game.voicePlaying ? '正在播' : ',等我单击或自动进下一幕'}`;
}
if (game.choiceArmed) {
return `「请选择」念完了(第${ACT_LABEL[(game.act || 1) - 1]}幕):向左或向右滑动做出你的决定`;
}
if (game.voicePlaying) return `第${ACT_LABEL[(game.act || 1) - 1]}幕值守中,正在听电台/岸台说话`;
if (game.phase === 'WATCH') return `对方说完了,等我接话(第${ACT_LABEL[(game.act || 1) - 1]}幕):单击继续`;
if (game.phase === 'ENDING') {
return game.awaitingRestart
? '结局和尾声回响都听完了。单击再来一夜,下滑开菜单'
: '结局播报中(岸台记一笔:这一晚后来怎样了)';
}
return '……';
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
// 开场:INTRO 自动流(塔名 + 规矩 + 目标)→ 链末单击 → C1
await ctx.waitCond(() => game.phase === 'WATCH' || game.choiceArmed);
ctx.status('开场口令和塔名播完了,等我单击开始值守');
ctx.note('雾角两长一短——塔在,灯在。单击,接过这一夜');
await ctx.gesture('tap');
ctx.status('进入第一幕,电台里有动静');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('岸台说频道里有动静,像隔着整片海');
let invalidTried = false;
let menuDone = false;
let walkedAway = false;
let helpDone = false;
let choiceIdx = 0;
// 抉择路径:全走心线(接住人/翻暗格/口诀/真话/堵漏/读完/出海/循渔歌/说出 → e1)
const choices = [
{ dir: 'left', why: '他的声音在抖——先接住这个人,船位待会儿再要' },
{ dir: 'left', why: '他提到四十年前……去翻灯座下的暗格,旧账该见光了' },
{ dir: 'left', why: '他快撑不住了——用守塔人的口诀带他稳船' },
{ dir: 'left', why: '他问起我的人……告诉他真话:我父亲也是守塔人' },
{ dir: 'left', why: '船板裂了——让他回舱堵漏,守住电台' },
{ dir: 'left', why: '祖父的最后一页就在灯下——今晚读完它' },
{ dir: 'left', why: '浪墙压过来了——给灯上满发条,驾接驳艇去接他' },
{ dir: 'left', why: '海上没有灯——循着他哼的渔歌找他' },
{ dir: 'left', why: '他问起四十年前的灯……把缺页的事,连根说给他' },
];
for (let i = 0; i < 300 && !game.leaving; i++) {
// 只等当前语音链播完或抉择武装;CALM/MEMORY 有定时器,绝不用裸 waitIdle
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
if (game.leaving) break;
if (game.choiceArmed) {
const pick = choices[choiceIdx];
if (!pick) break; // 抉择走完(理论上到 p9 后应已进 ENDING)
if (!invalidTried) {
invalidTried = true;
ctx.note(pick.why + '——但先上滑听一遍现在在哪、他信我几分');
await ctx.gesture('up');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('听完状态链(第几幕/信任档/真相度),游戏自动重播了选项');
await ctx.waitCond(() => game.choiceArmed);
}
if (!menuDone) {
menuDone = true;
ctx.note('下滑看看菜单有什么');
await ctx.gesture('down');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('菜单里,切一项听听');
await ctx.gesture('tap'); // 切一项听听(报名接在当前报名之后,不截断)
await ctx.waitCond(() => !game.voicePlaying);
ctx.note('听完了,下滑关闭菜单,回到选项');
await ctx.gesture('down'); // 下滑关闭(不依赖菜单项序号,绝不误触「重开/回大厅」)
await ctx.waitCond(() => game.choiceArmed);
}
ctx.status('选项念完了,做决定');
ctx.note(pick.why);
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
choiceIdx++;
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
continue;
}
if (game.phase === 'CALM') {
ctx.status(`第${ACT_LABEL[(game.act || 1) - 1]}幕过去,雾角报平安,岸台通报风暴进程`);
ctx.note('幕间喘口气,听风往哪走');
await ctx.waitVirtual(2600);
continue;
}
if (game.phase === 'MEMORY') {
ctx.note('唱针落下了……这是回忆,别慌,听完风声就回来');
await ctx.waitCond(() => game.phase !== 'MEMORY');
ctx.status('风声回来了,回到现在');
continue;
}
// WATCH 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
if (!walkedAway && game.phase === 'WATCH' && !game.choiceArmed) {
walkedAway = true;
ctx.silence(25, '听到一半走了神,在想父亲那支手电');
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
ctx.status('听到一声轻提醒和「单击屏幕,继续」,马上点');
await ctx.gesture('tap');
continue;
}
if (!helpDone && game.nodeId === 'amo_c') {
helpDone = true;
ctx.note('他爷爷四十年前在这片海没的……这句话有分量,双击重听一遍');
await ctx.gesture('doubletap');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('重听完,接着值守');
}
await ctx.gesture('tap'); // 链末单击 = 继续
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
ctx.status('结局链走完,尾声把这一夜逐项点名——阿澈、灯的记录簿、祖父的日志、父亲的班次');
ctx.note('听完岸台那笔账——人和真相都接住了(E1 灯与归人),会话结束');
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
// 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但仍保留「最终能通关」的底线。
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260914;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap'];
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
let menuOpenCount = 0;
let choiceCount = 0;
let answered = 0;
let noiseCount = 0;
let memCount = 0;
for (let i = 0; i < 400; i++) {
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
if (game.leaving) break;
if (game.phase === 'MENU') {
menuOpenCount++;
await ctx.gesture('tap'); // 乱切换
await ctx.waitCond(() => !game.voicePlaying);
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
continue;
}
if (game.choiceArmed) {
choiceCount++;
if (rng() < 0.35) {
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势
noiseCount++;
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
}
await ctx.gesture(pick(['left', 'right']));
continue;
}
if (game.phase === 'CALM') {
await ctx.waitVirtual(2600);
continue;
}
if (game.phase === 'MEMORY') {
memCount++;
if (rng() < 0.4) {
// 回忆里也手痒:双击重听当前回忆节点 / 下滑开菜单再关
noiseCount++;
await ctx.gesture(rng() < 0.5 ? 'doubletap' : 'down');
await ctx.waitCond(() => !game.voicePlaying || game.phase !== 'MEMORY');
if (game.phase === 'MENU') {
await ctx.gesture('down'); // 关掉,回忆还在原地等我
await ctx.waitCond(() => game.phase === 'MEMORY' || game.phase === 'WATCH');
}
}
await ctx.waitCond(() => game.phase !== 'MEMORY');
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
break;
}
// WATCH 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
noiseCount++;
answered++;
await ctx.gesture(pick(gestures));
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次、穿过 ${memCount} 段回忆`);
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
}