From d0612b399c8820bb6891319c5b42f22c3988e49d Mon Sep 17 00:00:00 2001 From: klarkxy <278370456@qq.com> Date: Tue, 28 Jul 2026 22:52:06 +0800 Subject: [PATCH] feat(runtime): preserve current branch context --- crates/nana-runtime/src/adjudication.rs | 100 +++- crates/nana-runtime/src/context.rs | 596 ++++++++++++++++++++++- crates/nana-runtime/src/lapp_provider.rs | 275 +++++++++-- crates/nana-runtime/src/lib.rs | 129 ++++- crates/nana-store/src/lib.rs | 276 ++++++++++- docs/status/2026-07-28-m2-wave7-local.md | 59 +++ src-tauri/src/lib.rs | 60 ++- tasks/todo.md | 8 +- 8 files changed, 1418 insertions(+), 85 deletions(-) create mode 100644 docs/status/2026-07-28-m2-wave7-local.md diff --git a/crates/nana-runtime/src/adjudication.rs b/crates/nana-runtime/src/adjudication.rs index 6e68e96..c72cfe2 100644 --- a/crates/nana-runtime/src/adjudication.rs +++ b/crates/nana-runtime/src/adjudication.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ - InvalidModelOutputKind, ProviderError, TurnControl, TurnPlan, TurnPlanProvider, - provider_interruption, + BranchHistoryProjection, InvalidModelOutputKind, ProviderError, TurnControl, TurnPlan, + TurnPlanProvider, provider_interruption, }; pub const HIDDEN_CHECK_TOOL_NAME: &str = "request_hidden_check"; @@ -109,6 +109,19 @@ pub trait AdjudicationModel { input: AdjudicationModelInput<'_>, ) -> Result; + /// Respond with caller-selected safe branch history. + /// + /// Existing models remain compatible and may ignore it. Narrative adapters + /// should override this instead of loading branch nodes themselves. + fn respond_with_history( + &mut self, + input: AdjudicationModelInput<'_>, + branch_history: &BranchHistoryProjection, + ) -> Result { + let _ = branch_history; + self.respond(input) + } + /// Respond while observing the outer turn lifecycle. /// /// Existing deterministic models remain source-compatible. Network-backed @@ -128,6 +141,18 @@ pub trait AdjudicationModel { result } } + + /// History-aware controlled response. The compatibility default keeps a + /// model's existing cancellation behavior and ignores history. + fn respond_with_history_and_control( + &mut self, + input: AdjudicationModelInput<'_>, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + let _ = branch_history; + self.respond_with_control(input, control) + } } #[derive(Debug, Clone, PartialEq, Eq, Error)] @@ -411,7 +436,26 @@ impl AdjudicatingTurnPlanProvider { request: &TurnRequest, state: &RuntimeState, ) -> Result { - self.plan_adjudicated_turn_with_control(request, state, &TurnControl::new()) + self.plan_adjudicated_turn_with_history_and_control( + request, + state, + &BranchHistoryProjection::default(), + &TurnControl::new(), + ) + } + + pub fn plan_adjudicated_turn_with_history( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + ) -> Result { + self.plan_adjudicated_turn_with_history_and_control( + request, + state, + branch_history, + &TurnControl::new(), + ) } /// Run the model/check/model loop without persisting any partial result. @@ -420,6 +464,21 @@ impl AdjudicatingTurnPlanProvider { request: &TurnRequest, state: &RuntimeState, control: &TurnControl, + ) -> Result { + self.plan_adjudicated_turn_with_history_and_control( + request, + state, + &BranchHistoryProjection::default(), + control, + ) + } + + pub fn plan_adjudicated_turn_with_history_and_control( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + control: &TurnControl, ) -> Result { let mut records = Vec::new(); let mut last_outcome = None; @@ -434,7 +493,9 @@ impl AdjudicatingTurnPlanProvider { AdjudicationModelInput::BeginTurn { request, state }, AdjudicationModelInput::CheckResolved, ); - let response = self.model.respond_with_control(input, control)?; + let response = + self.model + .respond_with_history_and_control(input, branch_history, control)?; let tool_call = exactly_one_tool(response)?; match tool_call { AdjudicationToolCall::RequestHiddenCheck(proposed) => { @@ -512,6 +573,37 @@ impl TurnPlanProvider for AdjudicatingTurnPlanProvider }, }) } + + fn plan_turn_with_history( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + ) -> Result { + self.plan_adjudicated_turn_with_history(request, state, branch_history) + .map_err(|error| match error { + AdjudicationRunError::Provider(error) => error, + AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput { + kind: InvalidModelOutputKind::InvalidPlan, + }, + }) + } + + fn plan_turn_with_history_and_control( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + self.plan_adjudicated_turn_with_history_and_control(request, state, branch_history, control) + .map_err(|error| match error { + AdjudicationRunError::Provider(error) => error, + AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput { + kind: InvalidModelOutputKind::InvalidPlan, + }, + }) + } } fn select_bound_actor<'a, T>( diff --git a/crates/nana-runtime/src/context.rs b/crates/nana-runtime/src/context.rs index b30ccf1..ffbf3fd 100644 --- a/crates/nana-runtime/src/context.rs +++ b/crates/nana-runtime/src/context.rs @@ -1,17 +1,20 @@ use std::collections::BTreeSet; use nana_domain::{ - CharacterCard, CharacterJudgmentRule, CharacterStyle, ItemPlacement, ItemSpec, KnowledgeRecord, - Persona, PlotEvent, PlotModule, PlotOutcome, PlotPressure, Promise, ResourceBundle, - ResourceHeader, ResourceId, ResourceKind, RuntimeState, SkillValue, TurnIntent, TurnRequest, - WorldBookEntry, + BeatKind, CharacterCard, CharacterJudgmentRule, CharacterStyle, ItemAcquisition, ItemPlacement, + ItemSpec, KnowledgeRecord, Persona, PlotEvent, PlotModule, PlotOutcome, PlotPressure, + PresentationBeat, PresentationSnapshot, Promise, RelationshipAxes, RelationshipView, + ResourceBundle, ResourceHeader, ResourceId, ResourceKind, RuntimeState, SkillValue, StoryNode, + TurnIntent, TurnRequest, WorldBookEntry, }; +use nana_engine::relationship_band; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::MAX_WORLD_BOOK_ENTRIES; -pub const SCENE_CONTEXT_SCHEMA_VERSION: u32 = 1; +pub const SCENE_CONTEXT_SCHEMA_VERSION: u32 = 2; +pub const SCENE_PROMPT_SCHEMA_VERSION: u32 = 1; pub const MAX_WORLD_BOOK_CONTEXT_BYTES: usize = 64 * 1024; pub const MAX_PLOT_EVENTS: usize = 8; pub const MAX_PLOT_CONTEXT_BYTES: usize = 48 * 1024; @@ -34,6 +37,10 @@ pub struct CompiledSceneContext { pub turn: ContextTurn, pub character_card: ContextCharacterCard, pub persona: ContextPersona, + #[serde(default)] + pub world_book_sources: Vec, + #[serde(default)] + pub branch_history: BranchHistoryProjection, pub world_book_entries: Vec, pub plot_events: Vec, pub state_memory: ContextStateMemory, @@ -54,6 +61,116 @@ pub struct ContextTurn { pub input: String, } +/// A caller-supplied, player-safe projection of the committed root-to-head path. +/// +/// The type deliberately has no state-delta, check, relationship, inventory, +/// knowledge, provider, or unselected-suggestion fields. A suggestion is only +/// a candidate edge; the next node's `player_input` records the edge the player +/// actually chose. The caller remains responsible for selecting only ancestors +/// of the branch being continued; this module never traverses the store or +/// discovers sibling branches. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchHistoryProjection { + pub entries: Vec, +} + +impl BranchHistoryProjection { + /// Project already-committed nodes into narrative-only history. + /// + /// The input order is preserved. Callers should pass the current branch's + /// root-to-head path and no nodes from sibling branches. + #[must_use] + pub fn from_committed_nodes<'a>(nodes: impl IntoIterator) -> Self { + Self { + entries: nodes + .into_iter() + .map(BranchHistoryEntry::from_committed_node) + .collect(), + } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchHistoryEntry { + pub node_id: String, + pub player_input: String, + #[serde(default)] + pub scene: BranchHistoryScene, + #[serde(default)] + pub character: BranchHistoryCharacter, + pub beats: Vec, +} + +impl BranchHistoryEntry { + #[must_use] + pub fn from_committed_node(node: &StoryNode) -> Self { + Self::from_presentation(&node.id, &node.user_input, &node.presentation) + } + + #[must_use] + pub fn from_presentation( + node_id: impl Into, + player_input: impl Into, + presentation: &PresentationSnapshot, + ) -> Self { + Self { + node_id: node_id.into(), + player_input: player_input.into(), + scene: BranchHistoryScene { + id: presentation.scene.id.clone(), + title: presentation.scene.title.clone(), + }, + character: BranchHistoryCharacter { + id: presentation.character.id.clone(), + name: presentation.character.name.clone(), + expression: presentation.character.expression.clone(), + pose: presentation.character.pose.clone(), + }, + beats: presentation + .beats + .iter() + .map(BranchHistoryBeat::from) + .collect(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchHistoryScene { + pub id: String, + pub title: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchHistoryCharacter { + pub id: String, + pub name: String, + pub expression: Option, + pub pose: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchHistoryBeat { + pub kind: BeatKind, + pub speaker: Option, + pub text: String, +} + +impl From<&PresentationBeat> for BranchHistoryBeat { + fn from(beat: &PresentationBeat) -> Self { + Self { + kind: beat.kind, + speaker: beat.speaker.clone(), + text: beat.text.clone(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResourceProvenance { pub resource_id: ResourceId, @@ -156,6 +273,8 @@ pub struct PlayerMemory { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CharacterMemory { pub actor_id: String, + #[serde(default = "neutral_relationship_view")] + pub relationship_to_player: RelationshipView, pub knowledge: Vec, pub promises: Vec, } @@ -173,10 +292,14 @@ pub struct ContextInventoryItem { pub name: String, pub description: String, pub tags: Vec, + #[serde(default)] + pub owner: String, pub quantity: u32, pub placement: ItemPlacement, pub condition: String, pub state_tags: Vec, + #[serde(default = "unknown_item_acquisition")] + pub acquisition: ItemAcquisition, } /// Summary storage is explicitly separate from factual memory. @@ -308,7 +431,29 @@ pub fn compile_scene_context( request: &TurnRequest, state: &RuntimeState, ) -> Result { - compile_scene_context_with_budget(bundle, request, state, ContextBudget::default()) + compile_scene_context_with_history_and_budget( + bundle, + request, + state, + &BranchHistoryProjection::default(), + ContextBudget::default(), + ) +} + +/// Compile context with an explicit player-safe current-branch history. +pub fn compile_scene_context_with_history( + bundle: &ResourceBundle, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, +) -> Result { + compile_scene_context_with_history_and_budget( + bundle, + request, + state, + branch_history, + ContextBudget::default(), + ) } /// Compile context with a caller-provided budget that may only tighten hard V1 @@ -318,6 +463,23 @@ pub fn compile_scene_context_with_budget( request: &TurnRequest, state: &RuntimeState, budget: ContextBudget, +) -> Result { + compile_scene_context_with_history_and_budget( + bundle, + request, + state, + &BranchHistoryProjection::default(), + budget, + ) +} + +/// Compile context with both explicit branch history and tightened V1 limits. +pub fn compile_scene_context_with_history_and_budget( + bundle: &ResourceBundle, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + budget: ContextBudget, ) -> Result { validate_turn_position(request, state)?; let budget = budget.bounded(); @@ -340,6 +502,8 @@ pub fn compile_scene_context_with_budget( }, character_card: character_context(character), persona: persona_context(persona), + world_book_sources: bound_world_book_sources(bundle, character, persona, plot_module)?, + branch_history: branch_history.clone(), world_book_entries: select_context_world_book_entries( bundle, character, @@ -371,6 +535,61 @@ pub fn encode_compiled_scene_context( serde_json::to_string(context).map_err(|_| ContextCompileError::Serialization) } +/// Encode a cache-friendly model envelope. +/// +/// Field order is a prompt contract: immutable system/resource data comes +/// first, the caller-provided current-branch history comes next, and volatile +/// state plus the current player input are last. Resource strings remain data +/// inside a user message; callers must not splice this encoding into a system +/// prompt. +pub fn encode_compiled_scene_prompt( + context: &CompiledSceneContext, +) -> Result { + #[derive(Serialize)] + struct StablePrefix<'a> { + schema_version: u32, + narrative_safety: &'a NarrativeSafety, + character_card: &'a ContextCharacterCard, + persona: &'a ContextPersona, + world_book_sources: &'a [ResourceProvenance], + } + + #[derive(Serialize)] + struct DynamicTail<'a> { + world_book_entries: &'a [ContextWorldBookEntry], + plot_events: &'a [ContextPlotEvent], + state_memory: &'a ContextStateMemory, + turn: &'a ContextTurn, + } + + #[derive(Serialize)] + struct ModelEnvelope<'a> { + prompt_schema_version: u32, + stable_prefix: StablePrefix<'a>, + branch_history: &'a BranchHistoryProjection, + dynamic_tail: DynamicTail<'a>, + } + + serde_json::to_string(&ModelEnvelope { + prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION, + stable_prefix: StablePrefix { + schema_version: context.schema_version, + narrative_safety: &context.narrative_safety, + character_card: &context.character_card, + persona: &context.persona, + world_book_sources: &context.world_book_sources, + }, + branch_history: &context.branch_history, + dynamic_tail: DynamicTail { + world_book_entries: &context.world_book_entries, + plot_events: &context.plot_events, + state_memory: &context.state_memory, + turn: &context.turn, + }, + }) + .map_err(|_| ContextCompileError::Serialization) +} + fn validate_turn_position( request: &TurnRequest, state: &RuntimeState, @@ -500,6 +719,40 @@ fn persona_context(persona: &Persona) -> ContextPersona { } } +fn bound_world_book_sources( + bundle: &ResourceBundle, + character: &CharacterCard, + persona: &Persona, + plot_module: Option<&PlotModule>, +) -> Result, ContextCompileError> { + let bound_refs = character + .header + .dependencies + .iter() + .chain(persona.header.dependencies.iter()) + .chain( + plot_module + .into_iter() + .flat_map(|module| module.header.dependencies.iter()), + ) + .filter(|dependency| dependency.kind == ResourceKind::WorldBook) + .collect::>(); + let bound_ids = bound_refs + .iter() + .map(|dependency| dependency.id.clone()) + .collect::>(); + validate_bound_world_books(bundle, &bound_refs, &bound_ids)?; + + let mut sources = bundle + .world_books + .iter() + .filter(|book| bound_ids.contains(&book.header.id)) + .map(|book| provenance(&book.header)) + .collect::>(); + sources.sort_by(|left, right| left.resource_id.cmp(&right.resource_id)); + Ok(sources) +} + #[allow(clippy::too_many_arguments)] fn select_context_world_book_entries( bundle: &ResourceBundle, @@ -721,6 +974,7 @@ fn state_memory_context( }, primary_character: CharacterMemory { actor_id: primary_character_id.to_owned(), + relationship_to_player: relationship_to_player(state, primary_character_id), knowledge: Vec::new(), promises: Vec::new(), }, @@ -797,6 +1051,40 @@ fn state_memory_context( Ok(memory) } +fn relationship_to_player(state: &RuntimeState, primary_character_id: &str) -> RelationshipView { + let relationship_key = format!("{primary_character_id}->{PLAYER_ACTOR_ID}"); + let axes = state + .relationships + .get(&relationship_key) + .copied() + .unwrap_or_else(RelationshipAxes::neutral); + relationship_view_from_axes(axes) +} + +fn neutral_relationship_view() -> RelationshipView { + relationship_view_from_axes(RelationshipAxes::neutral()) +} + +fn unknown_item_acquisition() -> ItemAcquisition { + ItemAcquisition { + mode: nana_domain::AcquisitionMode::Initial, + from: None, + at_node: String::new(), + } +} + +fn relationship_view_from_axes(axes: RelationshipAxes) -> RelationshipView { + RelationshipView { + affinity: relationship_band(axes.affinity), + trust: relationship_band(axes.trust), + hope: relationship_band(axes.hope), + respect: relationship_band(axes.respect), + intimacy: relationship_band(axes.intimacy), + attachment: relationship_band(axes.attachment), + updated_at_node: None, + } +} + fn append_knowledge<'a>( target: &mut Vec, records: impl Iterator, @@ -860,10 +1148,12 @@ fn inventory_item_context( name: spec.name.clone(), description: spec.description.clone(), tags: spec.tags.clone(), + owner: item.owner.clone(), quantity: item.quantity, placement: item.placement, condition: item.condition.clone(), state_tags: item.state_tags.clone(), + acquisition: item.acquisition.clone(), } } @@ -978,17 +1268,21 @@ mod tests { use std::collections::BTreeMap; use nana_domain::{ - AcquisitionMode, CharacterCard, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, - ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, - KnowledgeRecord, Persona, PlotEvent, PlotModule, PlotOutcome, PlotPressure, Promise, - PromiseStatus, PromiseWeight, ResourceBundle, ResourceHeader, ResourceId, ResourceKind, + AcquisitionMode, ActionSuggestion, BeatKind, CharacterCard, CharacterStyle, + CheckDifficulty, CheckRecord, CheckResult, ItemAcquisition, ItemInstance, ItemMechanics, + ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona, PlotEvent, + PlotModule, PlotOutcome, PlotPressure, PresentationSnapshot, Promise, PromiseStatus, + PromiseWeight, RelationshipBand, ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState, TurnIntent, TurnRequest, WorldBook, WorldBookEntry, }; use super::{ - ContextBudget, ContextCompileError, HiddenCheckTreatment, ResourceStringTreatment, + BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection, + BranchHistoryScene, CompiledSceneContext, ContextBudget, ContextCompileError, + HiddenCheckTreatment, ResourceStringTreatment, SCENE_PROMPT_SCHEMA_VERSION, SummaryClassification, compile_scene_context, compile_scene_context_with_budget, - encode_compiled_scene_context, + compile_scene_context_with_history, encode_compiled_scene_context, + encode_compiled_scene_prompt, }; const REVISION: &str = "1"; @@ -1303,6 +1597,8 @@ mod tests { "\"turn\"", "\"character_card\"", "\"persona\"", + "\"world_book_sources\"", + "\"branch_history\"", "\"world_book_entries\"", "\"plot_events\"", "\"state_memory\"", @@ -1318,6 +1614,8 @@ mod tests { "generic.character.guide" ); assert_eq!(context.state_memory.primary_character.actor_id, "guide"); + assert_eq!(context.world_book_sources.len(), 1); + assert!(context.branch_history.is_empty()); assert_eq!( context.narrative_safety.resource_strings, ResourceStringTreatment::UntrustedData @@ -1328,6 +1626,225 @@ mod tests { ); } + fn history_entry(node_id: &str, player_input: &str, narration: &str) -> BranchHistoryEntry { + BranchHistoryEntry { + node_id: node_id.into(), + player_input: player_input.into(), + scene: BranchHistoryScene { + id: "harbor_platform".into(), + title: "Storm Harbor".into(), + }, + character: BranchHistoryCharacter { + id: "guide".into(), + name: "Guide".into(), + expression: Some("concerned".into()), + pose: Some("holding_lantern".into()), + }, + beats: vec![BranchHistoryBeat { + kind: BeatKind::Dialogue, + speaker: Some("Guide".into()), + text: narration.into(), + }], + } + } + + #[test] + fn history_projection_omits_unselected_suggestions() { + let mut presentation = PresentationSnapshot::default(); + presentation.scene.id = "old_station".into(); + presentation.scene.title = "Old Station".into(); + presentation.character.id = "guide".into(); + presentation.character.name = "Guide".into(); + presentation.character.expression = Some("guarded".into()); + presentation.character.pose = Some("holding_coat".into()); + presentation.suggestions.push(ActionSuggestion { + id: "suggestion_other_path".into(), + label: "UNSELECTED_LABEL_CANARY".into(), + draft: "UNSELECTED_DRAFT_CANARY".into(), + }); + + let entry = BranchHistoryEntry::from_presentation("node_1", "chosen action", &presentation); + let encoded = serde_json::to_string(&entry).expect("history entry"); + + assert!(encoded.contains("chosen action")); + assert!(encoded.contains("old_station")); + assert!(encoded.contains("Old Station")); + assert!(encoded.contains("guarded")); + assert!(encoded.contains("holding_coat")); + assert!(!encoded.contains("UNSELECTED_LABEL_CANARY")); + assert!(!encoded.contains("UNSELECTED_DRAFT_CANARY")); + } + + #[test] + fn v1_context_without_new_safe_projection_fields_remains_readable() { + let mut runtime = state(); + runtime.items = vec![item( + "legacy_player_lamp", + "player", + "player", + ItemPlacement::Bag, + )]; + let history = BranchHistoryProjection { + entries: vec![history_entry( + "node_legacy", + "Legacy player turn.", + "Legacy committed reply.", + )], + }; + let context = compile_scene_context_with_history( + &base_bundle(), + &request("storm"), + &runtime, + &history, + ) + .expect("current context"); + let mut legacy = serde_json::to_value(context).expect("legacy JSON"); + legacy["schema_version"] = serde_json::json!(1); + legacy["branch_history"]["entries"][0] + .as_object_mut() + .expect("legacy history entry") + .retain(|key, _| key != "scene" && key != "character"); + legacy["state_memory"]["primary_character"] + .as_object_mut() + .expect("legacy character memory") + .remove("relationship_to_player"); + legacy["state_memory"]["player"]["inventory"][0] + .as_object_mut() + .expect("legacy inventory item") + .retain(|key, _| key != "owner" && key != "acquisition"); + + let decoded = + serde_json::from_value::(legacy).expect("readable v1 context"); + assert_eq!(decoded.schema_version, 1); + assert_eq!(decoded.branch_history.entries[0].scene.id, ""); + assert_eq!(decoded.branch_history.entries[0].character.id, ""); + assert_eq!( + decoded + .state_memory + .primary_character + .relationship_to_player + .trust, + RelationshipBand::Warming + ); + assert_eq!(decoded.state_memory.player.inventory[0].owner, ""); + assert_eq!( + decoded.state_memory.player.inventory[0].acquisition.mode, + AcquisitionMode::Initial + ); + } + + #[test] + fn model_prompt_orders_stable_prefix_history_and_current_turn_tail() { + let first_history = BranchHistoryProjection { + entries: vec![history_entry( + "node_1", + "FIRST PLAYER TURN", + "FIRST COMMITTED REPLY", + )], + }; + let third_turn_history = BranchHistoryProjection { + entries: vec![ + first_history.entries[0].clone(), + history_entry("node_2", "SECOND PLAYER TURN", "SECOND COMMITTED REPLY"), + ], + }; + + let first = compile_scene_context_with_history( + &base_bundle(), + &request("first current input"), + &state(), + &first_history, + ) + .expect("first prompt context"); + let third = compile_scene_context_with_history( + &base_bundle(), + &request("THIRD CURRENT INPUT"), + &state(), + &third_turn_history, + ) + .expect("third prompt context"); + let first_prompt = encode_compiled_scene_prompt(&first).expect("first prompt"); + let third_prompt = encode_compiled_scene_prompt(&third).expect("third prompt"); + assert!(first_prompt.starts_with(&format!( + "{{\"prompt_schema_version\":{SCENE_PROMPT_SCHEMA_VERSION}," + ))); + + let history_marker = ",\"branch_history\":"; + let first_stable_end = first_prompt.find(history_marker).expect("history marker"); + let third_stable_end = third_prompt.find(history_marker).expect("history marker"); + assert_eq!( + &first_prompt[..first_stable_end], + &third_prompt[..third_stable_end], + "resource prefix must remain byte-identical across turns" + ); + + let stable_at = third_prompt + .find("\"stable_prefix\"") + .expect("stable prefix"); + let history_at = third_prompt + .find("\"branch_history\"") + .expect("branch history"); + let dynamic_at = third_prompt.find("\"dynamic_tail\"").expect("dynamic tail"); + let first_turn_at = third_prompt.find("FIRST PLAYER TURN").expect("first turn"); + let second_turn_at = third_prompt + .find("SECOND PLAYER TURN") + .expect("second turn"); + let current_at = third_prompt + .find("THIRD CURRENT INPUT") + .expect("current input"); + assert!(stable_at < history_at); + assert!(history_at < first_turn_at); + assert!(first_turn_at < second_turn_at); + assert!(second_turn_at < dynamic_at); + assert!(dynamic_at < current_at); + assert!(third_prompt.contains("FIRST COMMITTED REPLY")); + assert!(third_prompt.contains("SECOND COMMITTED REPLY")); + assert!(third_prompt.contains("harbor_platform")); + assert!(third_prompt.contains("concerned")); + assert!(third_prompt.contains("holding_lantern")); + } + + #[test] + fn model_prompt_never_serializes_hidden_runtime_canaries() { + let mut runtime = state(); + runtime.items.push(item( + "NPC_PRIVATE_INVENTORY_CANARY", + "guide", + "guide", + ItemPlacement::Hidden, + )); + let history = BranchHistoryProjection { + entries: vec![history_entry( + "node_1", + "Safe player input.", + "Safe committed reply.", + )], + }; + let context = compile_scene_context_with_history( + &base_bundle(), + &request("Safe current input."), + &runtime, + &history, + ) + .expect("context"); + let prompt = encode_compiled_scene_prompt(&context).expect("prompt"); + + for canary in [ + "PRIVATE_CHECK", + "PRIVATE_ACTION", + "PRIVATE_SKILL", + "PRIVATE CLOCK LABEL", + "NPC_PRIVATE_INVENTORY_CANARY", + "PRIVATE ITEM FACT", + "\"roll\"", + "\"target\"", + "\"difficulty\"", + "\"delta\"", + ] { + assert!(!prompt.contains(canary), "leaked {canary}"); + } + } + #[test] fn missing_or_ambiguous_bound_resources_are_compile_errors() { let mut missing = base_bundle(); @@ -1489,8 +2006,14 @@ mod tests { #[test] fn player_inventory_is_public_but_npc_hidden_and_item_hidden_facts_are_excluded() { let mut state = state(); + let mut borrowed_lamp = item("player_lamp", "guide", "player", ItemPlacement::Bag); + borrowed_lamp.acquisition = ItemAcquisition { + mode: AcquisitionMode::Borrowed, + from: Some("guide".into()), + at_node: "node_borrowed".into(), + }; state.items = vec![ - item("player_lamp", "player", "player", ItemPlacement::Bag), + borrowed_lamp, item("npc_lamp", "guide", "guide", ItemPlacement::Hand), item("concealed_lamp", "player", "player", ItemPlacement::Hidden), ]; @@ -1502,7 +2025,22 @@ mod tests { context.state_memory.player.inventory[0].instance_id, "player_lamp" ); + assert_eq!(context.state_memory.player.inventory[0].owner, "guide"); + assert_eq!( + context.state_memory.player.inventory[0].acquisition.mode, + AcquisitionMode::Borrowed + ); + assert_eq!( + context.state_memory.player.inventory[0] + .acquisition + .from + .as_deref(), + Some("guide") + ); let json = serde_json::to_string(&context).expect("json"); + assert!(json.contains("\"owner\":\"guide\"")); + assert!(json.contains("\"mode\":\"borrowed\"")); + assert!(json.contains("\"at_node\":\"node_borrowed\"")); assert!(!json.contains("npc_lamp")); assert!(!json.contains("concealed_lamp")); assert!(!json.contains("PRIVATE ITEM FACT")); @@ -1514,6 +2052,21 @@ mod tests { let context = compile_scene_context(&base_bundle(), &request("storm"), &state()).expect("context"); let json = serde_json::to_string(&context).expect("json"); + let relationship = &context + .state_memory + .primary_character + .relationship_to_player; + assert_eq!(relationship.affinity, RelationshipBand::Distant); + assert_eq!(relationship.trust, RelationshipBand::Distant); + assert_eq!(relationship.hope, RelationshipBand::Distant); + assert_eq!(relationship.respect, RelationshipBand::Guarded); + assert_eq!(relationship.intimacy, RelationshipBand::Guarded); + assert_eq!(relationship.attachment, RelationshipBand::Guarded); + assert!(json.contains("\"affinity\":\"distant\"")); + assert!(json.contains("\"respect\":\"guarded\"")); + assert!(!json.contains("\"affinity\":17")); + assert!(!json.contains("\"trust\":18")); + assert!(!json.contains("\"hope\":19")); for private_value in [ "PRIVATE_CHECK", @@ -1531,6 +2084,23 @@ mod tests { context.narrative_safety.hidden_checks, HiddenCheckTreatment::OmitMechanicalDetails ); + + let mut neutral_state = state(); + neutral_state.relationships.clear(); + let neutral = compile_scene_context(&base_bundle(), &request("storm"), &neutral_state) + .expect("neutral context"); + let neutral_relationship = neutral + .state_memory + .primary_character + .relationship_to_player; + assert_eq!(neutral_relationship.affinity, RelationshipBand::Warming); + assert_eq!(neutral_relationship.trust, RelationshipBand::Warming); + assert_eq!(neutral_relationship.hope, RelationshipBand::Warming); + assert!( + serde_json::to_string(&neutral_relationship) + .expect("neutral relationship") + .contains("warming") + ); } #[test] diff --git a/crates/nana-runtime/src/lapp_provider.rs b/crates/nana-runtime/src/lapp_provider.rs index 416727d..bd6ace5 100644 --- a/crates/nana-runtime/src/lapp_provider.rs +++ b/crates/nana-runtime/src/lapp_provider.rs @@ -23,9 +23,9 @@ use serde_json::{Value, json}; use crate::{ AdjudicationModel, AdjudicationModelInput, AdjudicationModelResponse, AdjudicationToolCall, - HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, InvalidModelOutputKind, ProviderError, TurnControl, - TurnPlan, TurnPlanProvider, compile_scene_context, encode_compiled_scene_context, - load_default_lapp_profile, provider_interruption, + BranchHistoryProjection, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, InvalidModelOutputKind, + ProviderError, TurnControl, TurnPlan, TurnPlanProvider, compile_scene_context_with_history, + encode_compiled_scene_prompt, load_default_lapp_profile, provider_interruption, }; pub const TURN_PLAN_TOOL_NAME: &str = "submit_turn_plan"; @@ -503,6 +503,8 @@ async fn wait_with_turn_control( #[derive(Debug)] pub struct LappTurnPlanProvider { executor: Executor, + bundle: ResourceBundle, + branch_history: BranchHistoryProjection, } /// Stateful LAPP adapter for the trusted hidden-check loop. @@ -515,6 +517,7 @@ pub struct LappTurnPlanProvider { pub struct LappAdjudicationModel { executor: Executor, bundle: ResourceBundle, + branch_history: BranchHistoryProjection, messages: Vec, current_request: Option, pending_tool_call_id: Option, @@ -526,6 +529,9 @@ impl LappAdjudicationModel { Self { executor, bundle, + branch_history: BranchHistoryProjection { + entries: Vec::new(), + }, messages: Vec::new(), current_request: None, pending_tool_call_id: None, @@ -537,6 +543,21 @@ impl LappAdjudicationModel { &self.executor } + #[must_use] + pub const fn branch_history(&self) -> &BranchHistoryProjection { + &self.branch_history + } + + #[must_use] + pub fn with_branch_history(mut self, branch_history: BranchHistoryProjection) -> Self { + self.branch_history = branch_history; + self + } + + pub fn set_branch_history(&mut self, branch_history: BranchHistoryProjection) { + self.branch_history = branch_history; + } + #[must_use] pub fn into_executor(self) -> Executor { self.executor @@ -602,7 +623,15 @@ impl AdjudicationModel for LappAdjudicationModel, ) -> Result { - self.respond_inner(input, None) + self.respond_inner(input, None, None) + } + + fn respond_with_history( + &mut self, + input: AdjudicationModelInput<'_>, + branch_history: &BranchHistoryProjection, + ) -> Result { + self.respond_inner(input, None, Some(branch_history)) } fn respond_with_control( @@ -610,7 +639,16 @@ impl AdjudicationModel for LappAdjudicationModel, control: &TurnControl, ) -> Result { - self.respond_inner(input, Some(control)) + self.respond_inner(input, Some(control), None) + } + + fn respond_with_history_and_control( + &mut self, + input: AdjudicationModelInput<'_>, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + self.respond_inner(input, Some(control), Some(branch_history)) } } @@ -619,12 +657,19 @@ impl LappAdjudicationModel { &mut self, input: AdjudicationModelInput<'_>, control: Option<&TurnControl>, + supplied_branch_history: Option<&BranchHistoryProjection>, ) -> Result { match input { AdjudicationModelInput::BeginTurn { request, state } => { - let context = compile_scene_context(&self.bundle, request, state) - .map_err(|_| ProviderError::ContextEncoding)?; - let encoded = encode_compiled_scene_context(&context) + let branch_history = supplied_branch_history.unwrap_or(&self.branch_history); + let context = compile_scene_context_with_history( + &self.bundle, + request, + state, + branch_history, + ) + .map_err(|_| ProviderError::ContextEncoding)?; + let encoded = encode_compiled_scene_prompt(&context) .map_err(|_| ProviderError::ContextEncoding)?; self.messages = vec![ ChatMessage { @@ -724,8 +769,14 @@ impl LappAdjudicationModel { impl LappTurnPlanProvider { #[must_use] - pub const fn new(executor: Executor) -> Self { - Self { executor } + pub const fn new(executor: Executor, bundle: ResourceBundle) -> Self { + Self { + executor, + bundle, + branch_history: BranchHistoryProjection { + entries: Vec::new(), + }, + } } #[must_use] @@ -733,6 +784,21 @@ impl LappTurnPlanProvider { &self.executor } + #[must_use] + pub const fn branch_history(&self) -> &BranchHistoryProjection { + &self.branch_history + } + + #[must_use] + pub fn with_branch_history(mut self, branch_history: BranchHistoryProjection) -> Self { + self.branch_history = branch_history; + self + } + + pub fn set_branch_history(&mut self, branch_history: BranchHistoryProjection) { + self.branch_history = branch_history; + } + #[must_use] pub fn into_executor(self) -> Executor { self.executor @@ -741,28 +807,31 @@ impl LappTurnPlanProvider { impl LappTurnPlanProvider { /// Load the current user's LAPP profile and select its `chat` default. - pub fn from_default_profile() -> Result { + pub fn from_default_profile(bundle: ResourceBundle) -> Result { let profile = load_default_lapp_profile()?; - Self::from_profile(&profile) + Self::from_profile(&profile, bundle) } pub fn from_default_profile_with_gate( + bundle: ResourceBundle, native_call_gate: LappNativeCallGate, ) -> Result { let profile = load_default_lapp_profile()?; - Self::from_profile_with_gate(&profile, native_call_gate) + Self::from_profile_with_gate(&profile, bundle, native_call_gate) } /// Build against an already validated LAPP profile. - pub fn from_profile(profile: &Profile) -> Result { - OpenLappChatExecutor::from_profile(profile).map(Self::new) + pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result { + OpenLappChatExecutor::from_profile(profile).map(|executor| Self::new(executor, bundle)) } pub fn from_profile_with_gate( profile: &Profile, + bundle: ResourceBundle, native_call_gate: LappNativeCallGate, ) -> Result { - OpenLappChatExecutor::from_profile_with_gate(profile, native_call_gate).map(Self::new) + OpenLappChatExecutor::from_profile_with_gate(profile, native_call_gate) + .map(|executor| Self::new(executor, bundle)) } } @@ -772,7 +841,7 @@ impl TurnPlanProvider for LappTurnPlanProvider request: &TurnRequest, state: &RuntimeState, ) -> Result { - let input = build_chat_input(request, state)?; + let input = build_chat_input(&self.bundle, request, state, &self.branch_history)?; let response = self.executor.chat(&input)?; let plan = parse_chat_response(&response, request)?; validate_generated_plan(request, &plan)?; @@ -785,7 +854,34 @@ impl TurnPlanProvider for LappTurnPlanProvider state: &RuntimeState, control: &TurnControl, ) -> Result { - let input = build_chat_input(request, state)?; + let input = build_chat_input(&self.bundle, request, state, &self.branch_history)?; + let response = self.executor.chat_with_control(&input, control)?; + let plan = parse_chat_response(&response, request)?; + validate_generated_plan(request, &plan)?; + Ok(plan) + } + + fn plan_turn_with_history( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + ) -> Result { + let input = build_chat_input(&self.bundle, request, state, branch_history)?; + let response = self.executor.chat(&input)?; + let plan = parse_chat_response(&response, request)?; + validate_generated_plan(request, &plan)?; + Ok(plan) + } + + fn plan_turn_with_history_and_control( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + let input = build_chat_input(&self.bundle, request, state, branch_history)?; let response = self.executor.chat_with_control(&input, control)?; let plan = parse_chat_response(&response, request)?; validate_generated_plan(request, &plan)?; @@ -821,14 +917,14 @@ impl TurnPlanWire { } fn build_chat_input( + bundle: &ResourceBundle, request: &TurnRequest, state: &RuntimeState, + branch_history: &BranchHistoryProjection, ) -> Result { - let context = serde_json::to_string(&json!({ - "request": request, - "runtimeState": state, - })) - .map_err(|_| ProviderError::ContextEncoding)?; + let context = compile_scene_context_with_history(bundle, request, state, branch_history) + .and_then(|context| encode_compiled_scene_prompt(&context)) + .map_err(|_| ProviderError::ContextEncoding)?; Ok(ChatInput { messages: vec![ @@ -1132,8 +1228,8 @@ mod tests { use std::time::{Duration, Instant}; use nana_domain::{ - CheckResult, ResourceBundle, RuntimeState, StateOp, TurnFailureCode, TurnIntent, - TurnRequest, + BeatKind, CheckDifficulty, CheckRecord, CheckResult, ResourceBundle, RuntimeState, StateOp, + TurnFailureCode, TurnIntent, TurnRequest, }; use openlapp::client::{ChatInput, ChatResponse, ChatRole, ToolCall}; use serde_json::{Value, json}; @@ -1144,7 +1240,8 @@ mod tests { parse_chat_response, run_isolated_request, wait_with_turn_control, }; use crate::{ - AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationModel, + AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationModel, BranchHistoryBeat, + BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection, BranchHistoryScene, InvalidModelOutputKind, TurnControl, TurnPlanProvider, map_provider_error, }; @@ -1354,6 +1451,51 @@ mod tests { .expect("embedded demo bundle") } + fn two_turn_history() -> BranchHistoryProjection { + BranchHistoryProjection { + entries: vec![ + BranchHistoryEntry { + node_id: "node_opening".into(), + player_input: "FIRST PLAYER TURN".into(), + scene: BranchHistoryScene { + id: "station_platform".into(), + title: "Old Station".into(), + }, + character: BranchHistoryCharacter { + id: "nana".into(), + name: "Nana".into(), + expression: Some("guarded".into()), + pose: Some("holding_coat".into()), + }, + beats: vec![BranchHistoryBeat { + kind: BeatKind::Dialogue, + speaker: Some("Nana".into()), + text: "FIRST COMMITTED REPLY".into(), + }], + }, + BranchHistoryEntry { + node_id: "node_second".into(), + player_input: "SECOND PLAYER TURN".into(), + scene: BranchHistoryScene { + id: "station_platform".into(), + title: "Old Station".into(), + }, + character: BranchHistoryCharacter { + id: "nana".into(), + name: "Nana".into(), + expression: Some("uncertain".into()), + pose: Some("holding_coat".into()), + }, + beats: vec![BranchHistoryBeat { + kind: BeatKind::Narration, + speaker: None, + text: "SECOND COMMITTED REPLY".into(), + }], + }, + ], + } + } + fn response(text: String, tool_calls: Vec) -> ChatResponse { ChatResponse { text, @@ -1371,10 +1513,24 @@ mod tests { fn text_json_produces_a_non_view_turn_plan_and_expected_chat_input() { let executor = ScriptedExecutor::returning(Ok(response(plan_value().to_string(), Vec::new()))); - let mut provider = LappTurnPlanProvider::new(executor); + let mut provider = LappTurnPlanProvider::new(executor, demo_bundle()); + let mut runtime = state(); + runtime.checks.push(CheckRecord { + id: "HIDDEN_CHECK_CANARY".into(), + action_id: "HIDDEN_ACTION_CANARY".into(), + actor: "player".into(), + skill: "HIDDEN_SKILL_CANARY".into(), + target: 55, + difficulty: CheckDifficulty::Regular, + bonus_dice: 0, + roll: 42, + result: CheckResult::Failure, + pushed_from: None, + node_id: "node_1".into(), + }); let plan = provider - .plan_turn(&request(), &state()) + .plan_turn(&request(), &runtime) .expect("valid text plan"); assert_eq!( @@ -1395,11 +1551,30 @@ mod tests { assert_eq!(executor.inputs[0].messages.len(), 2); assert_eq!(executor.inputs[0].tools.len(), 1); assert_eq!(executor.inputs[0].tools[0].name, TURN_PLAN_TOOL_NAME); - assert!( - executor.inputs[0].messages[1] - .content - .contains("runtimeState") - ); + let prompt = &executor.inputs[0].messages[1].content; + for expected in [ + "\"prompt_schema_version\":1", + "\"stable_prefix\"", + "\"branch_history\"", + "\"dynamic_tail\"", + "\"character_card\"", + "独自守在废弃青川站", + ] { + assert!(prompt.contains(expected), "missing {expected}"); + } + for forbidden in [ + "runtimeState", + "runtime_state", + "HIDDEN_CHECK_CANARY", + "HIDDEN_ACTION_CANARY", + "HIDDEN_SKILL_CANARY", + "\"roll\"", + "\"target\"", + "\"initial_relationship\"", + "\"relationship_effects\"", + ] { + assert!(!prompt.contains(forbidden), "leaked {forbidden}"); + } assert!( executor.inputs[0].messages[0] .content @@ -1415,7 +1590,7 @@ mod tests { arguments: plan_value(), }; let executor = ScriptedExecutor::returning(Ok(response(String::new(), vec![tool_call]))); - let mut provider = LappTurnPlanProvider::new(executor); + let mut provider = LappTurnPlanProvider::new(executor, demo_bundle()); let plan = provider .plan_turn(&request(), &state()) @@ -1460,7 +1635,7 @@ mod tests { let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog); let plan = provider - .plan_turn(&request(), &state()) + .plan_turn_with_history(&request(), &state(), &two_turn_history()) .expect("hidden check then final plan"); let recorded = plan .delta @@ -1497,6 +1672,32 @@ mod tests { .content .contains("\"value\":55") ); + for expected in [ + "FIRST PLAYER TURN", + "FIRST COMMITTED REPLY", + "SECOND PLAYER TURN", + "SECOND COMMITTED REPLY", + ] { + assert!( + executor.inputs[0].messages[1].content.contains(expected), + "missing {expected}" + ); + } + let prompt = &executor.inputs[0].messages[1].content; + assert!( + prompt.find("\"stable_prefix\"").expect("stable prefix") + < prompt.find("\"branch_history\"").expect("history") + ); + assert!( + prompt.find("\"branch_history\"").expect("history") + < prompt.find("\"dynamic_tail\"").expect("dynamic tail") + ); + assert!( + prompt.find("SECOND PLAYER TURN").expect("second turn") + < prompt + .find("I will return before dawn.") + .expect("current input") + ); let continuation = &executor.inputs[1].messages; assert_eq!(continuation[2].role, ChatRole::Assistant); @@ -1660,7 +1861,7 @@ mod tests { } }]); let executor = ScriptedExecutor::returning(Ok(response(value.to_string(), Vec::new()))); - let mut provider = LappTurnPlanProvider::new(executor); + let mut provider = LappTurnPlanProvider::new(executor, demo_bundle()); assert!(matches!( provider.plan_turn(&request(), &state()), @@ -1676,7 +1877,7 @@ mod tests { code: Some(openlapp::ErrorCode::HttpStatus), status: None, })); - let mut provider = LappTurnPlanProvider::new(executor); + let mut provider = LappTurnPlanProvider::new(executor, demo_bundle()); let error = provider .plan_turn(&request(), &state()) diff --git a/crates/nana-runtime/src/lib.rs b/crates/nana-runtime/src/lib.rs index be13b62..14149f7 100644 --- a/crates/nana-runtime/src/lib.rs +++ b/crates/nana-runtime/src/lib.rs @@ -20,14 +20,17 @@ pub use adjudication::{ QualitativeCheckOutcome, classify_roll, deterministic_roll, }; pub use context::{ - CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard, + BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection, + BranchHistoryScene, CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard, ContextCompileError, ContextInventoryItem, ContextJudgmentRule, ContextPersona, ContextPlotEvent, ContextPlotOutcome, ContextPlotPressure, ContextSkill, ContextStateMemory, ContextStatePosition, ContextSummary, ContextTurn, ContextWorldBookEntry, HiddenCheckTreatment, NarrativeSafety, PlayerMemory, ResourceProvenance, ResourceStringTreatment, - SCENE_CONTEXT_SCHEMA_VERSION, SceneContext, SharedMemory, SummaryClassification, SummaryMemory, - SummaryTreatment, compile_scene_context, compile_scene_context_with_budget, - encode_compiled_scene_context, + SCENE_CONTEXT_SCHEMA_VERSION, SCENE_PROMPT_SCHEMA_VERSION, SceneContext, SharedMemory, + SummaryClassification, SummaryMemory, SummaryTreatment, compile_scene_context, + compile_scene_context_with_budget, compile_scene_context_with_history, + compile_scene_context_with_history_and_budget, encode_compiled_scene_context, + encode_compiled_scene_prompt, }; pub use lapp_provider::{ ChatExecutor, LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit, @@ -95,6 +98,21 @@ pub trait TurnPlanProvider { state: &RuntimeState, ) -> Result; + /// Produce a plan with a caller-selected, player-safe root-to-head history. + /// + /// The default preserves existing providers and ignores history. Providers + /// backed by a narrative model should override this method rather than + /// querying persistence directly. + fn plan_turn_with_history( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + ) -> Result { + let _ = branch_history; + self.plan_turn(request, state) + } + /// Produce a plan while observing a one-shot turn control. /// /// The default keeps existing providers source-compatible and discards @@ -116,6 +134,21 @@ pub trait TurnPlanProvider { result } } + + /// History-aware form of [`TurnPlanProvider::plan_turn_with_control`]. + /// + /// This default preserves the controlled behavior of existing providers + /// and ignores history. History-aware providers should override it. + fn plan_turn_with_history_and_control( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + let _ = branch_history; + self.plan_turn_with_control(request, state, control) + } } impl TurnPlanProvider for &mut Provider { @@ -135,6 +168,25 @@ impl TurnPlanProvider for &mut Provider { ) -> Result { (**self).plan_turn_with_control(request, state, control) } + + fn plan_turn_with_history( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + ) -> Result { + (**self).plan_turn_with_history(request, state, branch_history) + } + + fn plan_turn_with_history_and_control( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + (**self).plan_turn_with_history_and_control(request, state, branch_history, control) + } } /// Projects only already-committed state into the player-safe read model. @@ -204,9 +256,14 @@ where return Err(stale_node()); } + let ancestor_chain = self + .store + .load_ancestor_chain(&request.story_id, ¤t.current_node) + .map_err(|error| map_store_error(&error))?; + let branch_history = BranchHistoryProjection::from_committed_nodes(ancestor_chain.iter()); let plan = self .provider - .plan_turn_with_control(request, ¤t, control) + .plan_turn_with_history_and_control(request, ¤t, &branch_history, control) .map_err(|error| map_provider_error(&error))?; validate_turn_plan(request, &plan)?; @@ -1022,13 +1079,14 @@ mod persistent_turn_tests { use nana_store::{InMemoryStoryStore, StoryStore}; use super::{ - ProviderError, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, - hash_runtime_state, + BranchHistoryProjection, ProviderError, TurnControl, TurnEngine, TurnPlan, + TurnPlanProvider, TurnProjector, hash_runtime_state, }; struct RecordingPlanProvider { responses: VecDeque>, calls: usize, + histories: Vec, } impl RecordingPlanProvider { @@ -1036,6 +1094,7 @@ mod persistent_turn_tests { Self { responses: VecDeque::from([response]), calls: 0, + histories: Vec::new(), } } } @@ -1051,6 +1110,17 @@ mod persistent_turn_tests { .pop_front() .unwrap_or(Err(ProviderError::FixtureExhausted)) } + + fn plan_turn_with_history_and_control( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + self.histories.push(branch_history.clone()); + self.plan_turn_with_control(request, state, control) + } } struct CancellingPlanProvider { @@ -1228,6 +1298,15 @@ mod persistent_turn_tests { assert_eq!(result.player_view.branch_id, "branch_main"); assert_eq!(result.player_view.suggestions.len(), 1); assert_eq!(engine.provider().calls, 1); + assert_eq!(engine.provider().histories.len(), 1); + assert_eq!( + engine.provider().histories[0] + .entries + .iter() + .map(|entry| entry.node_id.as_str()) + .collect::>(), + vec!["node_1"] + ); assert_eq!(engine.projector().calls, 1); let committed = store @@ -1237,6 +1316,42 @@ mod persistent_turn_tests { assert_eq!(committed.world_flags.get("promise_spoken"), Some(&true)); } + #[test] + fn third_turn_receives_the_two_committed_ancestor_nodes() { + let store = seeded_store(); + let mut second = node("node_2", Some("node_1"), "branch_main"); + second.user_input = "SECOND PLAYER TURN".into(); + second.presentation.beats.push(PresentationBeat { + id: "beat_second".into(), + kind: BeatKind::Dialogue, + speaker: Some("Nana".into()), + text: "SECOND COMMITTED REPLY".into(), + visual: None, + }); + store + .append_node(&second, &state("node_2", "branch_main")) + .expect("second committed node"); + + let mut engine = TurnEngine::new( + &store, + RecordingPlanProvider::new(Ok(plan("node_3", StateDelta { ops: Vec::new() }))), + projector(&store), + ); + engine.submit_turn(&request("node_2")).expect("third turn"); + + let history = &engine.provider().histories[0]; + assert_eq!( + history + .entries + .iter() + .map(|entry| entry.node_id.as_str()) + .collect::>(), + vec!["node_1", "node_2"] + ); + assert_eq!(history.entries[1].player_input, "SECOND PLAYER TURN"); + assert_eq!(history.entries[1].beats[0].text, "SECOND COMMITTED REPLY"); + } + #[test] fn stale_request_does_not_call_provider_or_move_head() { let store = seeded_store(); diff --git a/crates/nana-store/src/lib.rs b/crates/nana-store/src/lib.rs index 684048b..b2b3d13 100644 --- a/crates/nana-store/src/lib.rs +++ b/crates/nana-store/src/lib.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, path::Path, sync::{Mutex, MutexGuard}, time::Duration, @@ -96,6 +96,18 @@ pub trait StoryStore: Send + Sync { fn load_node(&self, story_id: &str, node_id: &str) -> Result; + /// Loads the immutable path from the story root through `node_id`. + /// + /// Parent links may cross branch identifiers because a branch created from + /// history shares its ancestors with the source branch. Implementations + /// therefore follow only `(story_id, node_id)` and never filter ancestors + /// by the current node's `branch_id`. + fn load_ancestor_chain( + &self, + story_id: &str, + node_id: &str, + ) -> Result, StoreError>; + fn list_branches(&self, story_id: &str) -> Result, StoreError>; fn active_branch(&self, story_id: &str) -> Result; @@ -332,6 +344,20 @@ impl StoryStore for InMemoryStoryStore { .ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned())) } + fn load_ancestor_chain( + &self, + story_id: &str, + node_id: &str, + ) -> Result, StoreError> { + let data = self.lock()?; + walk_ancestor_chain(node_id, |candidate_id| { + Ok(data + .nodes + .get(&(story_id.to_owned(), candidate_id.to_owned())) + .cloned()) + }) + } + fn list_branches(&self, story_id: &str) -> Result, StoreError> { let data = self.lock()?; if !data @@ -816,6 +842,44 @@ impl StoryStore for SqliteStoryStore { Ok(node) } + fn load_ancestor_chain( + &self, + story_id: &str, + node_id: &str, + ) -> Result, StoreError> { + let connection = self.lock()?; + let mut statement = connection.prepare( + "SELECT branch_id, parent_id, node_json + FROM nodes + WHERE story_id = ?1 AND node_id = ?2", + )?; + + walk_ancestor_chain(node_id, |candidate_id| { + let stored = statement + .query_row(params![story_id, candidate_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + }) + .optional()?; + let Some((branch_id, parent_id, node_json)) = stored else { + return Ok(None); + }; + + let node = deserialize_node(&node_json)?; + validate_loaded_node( + &node, + story_id, + candidate_id, + &branch_id, + parent_id.as_deref(), + )?; + Ok(Some(node)) + }) + } + fn list_branches(&self, story_id: &str) -> Result, StoreError> { let connection = self.lock()?; let story_exists = connection.query_row( @@ -1727,6 +1791,34 @@ fn load_branch_state( restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id) } +fn walk_ancestor_chain( + node_id: &str, + mut load_node: impl FnMut(&str) -> Result, StoreError>, +) -> Result, StoreError> { + let mut current_id = node_id.to_owned(); + let mut visited = BTreeSet::new(); + let mut chain = Vec::new(); + + loop { + if !visited.insert(current_id.clone()) { + return Err(StoreError::StateMismatch("ancestry cycle")); + } + + let node = load_node(¤t_id)? + .ok_or_else(|| StoreError::ParentNotFound(current_id.clone()))?; + let parent_id = node.parent_id.clone(); + chain.push(node); + + let Some(parent_id) = parent_id else { + break; + }; + current_id = parent_id; + } + + chain.reverse(); + Ok(chain) +} + fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> { if node.story_id != state.story_id { return Err(StoreError::StateMismatch("story_id")); @@ -2075,6 +2167,104 @@ mod tests { .expect("main append"); } + fn assert_loads_only_the_selected_branch_path(store: &impl InspectableStoryStore) { + seed_historical_main(store); + store + .fork_branch("story_demo", "node_001", "branch_rewind") + .expect("historical fork"); + store + .append_node( + &node("node_rewind", Some("node_001"), "branch_rewind"), + &state("node_rewind", "branch_rewind"), + ) + .expect("rewind append"); + + let main_path = store + .load_ancestor_chain("story_demo", "node_002") + .expect("main ancestry"); + assert_eq!( + main_path + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["node_001", "node_002"] + ); + + let rewind_path = store + .load_ancestor_chain("story_demo", "node_rewind") + .expect("rewind ancestry"); + assert_eq!( + rewind_path + .iter() + .map(|node| (node.id.as_str(), node.branch_id.as_str())) + .collect::>(), + vec![ + ("node_001", "branch_main"), + ("node_rewind", "branch_rewind"), + ] + ); + } + + fn assert_loads_a_500_node_ancestor_chain(store: &impl InspectableStoryStore) { + let mut parent_id = None; + for index in 0..500 { + let node_id = format!("node_{index:03}"); + store + .append_node( + &node(&node_id, parent_id.as_deref(), "branch_main"), + &state(&node_id, "branch_main"), + ) + .expect("long-chain append"); + parent_id = Some(node_id); + } + + let chain = store + .load_ancestor_chain("story_demo", "node_499") + .expect("500-node ancestry"); + assert_eq!(chain.len(), 500); + assert_eq!(chain.first().map(|node| node.id.as_str()), Some("node_000")); + assert_eq!(chain.last().map(|node| node.id.as_str()), Some("node_499")); + } + + fn assert_reports_an_unknown_ancestor_start(store: &impl InspectableStoryStore) { + assert_eq!( + store.load_ancestor_chain("story_demo", "node_missing"), + Err(StoreError::ParentNotFound("node_missing".to_owned())) + ); + } + + fn set_memory_parent(store: &InMemoryStoryStore, node_id: &str, parent_id: Option<&str>) { + let mut data = store.data.lock().expect("memory store lock"); + data.nodes + .get_mut(&("story_demo".to_owned(), node_id.to_owned())) + .expect("test node") + .parent_id = parent_id.map(ToOwned::to_owned); + } + + fn set_sqlite_parent(store: &SqliteStoryStore, node_id: &str, parent_id: Option<&str>) { + let mut corrupted = store + .load_node("story_demo", node_id) + .expect("stored test node"); + corrupted.parent_id = parent_id.map(ToOwned::to_owned); + let node_json = serde_json::to_string(&corrupted).expect("serializable test node"); + + let connection = store.connection.lock().expect("SQLite connection lock"); + connection + .execute_batch("PRAGMA foreign_keys = OFF;") + .expect("disable foreign keys for corruption test"); + connection + .execute( + "UPDATE nodes + SET parent_id = ?1, node_json = ?2 + WHERE story_id = 'story_demo' AND node_id = ?3", + params![parent_id, node_json, node_id], + ) + .expect("corrupt test ancestry"); + connection + .execute_batch("PRAGMA foreign_keys = ON;") + .expect("restore foreign keys after corruption test"); + } + fn assert_creates_independent_branches_from_history(store: &impl InspectableStoryStore) { seed_historical_main(store); @@ -2277,6 +2467,90 @@ mod tests { assert_forks_from_an_old_node(&store); } + #[test] + fn memory_loads_ancestors_across_shared_branch_history() { + let store = InMemoryStoryStore::new(); + assert_loads_only_the_selected_branch_path(&store); + } + + #[test] + fn sqlite_loads_ancestors_across_shared_branch_history() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + assert_loads_only_the_selected_branch_path(&store); + } + + #[test] + fn memory_loads_500_ancestors_without_a_ui_depth_limit() { + let store = InMemoryStoryStore::new(); + assert_loads_a_500_node_ancestor_chain(&store); + } + + #[test] + fn sqlite_loads_500_ancestors_without_a_ui_depth_limit() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + assert_loads_a_500_node_ancestor_chain(&store); + } + + #[test] + fn memory_reports_an_unknown_ancestor_start() { + let store = InMemoryStoryStore::new(); + assert_reports_an_unknown_ancestor_start(&store); + } + + #[test] + fn sqlite_reports_an_unknown_ancestor_start() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + assert_reports_an_unknown_ancestor_start(&store); + } + + #[test] + fn memory_reports_a_broken_ancestor_link() { + let store = InMemoryStoryStore::new(); + seed_historical_main(&store); + set_memory_parent(&store, "node_002", Some("node_missing")); + + assert_eq!( + store.load_ancestor_chain("story_demo", "node_002"), + Err(StoreError::ParentNotFound("node_missing".to_owned())) + ); + } + + #[test] + fn sqlite_reports_a_broken_ancestor_link() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + seed_historical_main(&store); + set_sqlite_parent(&store, "node_002", Some("node_missing")); + + assert_eq!( + store.load_ancestor_chain("story_demo", "node_002"), + Err(StoreError::ParentNotFound("node_missing".to_owned())) + ); + } + + #[test] + fn memory_reports_an_ancestor_cycle() { + let store = InMemoryStoryStore::new(); + seed_historical_main(&store); + set_memory_parent(&store, "node_001", Some("node_002")); + + assert_eq!( + store.load_ancestor_chain("story_demo", "node_002"), + Err(StoreError::StateMismatch("ancestry cycle")) + ); + } + + #[test] + fn sqlite_reports_an_ancestor_cycle() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + seed_historical_main(&store); + set_sqlite_parent(&store, "node_001", Some("node_002")); + + assert_eq!( + store.load_ancestor_chain("story_demo", "node_002"), + Err(StoreError::StateMismatch("ancestry cycle")) + ); + } + #[test] fn memory_creates_independent_branch_heads_from_historical_nodes() { let store = InMemoryStoryStore::new(); diff --git a/docs/status/2026-07-28-m2-wave7-local.md b/docs/status/2026-07-28-m2-wave7-local.md new file mode 100644 index 0000000..7e41a57 --- /dev/null +++ b/docs/status/2026-07-28-m2-wave7-local.md @@ -0,0 +1,59 @@ +# M2 第七波本地开工状态 + +日期:2026-07-28 + +## 本地基线 + +- 从私有 Gitea 安全恢复 `integration/v1@23672e857b`,并通过 `git fsck` 检查对象完整性。 +- 下载时使用的临时仓库只读 Token 已在 Gitea 撤销;临时配置、bundle 与内存中的凭据均 + 已清理。 +- `origin` 已恢复为 Gitea SSH 地址,分支仍跟踪 `origin/integration/v1`。当前执行环境的 + 网络代理会关闭 SSH 2222 端口,因此本轮只提交到本地,不把“已配置公钥”误报为“已成功 + 推送”。 +- 相邻 `lapp-rs` 已固定在 `5ba3c659e1536ec4bee16340faca603940a5cb17`,未修改其源码。 +- 本地基线提交为 `37b3397 chore: establish wave 7 local baseline`。 + +## 已完成 + +### Windows 可重复基线 + +- 新增 `.gitattributes`,固定文本 LF 与图片、音频二进制属性。 +- Node 与 Rust 契约源码哈希在计算前统一 LF,避免 Windows CRLF checkout 产生假漂移。 +- 增加 LF / CRLF 哈希等价测试。 +- 安装并锁定 Node 24、pnpm 10.29.2、Rust 1.96.0、rustfmt 与 clippy。 +- 使用锁文件安装 JavaScript 依赖。 + +### 当前分支连续上下文 + +- Store 新增按 `(story_id, node_id)` 和 `parent_id` 读取根到当前节点祖先链的接口。 +- Memory 与 SQLite 后端均检测缺失父节点和父链循环;读取不依赖 `branch_id`,允许分叉 + 复用共享祖先,同时不会遍历兄弟分支。 +- Runtime 在生成本轮前自动投影当前祖先链,并把已提交的玩家输入、演出节拍、场景和角色 + 视觉状态传给 Provider;未选择的行动建议不会被误当成历史事实。 +- LAPP 普通回合与隐藏判定回合共用同一套缓存友好上下文协议: + `stable_prefix → branch_history → dynamic_tail`。 +- 动态状态只包含可用于角色扮演的安全投影,包括关系阶段与玩家可见持有物的归属、取得 + 方式;精确关系数值、隐藏骰点、NPC 私物、未触发世界书和状态 delta 仍不能进入提示。 +- Tauri 的线路恢复复用 Store 祖先链接口,不再保留旧的 200 节点读取上限。 + +## 验证 + +- `node scripts/verify-contracts.mjs`:25 份契约无漂移。 +- `pnpm verify:web`:TypeScript 检查、5 个测试文件 / 29 项测试及生产构建通过。 +- `cargo metadata --no-deps`:通过,相邻 `lapp-rs` 路径依赖可解析。 +- `cargo fmt --all -- --check`:通过。 +- `git diff --check`:通过。 + +完整 Rust 测试、Clippy、Tauri 后端测试和 Windows 桌面编译尚未关闭,原因不是项目依赖 +解析,而是本机缺少 Microsoft C++ Build Tools 与 Windows SDK,`rustc` 当前找不到 +`link.exe`。 + +## 下一步 + +1. 安装 Microsoft C++ Build Tools 与 Windows SDK 后立即运行 `pnpm verify:rust` 和 + `pnpm tauri build --no-bundle`。 +2. 为长分支实现非权威上下文检查点、来源哈希和 SQLite v3 迁移;历史不得静默截断。 +3. 在连续上下文门禁关闭后,实现“风险预检 → 玩家确认 → 隐藏判定失败 → 推骰 / 重新 + 生成”纵向切片。 +4. 最后在隔离存档上启动 Demo 窗口,验证重启恢复、终局、回溯和双线路隔离,再运行真实 + LAPP 最小连接与隐藏判定工具调用。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95662df..cb07637 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,9 +24,10 @@ use nana_engine::{ SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view, }; use nana_runtime::{ - AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, LappNativeCallGate, - LappNativeCallPermit, OpenLappChatExecutor, ProviderError, TurnControl, TurnEngine, TurnPlan, - TurnPlanProvider, TurnProjector, load_default_lapp_profile, + AdjudicatingTurnPlanProvider, AdjudicationCatalog, BranchHistoryProjection, + LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit, OpenLappChatExecutor, + ProviderError, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, + load_default_lapp_profile, }; use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore}; use openlapp::{ @@ -359,6 +360,41 @@ impl TurnPlanProvider for RuntimePlanProvider { Self::RetiredTest => Err(ProviderError::Cancelled), } } + + fn plan_turn_with_history( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + ) -> Result { + match self { + Self::Demo(provider) => provider.plan_turn_with_history(request, state, branch_history), + Self::Lapp(provider) => provider.plan_turn_with_history(request, state, branch_history), + Self::Unavailable => Err(ProviderError::Configuration { code: None }), + #[cfg(test)] + Self::RetiredTest => Err(ProviderError::Cancelled), + } + } + + fn plan_turn_with_history_and_control( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + branch_history: &BranchHistoryProjection, + control: &TurnControl, + ) -> Result { + match self { + Self::Demo(provider) => { + provider.plan_turn_with_history_and_control(request, state, branch_history, control) + } + Self::Lapp(provider) => { + provider.plan_turn_with_history_and_control(request, state, branch_history, control) + } + Self::Unavailable => Err(ProviderError::Configuration { code: None }), + #[cfg(test)] + Self::RetiredTest => Err(ProviderError::Cancelled), + } + } } impl DemoAppState { @@ -905,22 +941,8 @@ impl DemoAppState { } fn load_lineage(&self, current: &StoryNode) -> Result, StoreError> { - let mut lineage = vec![current.clone()]; - let mut cursor = current.parent_id.clone(); - - while let Some(ref node_id) = cursor { - if lineage.len() >= 200 { - return Err(StoreError::StateMismatch( - "story lineage exceeds the projection limit", - )); - } - let node = self.store.load_node(¤t.story_id, node_id)?; - cursor.clone_from(&node.parent_id); - lineage.push(node); - } - - lineage.reverse(); - Ok(lineage) + self.store + .load_ancestor_chain(¤t.story_id, ¤t.id) } } diff --git a/tasks/todo.md b/tasks/todo.md index fc6f028..9ea37af 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -34,10 +34,10 @@ ## Task 4-5:连续上下文与检查点 -- [ ] Store 按 parent_id 读取当前祖先链。 -- [ ] 第三轮包含前两轮原始剧情。 -- [ ] 兄弟分支上下文隔离。 -- [ ] 稳定前缀与动态尾部固定编排。 +- [x] Store 按 parent_id 读取当前祖先链。 +- [x] 第三轮包含前两轮原始剧情。 +- [x] 兄弟分支上下文隔离。 +- [x] 稳定前缀与动态尾部固定编排。 - [ ] 超预算检查点与来源哈希。 - [ ] SQLite v3 迁移与 500 节点测试。