327 lines
9.3 KiB
TypeScript
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
|
|
}
|
|
};
|
|
}
|