/** * 《深夜茶馆》新手会话剧本(26 号重构版:经营+时机型,radio/storyline 范式)。 * * 剧本: * - `session`(主线):一位新掌柜的完整首晚弧线——开场第一句「点一下屏幕」就动手 → * 教学七步跟练(左右滑看柜/单击选定/听水声单击提壶/听滴答单击出汤/单击端茶/ * 上滑状态)→ 自由营业三位客人(邮差/苏晚/陈拾,各泡一盏,靠耳朵掐时机)→ * 打烊对账 → 上滑重听账目 → 单击睡下 → 第二晚开场收束。 * - `sessionStress`(乱按压力):一位手忙脚乱的新掌柜——标题乱划、教学中乱试探 * (教学不可跳过,只能跟做)、心急提前提壶被真话归因重教、菜单乱逛只下滑关闭、 * 乱手势不误判错步——照样泡完一盏茶走到打烊,全程零死锁。 * * 剧本失真红线(radio/storyline 剧本先例): * - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN)之后才出手; * critical 句(教学指令/结算链)播完再动,抢拍只会听到 busy; * - 玩家靠耳朵玩:候汤听水声、闷泡听滴答,时机手势跟着声音走(单击即时,无双击); * - 营业态常驻自动推进:禁止裸 waitIdle 空等,必须 waitCond/waitVirtual 有界等待; * - 菜单里只「下滑关闭」,绝不确认「保存并回大厅」(防误触结束会话)。 */ const TEA_NAME = { longjing: '龙井', moli: '茉莉花茶', tieguanyin: '铁观音', dianhong: '滇红', puer: '普洱', yinzhen: '白毫银针', }; /** 26 号 API:真等待态(链收+话轮已交)。 */ const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN'; export function describe(game) { if (game._menuOpen) { if (game._shopOpen) return '打烊置办里:左右滑看项目,单击买定,下滑返回菜单'; return '菜单里:左右滑切项,单击确认,上滑重听说明,下滑关闭'; } switch (game.state) { case 'TITLE': return game.run ? '检测到没打烊的茶馆:单击继续,右滑两次重开新店' : '标题页:单击开店(第一句就是动手指令),上滑听店铺概况'; case 'OPENING': return '开晚场:晚数句播报中(单击跳句,滑动打断并推进)'; case 'SERVE': { if (game.floor.speaking) return '堂前:客人进门/点单/反应播报中(单击跳句)'; return '堂前空档:上滑问状态,下滑菜单(自动流马上进灶)'; } case 'BREW': { const b = game._brew; if (!b) return '灶前'; if (game._drill) return `教学中(第 ${game._drill.step} 步):跟着旁白做(教学不可跳)`; if (b.sub === 'TEA') return '选茶:左右滑换茶罐(茶名句随身),单击选定'; if (b.sub === 'WATER') return '候汤:听水声,泡泡密起来时单击提壶'; return '闷泡:听滴答,滴答密起来时单击出汤'; } case 'CLOSE': return game._closeChainActive ? '打烊结算链播报中(账目/房租/章节),手势全部 busy 被吞' : '打烊:账报完了,单击睡下,上滑重听账目,下滑菜单(含打烊置办)'; default: return '……'; } } // ---------------------------------------------------------------- 会话工具 /** 泡当前客人:选茶(循环到目标)→ 候汤窗内单击提壶 → 闷泡窗内单击出汤。 */ async function brewTea(ctx, game, tea, note) { await ctx.waitCond(() => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill); const shelf = game._shelf(); let guard = 0; while (game._brew && game._brew.teaIdx >= 0 && shelf[game._brew.teaIdx] !== tea && guard++ < 10) { await ctx.gesture('right'); await ctx.waitVirtual(2500); // 茶罐名句落声 } ctx.note(`茶柜上选定${TEA_NAME[tea]}——单击`); await ctx.gesture('tap'); await ctx.waitCond(() => game._brew && game._brew.sub === 'WATER'); // 候汤:将沸茶 ~3.5s / 滚透茶 ~6.5s(首晚 ×1.3 更宽);听泡变密再提壶 const simmer = tea === 'longjing' || tea === 'yinzhen'; await ctx.waitVirtual(simmer ? 4200 : 6800); ctx.note(note || '水声对了——单击提壶'); await ctx.gesture('tap'); await ctx.waitCond(() => game._brew && game._brew.sub === 'STEEP'); // 闷泡:快 7s / 中 11s / 久 15s,滴答密起入窗 const steepAt = { longjing: 7000, moli: 7000, yinzhen: 11000, tieguanyin: 11000, dianhong: 15000, puer: 15000 }[tea] || 7000; await ctx.waitVirtual(steepAt - 800); ctx.note('滴答密了——单击出汤'); await ctx.gesture('tap'); await ctx.waitCond(() => !game.audio.isVoicePlaying() && !game.floor.speaking); // 判定链+反应链收尾 } // ---------------------------------------------------------------- 主线剧本 export async function session(ctx, mock) { const { game } = ctx; // —— 开场(§4.10):第一句就是动手指令「点一下屏幕。」—— await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); ctx.status('深夜茶馆:巷子最深处,一盏亮到后半夜的灯'); ctx.note('第一句就让我动手——点一下'); await ctx.gesture('tap'); // 开店 → 「轮到你了」状态声 → OPENING // —— 教学 DRILL 七步(单击版:跟旁白做,一次一个动作)—— await ctx.waitCond(() => game.state === 'BREW' && game._drill && game._drill.step === 0 && game.floor.state === 'YOUR_TURN'); ctx.status('头一晚。老周进门要一盏龙井——旁白带着我一步一步来'); ctx.note('右滑——先看茶柜,一罐一罐听'); await ctx.gesture('right'); // D0:换罐即学「单击选定」 await ctx.waitCond(() => game._drill && game._drill.step === 1); ctx.note('滑回龙井——老周要的就是它'); await ctx.gesture('left'); await ctx.waitVirtual(2500); ctx.note('单击,选定这罐龙井'); await ctx.gesture('tap'); // D1 → 教学句说完自动坐水 await ctx.waitCond(() => game._drill && game._drill.step === 2); await ctx.waitVirtual(3600); // 听觉分层:教学句说完水声才起,等泡泡密起来 ctx.status('灶上坐水了。听——泡泡细密起来,就是提壶的时候'); ctx.note('泡泡细密了——单击,提壶'); await ctx.gesture('tap'); // D2(窗内) await ctx.waitCond(() => game._drill && game._drill.step === 3); await ctx.waitVirtual(6400); // 闷泡滴答从密到缓,龙井快冲 ~7s ctx.note('滴答声密起来——单击,出汤'); await ctx.gesture('tap'); // D3(窗内) await ctx.waitCond(() => game._drill && game._drill.step === 4); ctx.status('这一盏泡得地道。茶好了,端出去'); ctx.note('单击,端出去'); await ctx.gesture('tap'); // D4 → 反应链 → 第一盏茶送出 await ctx.waitCond(() => game._drill && game._drill.step === 5 && game.floor.state === 'YOUR_TURN'); ctx.status('链尾教了「想知道自己在哪,就向上滑」'); ctx.note('上滑——听听今晚的局面'); await ctx.gesture('up'); // D5:状态链(点单句+客人序+口碑+存款) await ctx.waitCond(() => game._drill === null); ctx.status('都会了。教学完成,往后每一晚都这么泡'); // —— 自由营业:邮差(茉莉)/ 苏晚(滇红)/ 陈拾(茉莉)—— await brewTea(ctx, game, 'moli', '邮差要滚烫的茉莉——水滚透再提'); ctx.status(`邮差喝得很慢,多搁下两文。进账 ${game.run ? game.run.incomeTonight : '—'} 文`); await brewTea(ctx, game, 'dianhong', '苏晚要暖的滇红——滚透再冲'); ctx.status(`苏晚说像被窝里塞了个汤婆子。进账 ${game.run ? game.run.incomeTonight : '—'} 文`); await brewTea(ctx, game, 'moli', '陈拾要香的茉莉提神'); ctx.status(`陈拾把早饭钱花在了这盏茶上。进账 ${game.run ? game.run.incomeTonight : '—'} 文`); // —— 打烊:账目链 → 上滑重听 → 单击睡下 —— await ctx.waitCond(() => game.state === 'CLOSE' && game._closeDone); await ctx.waitCond(() => atWait(game)); // settle_night 落声 + 睡下等待点武装 ctx.status('客人走尽了。账报完了——今晚进账都在链上'); ctx.note('上滑,再听一遍账目'); await ctx.gesture('up'); await ctx.waitCond(() => atWait(game)); ctx.note('单击,睡下'); await ctx.gesture('tap'); // 睡下 → N2(链尾教「向下滑,可以打开菜单」) await ctx.waitCond(() => game.state === 'OPENING'); ctx.status('第二晚。老客们已经认得这条巷子了——今晚的营业收工'); } // ---------------------------------------------------------------- 乱按压力剧本 // 目标:全程零死锁、零静默死局,一样能走到打烊睡下。 // 剧本特征:乱划乱点、抢拍跳句、心急提前提壶、菜单乱逛,但仍保留「最终能通关」的底线。 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20260914; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; // ① 标题乱划(上滑/下滑还没解锁——只听到 invalid 回执和一句解释),然后开店 for (let i = 0; i < 6 && game.state === 'TITLE'; i++) { ctx.status('标题页乱划一气'); await ctx.gesture(rng() < 0.5 ? 'left' : 'up'); await ctx.waitVirtual(600); } ctx.note('不划了——点一下,开店'); await ctx.gesture('tap'); // ② 教学:乱试探几下(教学不可跳过,下滑只会拿到无效回执),然后跟做 await ctx.waitCond(() => game.state === 'BREW' && game._drill && game._drill.step === 0 && game.floor.state === 'YOUR_TURN'); for (let i = 0; i < 5 && game._drill && game._drill.step === 0; i++) { ctx.status('教学中乱按——旁白不理我,还在等我跟做'); await ctx.gesture(['doubletap', 'down', 'left', 'tap', 'right'][i]); await ctx.waitVirtual(900); } ctx.status('教学跳不过去,只能照做——一次一个动作'); await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); if (game._drill && game._drill.step === 0) { await ctx.gesture('right'); // D0 await ctx.waitCond(() => game._drill && game._drill.step === 1 && game.floor.state === 'YOUR_TURN'); } if (game._drill && game._drill.step === 1) { // 心急提前点/乱滑几下也无妨(D1 只认单击选定) await ctx.gesture(rng() < 0.5 ? 'left' : 'right'); await ctx.waitVirtual(2000); await ctx.gesture('tap'); // D1 } await ctx.waitCond(() => game._drill && game._drill.step === 2); // ③ 心急提前提壶:窗外单击 → 真话归因 + 重教,再来一次点对 ctx.note('水还没响透就点——听归因怎么说'); await ctx.waitVirtual(1200); await ctx.gesture('tap'); await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); if (game._drill && game._drill.step === 2 && game._brew && game._brew.water === null) { ctx.status('偏早被真话归因了:这一步偏了,水声重起——听密了再提'); await ctx.waitVirtual(3600); await ctx.gesture('tap'); } await ctx.waitCond(() => game._drill && (game._drill.step === 3 || game._drill.step === 4)); if (game._drill && game._drill.step === 3) { await ctx.waitVirtual(6600); ctx.note('滴答密了——单击,出汤'); await ctx.gesture('tap'); // D3 } await ctx.waitCond(() => game._drill && game._drill.step === 4 && game.floor.state === 'YOUR_TURN'); ctx.note('单击,端出去'); await ctx.gesture('tap'); // D4 await ctx.waitCond(() => game._drill === null || (game._drill && game._drill.step === 5)); if (game._drill && game._drill.step === 5) { ctx.note('上滑,把状态链听完'); await ctx.gesture('up'); // D5 await ctx.waitCond(() => game._drill === null); } ctx.status('乱按也被带着走完了教学——核心动词始终可达'); // ④ 自由营业第一位:手痒开菜单乱逛(只敢下滑关掉),照客人要的泡 await ctx.waitCond(() => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill && game.floor.state === 'YOUR_TURN'); if (rng() < 0.8) { ctx.note('手痒开菜单乱逛——只敢下滑关掉'); await ctx.gesture('down'); await ctx.waitVirtual(2500); await ctx.gesture('right'); await ctx.waitVirtual(2200); await ctx.gesture('down'); // 关菜单回灶前 await ctx.waitVirtual(800); } const want = game._guest ? game._guest.tea : 'longjing'; const shelf = game._shelf(); let guard = 0; while (game._brew && game._brew.sub === 'TEA' && shelf[game._brew.teaIdx] !== want && guard++ < 10) { await ctx.gesture('right'); await ctx.waitVirtual(2500); } if (game._brew && game._brew.sub === 'TEA') { ctx.note(`不折腾了,照客人要的${TEA_NAME[want]}泡`); await ctx.gesture('tap'); await ctx.waitCond(() => game._brew && game._brew.sub !== 'TEA'); } if (game._brew && game._brew.sub === 'WATER') { // 时机窗内乱滑不判错步(分层铁律:只有回执声,水声照走)——滑两下再提 await ctx.gesture('left'); await ctx.waitVirtual(400); await ctx.gesture('right'); await ctx.waitVirtual((want === 'longjing' || want === 'yinzhen') ? 4200 : 6800); ctx.note('水声对了——单击提壶'); await ctx.gesture('tap'); await ctx.waitCond(() => game._brew && game._brew.sub === 'STEEP'); } if (game._brew && game._brew.sub === 'STEEP' && game._brew.steep === null) { const steepAt = { longjing: 7000, moli: 7000, yinzhen: 11000, tieguanyin: 11000, dianhong: 15000, puer: 15000 }[want] || 7000; await ctx.waitVirtual(steepAt - 800); await ctx.gesture('tap'); } await ctx.waitCond(() => !game.audio.isVoicePlaying() && !game.floor.speaking); ctx.status(`这盏泡${game.run && game.run.results[game.run.results.length - 1] === 'good' ? '得地道' : '过去了'}——故事照样走`); // ⑤ 后续客人:全部尽快端茶(水闷听天由命),走到打烊 let sg = 0; while (game.state !== 'CLOSE' && sg++ < 4) { // 最后一位泡完会直接进 CLOSE:等待条件必须带上这个出口 await ctx.waitCond(() => (game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill) || game.state === 'CLOSE'); if (game.state !== 'BREW') break; const w2 = game._guest ? game._guest.tea : 'longjing'; const sh2 = game._shelf(); let g2 = 0; while (game._brew && game._brew.sub === 'TEA' && sh2[game._brew.teaIdx] !== w2 && g2++ < 10) { await ctx.gesture('right'); await ctx.waitVirtual(2200); } await ctx.gesture('tap'); await ctx.waitCond(() => game._brew && game._brew.sub === 'WATER'); await ctx.waitVirtual((w2 === 'longjing' || w2 === 'yinzhen') ? 4200 : 6800); await ctx.gesture('tap'); await ctx.waitCond(() => game._brew && game._brew.sub === 'STEEP'); await ctx.waitVirtual(6500); await ctx.gesture('tap'); await ctx.waitCond(() => !game.audio.isVoicePlaying() && !game.floor.speaking); } // ⑥ 打烊:结算链播报中乱按(链锁全吞),链完单击睡下 await ctx.waitCond(() => game.state === 'CLOSE'); ctx.status('打烊链在播——乱按一气(链锁内应该全被吞掉)'); // 只在链播中乱按(closeDone 即停:等待态里 tap/doubletap 会真的睡下) for (let i = 0; i < 5 && game._closeChainActive; i++) { await ctx.gesture(['left', 'up', 'down', 'tap', 'right'][i % 5]); await ctx.waitVirtual(500); } await ctx.waitCond(() => game._closeDone); await ctx.waitCond(() => atWait(game)); // settle_night 落声 + 睡下等待点武装 if (game.state === 'CLOSE') { ctx.note('单击,睡下'); await ctx.gesture('tap'); } await ctx.waitCond(() => game.state === 'OPENING'); ctx.status('乱按一路也睡下了:结算链完整、零死锁,压力测试收工'); }