diff --git a/contracts/.source.sha256 b/contracts/.source.sha256 index 5598590..ea52d56 100644 --- a/contracts/.source.sha256 +++ b/contracts/.source.sha256 @@ -1 +1 @@ -25441c099be603f4bcbe46079868ef851fded230d8bcda6dd627a027d80ba78c +93d800013b9bec3087490ae629039724076cf26d9bb9a5f8834c6aa7473b1ca7 diff --git a/contracts/schema/fork-branch-request.schema.json b/contracts/schema/fork-branch-request.schema.json new file mode 100644 index 0000000..7e7b6bb --- /dev/null +++ b/contracts/schema/fork-branch-request.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ForkBranchRequest", + "description": "Requests a new playable branch rooted at an already committed story node.\n\nThe caller identifies the branch head it observed so the trusted runtime can\nreject a rewind racing with a newer turn. The new branch id is generated by\nthe runtime from these trusted inputs and is never supplied by the UI or the\nmodel.", + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "currentBranchId": { + "type": "string" + }, + "expectedCurrentNodeId": { + "type": "string" + }, + "sourceNodeId": { + "type": "string" + }, + "storyId": { + "type": "string" + } + }, + "required": [ + "storyId", + "currentBranchId", + "expectedCurrentNodeId", + "sourceNodeId", + "actionId" + ] +} diff --git a/contracts/schema/fork-branch-result.schema.json b/contracts/schema/fork-branch-result.schema.json new file mode 100644 index 0000000..a9c6624 --- /dev/null +++ b/contracts/schema/fork-branch-result.schema.json @@ -0,0 +1,397 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ForkBranchResult", + "type": "object", + "properties": { + "branchId": { + "type": "string" + }, + "playerView": { + "$ref": "#/$defs/PlayerView" + } + }, + "required": [ + "branchId", + "playerView" + ], + "$defs": { + "ActionSuggestion": { + "type": "object", + "properties": { + "draft": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "draft" + ] + }, + "BeatKind": { + "type": "string", + "enum": [ + "narration", + "dialogue", + "action", + "system" + ] + }, + "HistoryNodeView": { + "type": "object", + "properties": { + "branchId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isCurrent": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "parentId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "branchId", + "label", + "isCurrent" + ] + }, + "ItemPlacement": { + "type": "string", + "enum": [ + "bag", + "worn", + "hand", + "scene", + "hidden" + ] + }, + "KnowledgeCertainty": { + "type": "string", + "enum": [ + "suspected", + "reported", + "confirmed" + ] + }, + "PlayerItemView": { + "type": "object", + "properties": { + "condition": { + "type": "string" + }, + "description": { + "type": "string" + }, + "instanceId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "placement": { + "$ref": "#/$defs/ItemPlacement" + }, + "quantity": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "instanceId", + "name", + "description", + "quantity", + "placement", + "condition" + ] + }, + "PlayerKnowledgeView": { + "type": "object", + "properties": { + "certainty": { + "$ref": "#/$defs/KnowledgeCertainty" + }, + "id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "summary", + "certainty" + ] + }, + "PlayerPromiseView": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/PromiseStatus" + }, + "weight": { + "$ref": "#/$defs/PromiseWeight" + } + }, + "required": [ + "id", + "content", + "status", + "weight" + ] + }, + "PlayerView": { + "type": "object", + "properties": { + "beats": { + "type": "array", + "items": { + "$ref": "#/$defs/PresentationBeat" + } + }, + "branchId": { + "type": "string" + }, + "canContinue": { + "type": "boolean" + }, + "characterExpression": { + "type": [ + "string", + "null" + ], + "default": null + }, + "characterName": { + "type": "string" + }, + "characterPose": { + "type": [ + "string", + "null" + ], + "default": null + }, + "history": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/HistoryNodeView" + } + }, + "inventory": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/PlayerItemView" + } + }, + "knowledge": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/PlayerKnowledgeView" + } + }, + "nodeId": { + "type": "string" + }, + "promises": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/PlayerPromiseView" + } + }, + "relationship": { + "$ref": "#/$defs/RelationshipView" + }, + "sceneId": { + "type": "string" + }, + "sceneTitle": { + "type": "string" + }, + "storyId": { + "type": "string" + }, + "suggestions": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/ActionSuggestion" + } + } + }, + "required": [ + "storyId", + "nodeId", + "branchId", + "sceneId", + "sceneTitle", + "characterName", + "beats", + "relationship", + "canContinue" + ] + }, + "PresentationBeat": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/BeatKind" + }, + "speaker": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "visual": { + "anyOf": [ + { + "$ref": "#/$defs/VisualDirective" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "kind", + "text" + ] + }, + "PromiseStatus": { + "type": "string", + "enum": [ + "proposed", + "accepted", + "fulfilled", + "broken", + "released", + "impossible" + ] + }, + "PromiseWeight": { + "type": "string", + "enum": [ + "minor", + "major" + ] + }, + "RelationshipBand": { + "type": "string", + "enum": [ + "distant", + "guarded", + "warming", + "close", + "bonded" + ] + }, + "RelationshipView": { + "type": "object", + "properties": { + "affinity": { + "$ref": "#/$defs/RelationshipBand" + }, + "attachment": { + "$ref": "#/$defs/RelationshipBand" + }, + "hope": { + "$ref": "#/$defs/RelationshipBand" + }, + "intimacy": { + "$ref": "#/$defs/RelationshipBand" + }, + "respect": { + "$ref": "#/$defs/RelationshipBand" + }, + "trust": { + "$ref": "#/$defs/RelationshipBand" + }, + "updatedAtNode": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "affinity", + "trust", + "hope", + "respect", + "intimacy", + "attachment" + ] + }, + "VisualDirective": { + "type": "object", + "properties": { + "character": { + "type": [ + "string", + "null" + ] + }, + "expression": { + "type": [ + "string", + "null" + ] + }, + "pose": { + "type": [ + "string", + "null" + ] + }, + "scene": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/contracts/ts/index.ts b/contracts/ts/index.ts index 5f77387..7d25367 100644 --- a/contracts/ts/index.ts +++ b/contracts/ts/index.ts @@ -116,6 +116,10 @@ export type TurnRequest = { storyId: string, branchId: string, expectedNodeId: s export type TurnResult = { committedNodeId: string, playerView: PlayerView, }; +export type ForkBranchRequest = { storyId: string, currentBranchId: string, expectedCurrentNodeId: string, sourceNodeId: string, actionId: string, }; + +export type ForkBranchResult = { branchId: string, playerView: PlayerView, }; + export type TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_unavailable" | "cancelled" | "timed_out" | "internal"; export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, }; diff --git a/crates/nana-contracts/src/main.rs b/crates/nana-contracts/src/main.rs index 9e4ec2e..b7f55ed 100644 --- a/crates/nana-contracts/src/main.rs +++ b/crates/nana-contracts/src/main.rs @@ -6,15 +6,16 @@ use std::{ use nana_domain::{ AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary, - HistoryNodeView, ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, - KnowledgeCertainty, KnowledgeRecord, Persona, PlayerItemView, PlayerKnowledgeView, - PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure, - PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise, - PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand, - RelationshipDimension, RelationshipState, RelationshipView, ResourceBundle, ResourceHeader, - ResourceId, ResourceKind, ResourceRef, RuntimeState, SkillValue, StateDelta, StateOp, Story, - StoryBinding, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, - ValidationCode, ValidationIssue, ValidationReport, VisualDirective, WorldBook, WorldBookEntry, + ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance, + ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona, + PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule, + PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene, + PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, + RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView, + ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState, + SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode, + TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport, + VisualDirective, WorldBook, WorldBookEntry, }; use schemars::{JsonSchema, schema_for}; use serde::Serialize; @@ -71,6 +72,8 @@ fn generated_outputs(root: &Path) -> Result(&mut outputs, &schema_dir, "player-view")?; add_schema::(&mut outputs, &schema_dir, "turn-request")?; add_schema::(&mut outputs, &schema_dir, "turn-result")?; + add_schema::(&mut outputs, &schema_dir, "fork-branch-request")?; + add_schema::(&mut outputs, &schema_dir, "fork-branch-result")?; add_schema::(&mut outputs, &schema_dir, "turn-failure")?; add_schema::(&mut outputs, &schema_dir, "app-info")?; add_schema::(&mut outputs, &schema_dir, "demo-pack-summary")?; @@ -134,6 +137,8 @@ fn generated_outputs(root: &Path) -> Result { wrapper.unmount(); }); - it("selects a rewind node without claiming to persist a branch", async () => { + it("creates a new branch from an old node and keeps the old route intact", async () => { const wrapper = await mountLoadedApp(); + + await wrapper.get(".continue-button").trigger("click"); + await vi.waitFor(() => { + expect(wrapper.get(".statusline span").text()).toBe("node_002"); + }); + const historyButton = wrapper.findAll(".top-actions button")[2]; await historyButton.trigger("click"); expect(wrapper.find(".rewind-placeholder").exists()).toBe(false); - const nodeButton = wrapper.get(".history-list button"); + const nodeButton = wrapper.findAll(".history-list button")[0]; await nodeButton.trigger("click"); expect(nodeButton.attributes("aria-pressed")).toBe("true"); - expect(wrapper.get(".rewind-placeholder").text()).toContain("从这里继续(尚未接入)"); - expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeDefined(); - expect(wrapper.get(".rewind-placeholder").text()).toContain("不会更改当前线路"); + expect(wrapper.get(".rewind-placeholder").text()).toContain("旧线路不会被覆盖"); + expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeUndefined(); + + await wrapper.get(".rewind-placeholder button").trigger("click"); + expect(wrapper.get(".rewind-placeholder button").text()).toContain("正在创建线路"); + + await vi.waitFor(() => { + const status = wrapper.findAll(".statusline span"); + expect(status[0]?.text()).toBe("node_001"); + expect(status[1]?.text()).toContain("branch_fork_"); + }); + expect(wrapper.find("#history-panel").exists()).toBe(false); wrapper.unmount(); }); diff --git a/src/App.vue b/src/App.vue index 01d4871..2b96e67 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,6 +13,7 @@ const { loading, pack, playerView, + forkBranch, submitTurn, turnError } = useDemo(); @@ -97,6 +98,15 @@ async function continueStory() { await submitTurn("continue", ""); } +async function continueFromSelectedNode() { + if (!selectedHistoryNode.value || busy.value) return; + await forkBranch(selectedHistoryNode.value); + if (!turnError.value) { + selectedHistoryNode.value = null; + closePanel(); + } +} + function handleEscape(event: KeyboardEvent) { if (event.key === "Escape" && activePanel.value) { closePanel(); @@ -317,7 +327,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
-

选择一个故事节点查看回溯入口。当前原型不会创建或保存新分支。

+

从旧节点继续会创建一条新线路,当前线路仍会完整保留。

已选择:{{ selectedHistoryNode }} - - 这里只展示入口,不会更改当前线路。 + + + {{ + selectedHistoryNode === playerView.nodeId + ? "这里已经是当前节点。" + : "新线路会从所选节点开始,旧线路不会被覆盖。" + }} +
diff --git a/src/app/bridge.spec.ts b/src/app/bridge.spec.ts index 80c23f5..a81d480 100644 --- a/src/app/bridge.spec.ts +++ b/src/app/bridge.spec.ts @@ -1,7 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import initialView from "../../fixtures/player-view/initial.json"; -import type { PlayerView, TurnRequest, TurnResult } from "@contracts"; +import type { + ForkBranchRequest, + ForkBranchResult, + PlayerView, + TurnRequest, + TurnResult +} from "@contracts"; const invokeMock = vi.hoisted(() => vi.fn()); @@ -9,7 +15,7 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); -import { submitTurn } from "./bridge"; +import { forkBranch, submitTurn } from "./bridge"; const request: TurnRequest = { storyId: initialView.storyId, @@ -43,4 +49,25 @@ describe("Tauri bridge", () => { await expect(submitTurn(request, initialView as PlayerView)).resolves.toEqual(expected); expect(invokeMock).toHaveBeenCalledWith("submit_turn", { request }); }); + + it("sends only the typed fork request to the fork_branch command", async () => { + const forkRequest: ForkBranchRequest = { + storyId: initialView.storyId, + currentBranchId: initialView.branchId, + expectedCurrentNodeId: "node_002", + sourceNodeId: initialView.nodeId, + actionId: "fork_bridge_test" + }; + const expected: ForkBranchResult = { + branchId: "branch_fork_bridge_test", + playerView: { + ...(initialView as PlayerView), + branchId: "branch_fork_bridge_test" + } + }; + invokeMock.mockResolvedValue(expected); + + await expect(forkBranch(forkRequest, initialView as PlayerView)).resolves.toEqual(expected); + expect(invokeMock).toHaveBeenCalledWith("fork_branch", { request: forkRequest }); + }); }); diff --git a/src/app/bridge.ts b/src/app/bridge.ts index 646af7d..8209218 100644 --- a/src/app/bridge.ts +++ b/src/app/bridge.ts @@ -4,12 +4,14 @@ import initialView from "../../fixtures/player-view/initial.json"; import type { AppInfo, DemoPackSummary, + ForkBranchRequest, + ForkBranchResult, PlayerView, TurnRequest, TurnResult } from "@contracts"; -import { submitDemoTurn } from "./turnAdapter"; +import { forkDemoBranch, submitDemoTurn } from "./turnAdapter"; function isTauri(): boolean { return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined; @@ -58,3 +60,13 @@ export async function submitTurn( } return invoke("submit_turn", { request }); } + +export async function forkBranch( + request: ForkBranchRequest, + currentView: PlayerView +): Promise { + if (!isTauri()) { + return forkDemoBranch(request, currentView); + } + return invoke("fork_branch", { request }); +} diff --git a/src/app/turnAdapter.spec.ts b/src/app/turnAdapter.spec.ts index 11fb8c7..13207cb 100644 --- a/src/app/turnAdapter.spec.ts +++ b/src/app/turnAdapter.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import initialView from "../../fixtures/player-view/initial.json"; -import type { PlayerView, TurnRequest } from "@contracts"; +import type { ForkBranchRequest, PlayerView, TurnRequest } from "@contracts"; -import { submitDemoTurn } from "./turnAdapter"; +import { forkDemoBranch, submitDemoTurn } from "./turnAdapter"; function request(intent: TurnRequest["intent"], input: string): TurnRequest { return { @@ -36,4 +36,28 @@ describe("local turn adapter", () => { expect(result.playerView.beats.some((beat) => beat.speaker === "你")).toBe(false); expect(result.playerView.beats.at(-1)?.speaker).toBe("娜娜"); }); + + it("mirrors a fork without moving or deleting the source history", async () => { + const advanced = await submitDemoTurn( + request("speak_or_act", "我先去看看站台。"), + initialView as PlayerView + ); + const forkRequest: ForkBranchRequest = { + storyId: advanced.playerView.storyId, + currentBranchId: advanced.playerView.branchId, + expectedCurrentNodeId: advanced.playerView.nodeId, + sourceNodeId: initialView.nodeId, + actionId: "fork_adapter_test" + }; + + const forked = await forkDemoBranch(forkRequest, advanced.playerView); + + expect(forked.branchId).toBe("branch_fork_adapter_test"); + expect(forked.playerView.nodeId).toBe(initialView.nodeId); + expect(forked.playerView.branchId).toBe(forked.branchId); + expect(forked.playerView.history).toHaveLength(1); + expect(forked.playerView.history[0]?.isCurrent).toBe(true); + expect(advanced.playerView.branchId).toBe(initialView.branchId); + expect(advanced.playerView.nodeId).toBe("node_002"); + }); }); diff --git a/src/app/turnAdapter.ts b/src/app/turnAdapter.ts index 89cbe04..60c76db 100644 --- a/src/app/turnAdapter.ts +++ b/src/app/turnAdapter.ts @@ -1,4 +1,11 @@ -import type { PlayerView, PresentationBeat, TurnRequest, TurnResult } from "@contracts"; +import type { + ForkBranchRequest, + ForkBranchResult, + PlayerView, + PresentationBeat, + TurnRequest, + TurnResult +} from "@contracts"; const DEMO_LATENCY_MS = 120; @@ -95,3 +102,42 @@ export async function submitDemoTurn( } }; } + +/** + * 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 + } + }; +} diff --git a/src/app/useDemo.ts b/src/app/useDemo.ts index 56019ae..5b39dae 100644 --- a/src/app/useDemo.ts +++ b/src/app/useDemo.ts @@ -3,6 +3,7 @@ import { onMounted, ref } from "vue"; import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts"; import { + forkBranch as forkRuntimeBranch, getAppInfo, getDemoPackSummary, getDemoPlayerView, @@ -77,6 +78,33 @@ export function useDemo() { } } + async function forkBranch(sourceNodeId: string): Promise { + const currentView = playerView.value; + if (!currentView || busy.value || sourceNodeId === currentView.nodeId) return; + + busy.value = true; + turnError.value = null; + lastSubmittedIntent.value = null; + + try { + const result = await forkRuntimeBranch( + { + storyId: currentView.storyId, + currentBranchId: currentView.branchId, + expectedCurrentNodeId: currentView.nodeId, + sourceNodeId, + actionId: `fork_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + }, + currentView + ); + playerView.value = result.playerView; + } catch (reason) { + turnError.value = errorMessage(reason); + } finally { + busy.value = false; + } + } + return { appInfo, busy, @@ -85,6 +113,7 @@ export function useDemo() { loading, pack, playerView, + forkBranch, submitTurn, turnError }; diff --git a/src/styles/main.css b/src/styles/main.css index 42ed5fe..98e00fa 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -720,12 +720,19 @@ textarea:focus-visible { .rewind-placeholder button { padding: 11px 14px; - color: rgb(26 20 20 / 64%); - background: rgb(231 184 164 / 54%); + cursor: pointer; + color: #21191b; + background: #e7b8a4; border: 0; border-radius: 9px; } +.rewind-placeholder button:disabled { + cursor: default; + color: rgb(26 20 20 / 52%); + background: rgb(231 184 164 / 44%); +} + .rewind-placeholder small { color: rgb(245 241 234 / 34%); }