feat(runtime): preserve current branch context
This commit is contained in:
@@ -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<AdjudicationModelResponse, ProviderError>;
|
||||
|
||||
/// 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<AdjudicationModelResponse, ProviderError> {
|
||||
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<AdjudicationModelResponse, ProviderError> {
|
||||
let _ = branch_history;
|
||||
self.respond_with_control(input, control)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
@@ -411,7 +436,26 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
) -> Result<TurnPlan, AdjudicationRunError> {
|
||||
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<TurnPlan, AdjudicationRunError> {
|
||||
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<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnPlan, AdjudicationRunError> {
|
||||
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<TurnPlan, AdjudicationRunError> {
|
||||
let mut records = Vec::new();
|
||||
let mut last_outcome = None;
|
||||
@@ -434,7 +493,9 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
|
||||
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<Model: AdjudicationModel> TurnPlanProvider for AdjudicatingTurnPlanProvider
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_turn_with_history(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
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<TurnPlan, ProviderError> {
|
||||
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>(
|
||||
|
||||
@@ -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<ResourceProvenance>,
|
||||
#[serde(default)]
|
||||
pub branch_history: BranchHistoryProjection,
|
||||
pub world_book_entries: Vec<ContextWorldBookEntry>,
|
||||
pub plot_events: Vec<ContextPlotEvent>,
|
||||
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<BranchHistoryEntry>,
|
||||
}
|
||||
|
||||
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<Item = &'a StoryNode>) -> 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<BranchHistoryBeat>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
player_input: impl Into<String>,
|
||||
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<String>,
|
||||
pub pose: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BranchHistoryBeat {
|
||||
pub kind: BeatKind,
|
||||
pub speaker: Option<String>,
|
||||
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<KnowledgeRecord>,
|
||||
pub promises: Vec<Promise>,
|
||||
}
|
||||
@@ -173,10 +292,14 @@ pub struct ContextInventoryItem {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub owner: String,
|
||||
pub quantity: u32,
|
||||
pub placement: ItemPlacement,
|
||||
pub condition: String,
|
||||
pub state_tags: Vec<String>,
|
||||
#[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<CompiledSceneContext, ContextCompileError> {
|
||||
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<CompiledSceneContext, ContextCompileError> {
|
||||
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<CompiledSceneContext, ContextCompileError> {
|
||||
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<CompiledSceneContext, ContextCompileError> {
|
||||
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<String, ContextCompileError> {
|
||||
#[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<Vec<ResourceProvenance>, 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::<Vec<_>>();
|
||||
let bound_ids = bound_refs
|
||||
.iter()
|
||||
.map(|dependency| dependency.id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<KnowledgeRecord>,
|
||||
records: impl Iterator<Item = &'a KnowledgeRecord>,
|
||||
@@ -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::<CompiledSceneContext>(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]
|
||||
|
||||
@@ -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<Output>(
|
||||
#[derive(Debug)]
|
||||
pub struct LappTurnPlanProvider<Executor> {
|
||||
executor: Executor,
|
||||
bundle: ResourceBundle,
|
||||
branch_history: BranchHistoryProjection,
|
||||
}
|
||||
|
||||
/// Stateful LAPP adapter for the trusted hidden-check loop.
|
||||
@@ -515,6 +517,7 @@ pub struct LappTurnPlanProvider<Executor> {
|
||||
pub struct LappAdjudicationModel<Executor> {
|
||||
executor: Executor,
|
||||
bundle: ResourceBundle,
|
||||
branch_history: BranchHistoryProjection,
|
||||
messages: Vec<ChatMessage>,
|
||||
current_request: Option<TurnRequest>,
|
||||
pending_tool_call_id: Option<String>,
|
||||
@@ -526,6 +529,9 @@ impl<Executor> LappAdjudicationModel<Executor> {
|
||||
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<Executor> LappAdjudicationModel<Executor> {
|
||||
&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<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executo
|
||||
&mut self,
|
||||
input: AdjudicationModelInput<'_>,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError> {
|
||||
self.respond_inner(input, None)
|
||||
self.respond_inner(input, None, None)
|
||||
}
|
||||
|
||||
fn respond_with_history(
|
||||
&mut self,
|
||||
input: AdjudicationModelInput<'_>,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError> {
|
||||
self.respond_inner(input, None, Some(branch_history))
|
||||
}
|
||||
|
||||
fn respond_with_control(
|
||||
@@ -610,7 +639,16 @@ impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executo
|
||||
input: AdjudicationModelInput<'_>,
|
||||
control: &TurnControl,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError> {
|
||||
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<AdjudicationModelResponse, ProviderError> {
|
||||
self.respond_inner(input, Some(control), Some(branch_history))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,12 +657,19 @@ impl<Executor: ChatExecutor> LappAdjudicationModel<Executor> {
|
||||
&mut self,
|
||||
input: AdjudicationModelInput<'_>,
|
||||
control: Option<&TurnControl>,
|
||||
supplied_branch_history: Option<&BranchHistoryProjection>,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError> {
|
||||
match input {
|
||||
AdjudicationModelInput::BeginTurn { request, state } => {
|
||||
let context = compile_scene_context(&self.bundle, request, state)
|
||||
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_context(&context)
|
||||
let encoded = encode_compiled_scene_prompt(&context)
|
||||
.map_err(|_| ProviderError::ContextEncoding)?;
|
||||
self.messages = vec![
|
||||
ChatMessage {
|
||||
@@ -724,8 +769,14 @@ impl<Executor> LappAdjudicationModel<Executor> {
|
||||
|
||||
impl<Executor> LappTurnPlanProvider<Executor> {
|
||||
#[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<Executor> LappTurnPlanProvider<Executor> {
|
||||
&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<Executor> LappTurnPlanProvider<Executor> {
|
||||
|
||||
impl LappTurnPlanProvider<OpenLappChatExecutor> {
|
||||
/// Load the current user's LAPP profile and select its `chat` default.
|
||||
pub fn from_default_profile() -> Result<Self, ProviderError> {
|
||||
pub fn from_default_profile(bundle: ResourceBundle) -> Result<Self, ProviderError> {
|
||||
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<Self, ProviderError> {
|
||||
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<Self, ProviderError> {
|
||||
OpenLappChatExecutor::from_profile(profile).map(Self::new)
|
||||
pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result<Self, ProviderError> {
|
||||
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<Self, ProviderError> {
|
||||
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<Executor: ChatExecutor> TurnPlanProvider for LappTurnPlanProvider<Executor>
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
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<Executor: ChatExecutor> TurnPlanProvider for LappTurnPlanProvider<Executor>
|
||||
state: &RuntimeState,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
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<TurnPlan, ProviderError> {
|
||||
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<TurnPlan, ProviderError> {
|
||||
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,13 +917,13 @@ impl TurnPlanWire {
|
||||
}
|
||||
|
||||
fn build_chat_input(
|
||||
bundle: &ResourceBundle,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
) -> Result<ChatInput, ProviderError> {
|
||||
let context = serde_json::to_string(&json!({
|
||||
"request": request,
|
||||
"runtimeState": state,
|
||||
}))
|
||||
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 {
|
||||
@@ -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<ToolCall>) -> 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())
|
||||
|
||||
@@ -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<TurnPlan, ProviderError>;
|
||||
|
||||
/// 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<TurnPlan, ProviderError> {
|
||||
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<TurnPlan, ProviderError> {
|
||||
let _ = branch_history;
|
||||
self.plan_turn_with_control(request, state, control)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
|
||||
@@ -135,6 +168,25 @@ impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
(**self).plan_turn_with_control(request, state, control)
|
||||
}
|
||||
|
||||
fn plan_turn_with_history(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
(**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<TurnPlan, ProviderError> {
|
||||
(**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<Result<TurnPlan, ProviderError>>,
|
||||
calls: usize,
|
||||
histories: Vec<BranchHistoryProjection>,
|
||||
}
|
||||
|
||||
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<TurnPlan, ProviderError> {
|
||||
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<_>>(),
|
||||
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<_>>(),
|
||||
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();
|
||||
|
||||
@@ -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<StoryNode, StoreError>;
|
||||
|
||||
/// 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<Vec<StoryNode>, StoreError>;
|
||||
|
||||
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError>;
|
||||
|
||||
fn active_branch(&self, story_id: &str) -> Result<String, StoreError>;
|
||||
@@ -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<Vec<StoryNode>, 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<Vec<StoredBranch>, 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<Vec<StoryNode>, 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<String>>(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<Vec<StoredBranch>, 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<Option<StoryNode>, StoreError>,
|
||||
) -> Result<Vec<StoryNode>, 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<_>>(),
|
||||
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<_>>(),
|
||||
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();
|
||||
|
||||
@@ -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 最小连接与隐藏判定工具调用。
|
||||
+41
-19
@@ -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<TurnPlan, ProviderError> {
|
||||
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<TurnPlan, ProviderError> {
|
||||
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<Vec<StoryNode>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -34,10 +34,10 @@
|
||||
|
||||
## Task 4-5:连续上下文与检查点
|
||||
|
||||
- [ ] Store 按 parent_id 读取当前祖先链。
|
||||
- [ ] 第三轮包含前两轮原始剧情。
|
||||
- [ ] 兄弟分支上下文隔离。
|
||||
- [ ] 稳定前缀与动态尾部固定编排。
|
||||
- [x] Store 按 parent_id 读取当前祖先链。
|
||||
- [x] 第三轮包含前两轮原始剧情。
|
||||
- [x] 兄弟分支上下文隔离。
|
||||
- [x] 稳定前缀与动态尾部固定编排。
|
||||
- [ ] 超预算检查点与来源哈希。
|
||||
- [ ] SQLite v3 迁移与 500 节点测试。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user