import type { ForkBranchRequest, ForkBranchResult, PlayerView, PresentationBeat, TurnRequest, TurnResult } from "@contracts"; const DEMO_LATENCY_MS = 120; type DemoPhase = "promise" | "investigate" | "enter" | "return" | "regular"; function nextNodeId(currentNodeId: string): string { const match = currentNodeId.match(/^(.*?)(\d+)$/); if (!match) return `${currentNodeId}_next`; const [, prefix, digits] = match; return `${prefix}${String(Number(digits) + 1).padStart(digits.length, "0")}`; } function demoPhase(request: TurnRequest, currentView: PlayerView): DemoPhase { if ( request.input.includes("天亮前") && request.input.includes("回来") && !currentView.promises.some((promise) => promise.status === "accepted") ) { return "promise"; } if (currentView.history.some((node) => node.label === "进入封锁隧道")) return "return"; if (currentView.knowledge.some((record) => record.id.startsWith("knowledge_maintenance_door"))) { return "enter"; } if (currentView.promises.some((promise) => promise.status === "accepted")) return "investigate"; return "regular"; } function replyBeats(request: TurnRequest, phase: DemoPhase): PresentationBeat[] { if (phase === "promise") { return [ { id: `${request.actionId}_player`, kind: "action", speaker: "你", text: request.input, visual: null }, { id: `${request.actionId}_nana`, kind: "dialogue", speaker: "娜娜", text: "娜娜看了你一会儿,终于松开攥紧外套的手。“好。我等你到天亮。”", visual: { character: "nana", expression: "relieved", pose: "holding_coat", scene: null } } ]; } if (phase === "investigate") { return [ { id: `${request.actionId}_search`, kind: "narration", speaker: null, text: "你打开旧手电,沿着站台边缘寻找。斜光扫过积水,一道检修门和半张旧车票显了出来。", visual: { character: null, expression: null, pose: null, scene: "station_maintenance_door" } }, { id: `${request.actionId}_nana`, kind: "dialogue", speaker: "娜娜", text: "“这是我妹妹的字。门后通向封锁隧道。”", visual: { character: "nana", expression: "startled", pose: "reaching_out", scene: null } } ]; } if (phase === "enter") { return [ { id: `${request.actionId}_door`, kind: "narration", speaker: null, text: "检修门在肩膀的撞击下松开。手电光照见没过鞋面的水和向深处延伸的脚印。", visual: { character: null, expression: null, pose: null, scene: "sealed_tunnel" } }, { id: `${request.actionId}_nana`, kind: "dialogue", speaker: "娜娜", text: "“我留在这里。你答应过会回来,所以我等。”", visual: { character: "nana", expression: "determined", pose: "at_door", scene: null } } ]; } if (phase === "return") { return [ { id: `${request.actionId}_return`, kind: "narration", speaker: null, text: "天色发白前,你重新推开检修门。娜娜仍坐在原处。", visual: { character: "nana", expression: "disbelieving", pose: "waiting", scene: "station_before_dawn" } }, { id: `${request.actionId}_nana`, kind: "dialogue", speaker: "娜娜", text: "“你回来了。那我也会把剩下的事告诉你。”", visual: { character: "nana", expression: "relieved", pose: "lowered_guard", scene: null } } ]; } if (request.intent === "continue") { return [ { id: `${request.actionId}_rain`, kind: "narration", speaker: null, text: "一阵风越过站台,雨点敲在铁棚上,短暂盖过了远处的水声。", visual: null }, { id: `${request.actionId}_nana`, kind: "dialogue", speaker: "娜娜", text: "如果你还没想好,就先听一会儿雨吧。", visual: { character: "nana", expression: "guarded", pose: null, scene: null } } ]; } return [ { id: `${request.actionId}_player`, kind: "action", speaker: "你", // The demo repeats only the player's literal input. It never invents player speech or intent. text: request.input, visual: null }, { id: `${request.actionId}_nana`, kind: "dialogue", speaker: "娜娜", text: "娜娜静静听完,攥着外套的手稍微松开了一些。“我知道了。”", visual: { character: "nana", expression: "uneasy", pose: "holding_coat", scene: null } } ]; } /** * Browser-only M0 adapter. It deliberately mirrors the future runtime boundary: * App -> TurnRequest -> TurnResult. No Tauri command or persisted branch is implied. */ export async function submitDemoTurn( request: TurnRequest, currentView: PlayerView ): Promise { await new Promise((resolve) => window.setTimeout(resolve, DEMO_LATENCY_MS)); const committedNodeId = nextNodeId(currentView.nodeId); const previousHistory = currentView.history.map((node) => ({ ...node, isCurrent: false })); const phase = demoPhase(request, currentView); const beats = replyBeats(request, phase); const finalVisual = [...beats].reverse().find((beat) => beat.visual)?.visual; const historyLabel = { promise: "天亮前的许诺", investigate: "检修门的线索", enter: "进入封锁隧道", return: "天亮前归来", regular: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜" }[phase]; const promises = phase === "promise" ? [ ...currentView.promises, { id: `promise_return_before_dawn_${request.actionId}`, content: "天亮前一定回来", status: "accepted" as const, weight: "major" as const } ] : phase === "return" ? currentView.promises.map((promise) => promise.status === "accepted" ? { ...promise, status: "fulfilled" as const } : promise ) : currentView.promises; const knowledge = phase === "investigate" ? [ ...currentView.knowledge, { id: `knowledge_maintenance_door_${request.actionId}`, title: "封锁隧道的检修门", summary: "旧站台下方的检修门通向封锁隧道,妹妹留下的车票指向四点十七分。", certainty: "confirmed" as const } ] : currentView.knowledge; const inventory = phase === "investigate" ? [ ...currentView.inventory, { instanceId: `item_half_ticket_${request.actionId}`, name: "半张旧车票", description: "受潮的车票背面写着“四点十七分,检修线”。", quantity: 1, placement: "bag" as const, condition: "damp" } ] : currentView.inventory; return { committedNodeId, playerView: { ...currentView, nodeId: committedNodeId, characterExpression: finalVisual?.expression ?? currentView.characterExpression, characterPose: finalVisual?.pose ?? currentView.characterPose, beats: [...currentView.beats, ...beats].slice(-8), promises, knowledge, inventory, suggestions: phase === "return" ? [] : currentView.suggestions, canContinue: phase !== "return", history: [ ...previousHistory, { id: committedNodeId, parentId: currentView.nodeId, branchId: currentView.branchId, label: historyLabel, isCurrent: true } ] } }; } /** * Browser-only rewind preview. The desktop path performs the durable fork in * SQLite; this adapter mirrors the same DTO boundary without claiming local * persistence. */ export async function forkDemoBranch( request: ForkBranchRequest, currentView: PlayerView ): Promise { await new Promise((resolve) => window.setTimeout(resolve, DEMO_LATENCY_MS)); if ( request.storyId !== currentView.storyId || request.currentBranchId !== currentView.branchId || request.expectedCurrentNodeId !== currentView.nodeId ) { throw new Error("story branch changed; refresh and retry"); } const sourceIndex = currentView.history.findIndex((node) => node.id === request.sourceNodeId); if (sourceIndex < 0) throw new Error("selected story node is unavailable"); const branchId = `branch_${request.actionId.replaceAll(/[^a-zA-Z0-9_.-]/g, "_")}`; const history = currentView.history.slice(0, sourceIndex + 1).map((node) => ({ ...node, isCurrent: node.id === request.sourceNodeId })); return { branchId, playerView: { ...currentView, nodeId: request.sourceNodeId, branchId, history } }; }