feat(app): define trusted rewind branch flow
This commit is contained in:
@@ -1 +1 @@
|
|||||||
25441c099be603f4bcbe46079868ef851fded230d8bcda6dd627a027d80ba78c
|
93d800013b9bec3087490ae629039724076cf26d9bb9a5f8834c6aa7473b1ca7
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,10 @@ export type TurnRequest = { storyId: string, branchId: string, expectedNodeId: s
|
|||||||
|
|
||||||
export type TurnResult = { committedNodeId: string, playerView: PlayerView, };
|
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 TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
|
||||||
|
|
||||||
export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
|
export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ use std::{
|
|||||||
use nana_domain::{
|
use nana_domain::{
|
||||||
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
|
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
|
||||||
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
|
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
|
||||||
HistoryNodeView, ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec,
|
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
|
||||||
KnowledgeCertainty, KnowledgeRecord, Persona, PlayerItemView, PlayerKnowledgeView,
|
ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona,
|
||||||
PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure,
|
PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule,
|
||||||
PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise,
|
PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene,
|
||||||
PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand,
|
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
|
||||||
RelationshipDimension, RelationshipState, RelationshipView, ResourceBundle, ResourceHeader,
|
RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
|
||||||
ResourceId, ResourceKind, ResourceRef, RuntimeState, SkillValue, StateDelta, StateOp, Story,
|
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
|
||||||
StoryBinding, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
|
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode,
|
||||||
ValidationCode, ValidationIssue, ValidationReport, VisualDirective, WorldBook, WorldBookEntry,
|
TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport,
|
||||||
|
VisualDirective, WorldBook, WorldBookEntry,
|
||||||
};
|
};
|
||||||
use schemars::{JsonSchema, schema_for};
|
use schemars::{JsonSchema, schema_for};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -71,6 +72,8 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
|
|||||||
add_schema::<PlayerView>(&mut outputs, &schema_dir, "player-view")?;
|
add_schema::<PlayerView>(&mut outputs, &schema_dir, "player-view")?;
|
||||||
add_schema::<TurnRequest>(&mut outputs, &schema_dir, "turn-request")?;
|
add_schema::<TurnRequest>(&mut outputs, &schema_dir, "turn-request")?;
|
||||||
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
|
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
|
||||||
|
add_schema::<ForkBranchRequest>(&mut outputs, &schema_dir, "fork-branch-request")?;
|
||||||
|
add_schema::<ForkBranchResult>(&mut outputs, &schema_dir, "fork-branch-result")?;
|
||||||
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
|
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
|
||||||
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
|
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
|
||||||
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
|
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
|
||||||
@@ -134,6 +137,8 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
|
|||||||
TurnIntent::decl(),
|
TurnIntent::decl(),
|
||||||
TurnRequest::decl(),
|
TurnRequest::decl(),
|
||||||
TurnResult::decl(),
|
TurnResult::decl(),
|
||||||
|
ForkBranchRequest::decl(),
|
||||||
|
ForkBranchResult::decl(),
|
||||||
TurnFailureCode::decl(),
|
TurnFailureCode::decl(),
|
||||||
TurnFailure::decl(),
|
TurnFailure::decl(),
|
||||||
AppInfo::decl(),
|
AppInfo::decl(),
|
||||||
|
|||||||
@@ -720,6 +720,31 @@ pub struct TurnRequest {
|
|||||||
pub input: String,
|
pub input: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requests a new playable branch rooted at an already committed story node.
|
||||||
|
///
|
||||||
|
/// The caller identifies the branch head it observed so the trusted runtime can
|
||||||
|
/// reject a rewind racing with a newer turn. The new branch id is generated by
|
||||||
|
/// the runtime from these trusted inputs and is never supplied by the UI or the
|
||||||
|
/// model.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(rename_all = "camelCase")]
|
||||||
|
pub struct ForkBranchRequest {
|
||||||
|
pub story_id: String,
|
||||||
|
pub current_branch_id: String,
|
||||||
|
pub expected_current_node_id: String,
|
||||||
|
pub source_node_id: String,
|
||||||
|
pub action_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(rename_all = "camelCase")]
|
||||||
|
pub struct ForkBranchResult {
|
||||||
|
pub branch_id: String,
|
||||||
|
pub player_view: PlayerView,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(rename_all = "camelCase")]
|
#[ts(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const required = [
|
|||||||
"app-info.schema.json",
|
"app-info.schema.json",
|
||||||
"character-card.schema.json",
|
"character-card.schema.json",
|
||||||
"demo-pack-summary.schema.json",
|
"demo-pack-summary.schema.json",
|
||||||
|
"fork-branch-request.schema.json",
|
||||||
|
"fork-branch-result.schema.json",
|
||||||
"item-spec.schema.json",
|
"item-spec.schema.json",
|
||||||
"persona.schema.json",
|
"persona.schema.json",
|
||||||
"player-view.schema.json",
|
"player-view.schema.json",
|
||||||
|
|||||||
+20
-5
@@ -97,19 +97,34 @@ describe("App", () => {
|
|||||||
wrapper.unmount();
|
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();
|
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];
|
const historyButton = wrapper.findAll(".top-actions button")[2];
|
||||||
|
|
||||||
await historyButton.trigger("click");
|
await historyButton.trigger("click");
|
||||||
expect(wrapper.find(".rewind-placeholder").exists()).toBe(false);
|
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");
|
await nodeButton.trigger("click");
|
||||||
expect(nodeButton.attributes("aria-pressed")).toBe("true");
|
expect(nodeButton.attributes("aria-pressed")).toBe("true");
|
||||||
expect(wrapper.get(".rewind-placeholder").text()).toContain("从这里继续(尚未接入)");
|
expect(wrapper.get(".rewind-placeholder").text()).toContain("旧线路不会被覆盖");
|
||||||
expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeDefined();
|
expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeUndefined();
|
||||||
expect(wrapper.get(".rewind-placeholder").text()).toContain("不会更改当前线路");
|
|
||||||
|
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();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+25
-3
@@ -13,6 +13,7 @@ const {
|
|||||||
loading,
|
loading,
|
||||||
pack,
|
pack,
|
||||||
playerView,
|
playerView,
|
||||||
|
forkBranch,
|
||||||
submitTurn,
|
submitTurn,
|
||||||
turnError
|
turnError
|
||||||
} = useDemo();
|
} = useDemo();
|
||||||
@@ -97,6 +98,15 @@ async function continueStory() {
|
|||||||
await submitTurn("continue", "");
|
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) {
|
function handleEscape(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape" && activePanel.value) {
|
if (event.key === "Escape" && activePanel.value) {
|
||||||
closePanel();
|
closePanel();
|
||||||
@@ -317,7 +327,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="panel-content history-panel">
|
<div v-else class="panel-content history-panel">
|
||||||
<p class="panel-intro">选择一个故事节点查看回溯入口。当前原型不会创建或保存新分支。</p>
|
<p class="panel-intro">从旧节点继续会创建一条新线路,当前线路仍会完整保留。</p>
|
||||||
<ol class="history-list">
|
<ol class="history-list">
|
||||||
<li v-for="node in playerView.history" :key="node.id">
|
<li v-for="node in playerView.history" :key="node.id">
|
||||||
<button
|
<button
|
||||||
@@ -333,8 +343,20 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
</ol>
|
</ol>
|
||||||
<div v-if="selectedHistoryNode" class="rewind-placeholder" aria-live="polite">
|
<div v-if="selectedHistoryNode" class="rewind-placeholder" aria-live="polite">
|
||||||
<span>已选择:{{ selectedHistoryNode }}</span>
|
<span>已选择:{{ selectedHistoryNode }}</span>
|
||||||
<button type="button" disabled>从这里继续(尚未接入)</button>
|
<button
|
||||||
<small>这里只展示入口,不会更改当前线路。</small>
|
type="button"
|
||||||
|
:disabled="busy || selectedHistoryNode === playerView.nodeId"
|
||||||
|
@click="continueFromSelectedNode"
|
||||||
|
>
|
||||||
|
{{ busy ? "正在创建线路…" : "从这里继续" }}
|
||||||
|
</button>
|
||||||
|
<small>
|
||||||
|
{{
|
||||||
|
selectedHistoryNode === playerView.nodeId
|
||||||
|
? "这里已经是当前节点。"
|
||||||
|
: "新线路会从所选节点开始,旧线路不会被覆盖。"
|
||||||
|
}}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+29
-2
@@ -1,7 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import initialView from "../../fixtures/player-view/initial.json";
|
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());
|
const invokeMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -9,7 +15,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
invoke: invokeMock
|
invoke: invokeMock
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { submitTurn } from "./bridge";
|
import { forkBranch, submitTurn } from "./bridge";
|
||||||
|
|
||||||
const request: TurnRequest = {
|
const request: TurnRequest = {
|
||||||
storyId: initialView.storyId,
|
storyId: initialView.storyId,
|
||||||
@@ -43,4 +49,25 @@ describe("Tauri bridge", () => {
|
|||||||
await expect(submitTurn(request, initialView as PlayerView)).resolves.toEqual(expected);
|
await expect(submitTurn(request, initialView as PlayerView)).resolves.toEqual(expected);
|
||||||
expect(invokeMock).toHaveBeenCalledWith("submit_turn", { request });
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-1
@@ -4,12 +4,14 @@ import initialView from "../../fixtures/player-view/initial.json";
|
|||||||
import type {
|
import type {
|
||||||
AppInfo,
|
AppInfo,
|
||||||
DemoPackSummary,
|
DemoPackSummary,
|
||||||
|
ForkBranchRequest,
|
||||||
|
ForkBranchResult,
|
||||||
PlayerView,
|
PlayerView,
|
||||||
TurnRequest,
|
TurnRequest,
|
||||||
TurnResult
|
TurnResult
|
||||||
} from "@contracts";
|
} from "@contracts";
|
||||||
|
|
||||||
import { submitDemoTurn } from "./turnAdapter";
|
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
|
||||||
|
|
||||||
function isTauri(): boolean {
|
function isTauri(): boolean {
|
||||||
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||||
@@ -58,3 +60,13 @@ export async function submitTurn(
|
|||||||
}
|
}
|
||||||
return invoke<TurnResult>("submit_turn", { request });
|
return invoke<TurnResult>("submit_turn", { request });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function forkBranch(
|
||||||
|
request: ForkBranchRequest,
|
||||||
|
currentView: PlayerView
|
||||||
|
): Promise<ForkBranchResult> {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return forkDemoBranch(request, currentView);
|
||||||
|
}
|
||||||
|
return invoke<ForkBranchResult>("fork_branch", { request });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import initialView from "../../fixtures/player-view/initial.json";
|
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 {
|
function request(intent: TurnRequest["intent"], input: string): TurnRequest {
|
||||||
return {
|
return {
|
||||||
@@ -36,4 +36,28 @@ describe("local turn adapter", () => {
|
|||||||
expect(result.playerView.beats.some((beat) => beat.speaker === "你")).toBe(false);
|
expect(result.playerView.beats.some((beat) => beat.speaker === "你")).toBe(false);
|
||||||
expect(result.playerView.beats.at(-1)?.speaker).toBe("娜娜");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+47
-1
@@ -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;
|
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<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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from "vue";
|
|||||||
import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
|
import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
forkBranch as forkRuntimeBranch,
|
||||||
getAppInfo,
|
getAppInfo,
|
||||||
getDemoPackSummary,
|
getDemoPackSummary,
|
||||||
getDemoPlayerView,
|
getDemoPlayerView,
|
||||||
@@ -77,6 +78,33 @@ export function useDemo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function forkBranch(sourceNodeId: string): Promise<void> {
|
||||||
|
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 {
|
return {
|
||||||
appInfo,
|
appInfo,
|
||||||
busy,
|
busy,
|
||||||
@@ -85,6 +113,7 @@ export function useDemo() {
|
|||||||
loading,
|
loading,
|
||||||
pack,
|
pack,
|
||||||
playerView,
|
playerView,
|
||||||
|
forkBranch,
|
||||||
submitTurn,
|
submitTurn,
|
||||||
turnError
|
turnError
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-2
@@ -720,12 +720,19 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.rewind-placeholder button {
|
.rewind-placeholder button {
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
color: rgb(26 20 20 / 64%);
|
cursor: pointer;
|
||||||
background: rgb(231 184 164 / 54%);
|
color: #21191b;
|
||||||
|
background: #e7b8a4;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rewind-placeholder button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
color: rgb(26 20 20 / 52%);
|
||||||
|
background: rgb(231 184 164 / 44%);
|
||||||
|
}
|
||||||
|
|
||||||
.rewind-placeholder small {
|
.rewind-placeholder small {
|
||||||
color: rgb(245 241 234 / 34%);
|
color: rgb(245 241 234 / 34%);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user