Files
nana-story/src/app/turnAdapter.ts
T
Codex 1f935a3206
verify / verify (push) Has been cancelled
feat(app): complete playable branch sessions
2026-07-28 04:42:40 -04:00

327 lines
9.3 KiB
TypeScript

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<TurnResult> {
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<ForkBranchResult> {
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
}
};
}