1097 lines
37 KiB
Rust
1097 lines
37 KiB
Rust
use std::collections::{BTreeSet, VecDeque};
|
|
|
|
use nana_domain::{
|
|
PlayerView, PresentationSnapshot, RuntimeState, StateDelta, StoryNode, TurnFailure,
|
|
TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry,
|
|
};
|
|
use nana_engine::{ReduceError, apply_delta};
|
|
use nana_store::{StoreError, StoryStore};
|
|
use thiserror::Error;
|
|
|
|
mod adjudication;
|
|
mod context;
|
|
mod lapp_provider;
|
|
|
|
pub use adjudication::{
|
|
AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationError, AdjudicationModel,
|
|
AdjudicationModelInput, AdjudicationModelResponse, AdjudicationRunError, AdjudicationToolCall,
|
|
CatalogError, DEFAULT_MAX_ADJUDICATION_STEPS, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest,
|
|
QualitativeCheckOutcome, classify_roll, deterministic_roll,
|
|
};
|
|
pub use context::{
|
|
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,
|
|
};
|
|
pub use lapp_provider::{
|
|
ChatExecutor, LappAdjudicationModel, LappTurnPlanProvider, OpenLappChatExecutor,
|
|
TURN_PLAN_TOOL_NAME,
|
|
};
|
|
|
|
pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17";
|
|
pub const MAX_WORLD_BOOK_ENTRIES: usize = 8;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum InvalidModelOutputKind {
|
|
InvalidJson,
|
|
InvalidSchema,
|
|
InvalidShape,
|
|
InvalidPlan,
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ProviderError {
|
|
#[error("no recorded response remains")]
|
|
FixtureExhausted,
|
|
#[error("LAPP profile could not be loaded")]
|
|
Profile { code: openlapp::ErrorCode },
|
|
#[error("LAPP chat client could not be configured")]
|
|
Configuration { code: Option<openlapp::ErrorCode> },
|
|
#[error("LAPP chat request failed")]
|
|
Upstream { code: Option<openlapp::ErrorCode> },
|
|
#[error("model returned an invalid turn plan")]
|
|
InvalidModelOutput { kind: InvalidModelOutputKind },
|
|
#[error("turn context could not be encoded")]
|
|
ContextEncoding,
|
|
}
|
|
|
|
pub trait TurnProvider {
|
|
fn complete_turn(&mut self, request: &TurnRequest) -> Result<TurnResult, ProviderError>;
|
|
}
|
|
|
|
/// Non-view model output used by the persistent turn engine.
|
|
///
|
|
/// The provider can propose player-facing presentation and state changes, but it
|
|
/// cannot construct the final [`PlayerView`]. That view is derived from committed
|
|
/// state by a separate trusted projection boundary.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TurnPlan {
|
|
pub committed_node_id: String,
|
|
pub presentation: PresentationSnapshot,
|
|
pub delta: StateDelta,
|
|
}
|
|
|
|
/// Produces the uncommitted model plan for a turn.
|
|
pub trait TurnPlanProvider {
|
|
fn plan_turn(
|
|
&mut self,
|
|
request: &TurnRequest,
|
|
state: &RuntimeState,
|
|
) -> Result<TurnPlan, ProviderError>;
|
|
}
|
|
|
|
impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
|
|
fn plan_turn(
|
|
&mut self,
|
|
request: &TurnRequest,
|
|
state: &RuntimeState,
|
|
) -> Result<TurnPlan, ProviderError> {
|
|
(**self).plan_turn(request, state)
|
|
}
|
|
}
|
|
|
|
/// Projects only already-committed state into the player-safe read model.
|
|
///
|
|
/// Projection is deliberately infallible: it is a pure, defensive operation
|
|
/// that omits or degrades data it cannot safely represent. This prevents a
|
|
/// post-commit error window where the branch advances but the turn reports a
|
|
/// failure.
|
|
pub trait TurnProjector {
|
|
fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView;
|
|
}
|
|
|
|
/// Store-backed single-turn coordinator.
|
|
///
|
|
/// All fallible work is completed before the atomic append. Projection runs
|
|
/// only after the store confirms the new branch head.
|
|
pub struct TurnEngine<'store, Store, Provider, Projector> {
|
|
store: &'store Store,
|
|
provider: Provider,
|
|
projector: Projector,
|
|
}
|
|
|
|
impl<'store, Store, Provider, Projector> TurnEngine<'store, Store, Provider, Projector>
|
|
where
|
|
Store: StoryStore,
|
|
Provider: TurnPlanProvider,
|
|
Projector: TurnProjector,
|
|
{
|
|
#[must_use]
|
|
pub fn new(store: &'store Store, provider: Provider, projector: Projector) -> Self {
|
|
Self {
|
|
store,
|
|
provider,
|
|
projector,
|
|
}
|
|
}
|
|
|
|
/// Validate, plan, reduce, commit, then project one player turn.
|
|
pub fn submit_turn(&mut self, request: &TurnRequest) -> Result<TurnResult, TurnFailure> {
|
|
validate_turn_request(request)?;
|
|
|
|
let current = self
|
|
.store
|
|
.load_state(&request.story_id, &request.branch_id)
|
|
.map_err(|error| map_store_error(&error))?;
|
|
if current.current_node != request.expected_node_id {
|
|
return Err(stale_node());
|
|
}
|
|
|
|
let plan = self
|
|
.provider
|
|
.plan_turn(request, ¤t)
|
|
.map_err(|error| map_provider_error(&error))?;
|
|
validate_turn_plan(request, &plan)?;
|
|
|
|
let mut committed = apply_delta(¤t, &plan.delta).map_err(map_reduce_error)?;
|
|
committed.current_node.clone_from(&plan.committed_node_id);
|
|
committed.current_branch.clone_from(&request.branch_id);
|
|
|
|
let state_hash = hash_runtime_state(&committed)?;
|
|
let node = StoryNode {
|
|
id: plan.committed_node_id.clone(),
|
|
story_id: request.story_id.clone(),
|
|
branch_id: request.branch_id.clone(),
|
|
parent_id: Some(current.current_node),
|
|
action_id: request.action_id.clone(),
|
|
user_input: request.input.clone(),
|
|
presentation: plan.presentation,
|
|
delta: plan.delta,
|
|
state_hash,
|
|
};
|
|
|
|
self.store
|
|
.append_node(&node, &committed)
|
|
.map_err(|error| map_store_error(&error))?;
|
|
|
|
let mut player_view = self.projector.project_committed_turn(&committed, &node);
|
|
// Identity comes from the committed state, never from projection input.
|
|
// Normalizing these fields keeps even a defensive fallback projector
|
|
// aligned with the commit it represents.
|
|
player_view.story_id.clone_from(&committed.story_id);
|
|
player_view.node_id.clone_from(&committed.current_node);
|
|
player_view.branch_id.clone_from(&committed.current_branch);
|
|
|
|
Ok(TurnResult {
|
|
committed_node_id: node.id,
|
|
player_view,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn provider(&self) -> &Provider {
|
|
&self.provider
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn projector(&self) -> &Projector {
|
|
&self.projector
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct FakeProvider {
|
|
responses: VecDeque<TurnResult>,
|
|
}
|
|
|
|
impl FakeProvider {
|
|
#[must_use]
|
|
pub fn new(responses: impl IntoIterator<Item = TurnResult>) -> Self {
|
|
Self {
|
|
responses: responses.into_iter().collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TurnProvider for FakeProvider {
|
|
fn complete_turn(&mut self, _request: &TurnRequest) -> Result<TurnResult, ProviderError> {
|
|
self.responses
|
|
.pop_front()
|
|
.ok_or(ProviderError::FixtureExhausted)
|
|
}
|
|
}
|
|
|
|
/// Validate the parts of a turn request that do not require persisted story state.
|
|
///
|
|
/// Stale-node detection belongs to the store boundary. This validation deliberately
|
|
/// does not infer any semantics for regenerate or pushed-check turns.
|
|
pub fn validate_turn_request(request: &TurnRequest) -> Result<(), TurnFailure> {
|
|
validate_required_text("story_id", &request.story_id)?;
|
|
validate_required_text("branch_id", &request.branch_id)?;
|
|
validate_required_text("expected_node_id", &request.expected_node_id)?;
|
|
validate_required_text("action_id", &request.action_id)?;
|
|
|
|
if matches!(&request.intent, TurnIntent::SpeakOrAct) && request.input.trim().is_empty() {
|
|
return Err(invalid_input(
|
|
"input must not be empty for a speak_or_act turn",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Execute one provider turn without persistence or network policy.
|
|
///
|
|
/// This is the deterministic seam used by fixtures and later by the store-backed
|
|
/// runtime: request validation happens before provider invocation and the provider
|
|
/// result is checked before it can cross into another layer.
|
|
pub fn execute_turn(
|
|
provider: &mut impl TurnProvider,
|
|
request: &TurnRequest,
|
|
) -> Result<TurnResult, TurnFailure> {
|
|
validate_turn_request(request)?;
|
|
let result = provider
|
|
.complete_turn(request)
|
|
.map_err(|error| map_provider_error(&error))?;
|
|
validate_turn_result(request, &result)?;
|
|
Ok(result)
|
|
}
|
|
|
|
/// Select world-book entries using deterministic lexical triggers.
|
|
///
|
|
/// Entries retain their input order. Duplicate ids keep the first matching entry.
|
|
/// Required flags are an all-of gate; an entry then needs either a matching tag or
|
|
/// a non-empty keyword contained in the turn input.
|
|
#[must_use]
|
|
pub fn select_world_book_entries(
|
|
entries: &[WorldBookEntry],
|
|
active_flags: &BTreeSet<String>,
|
|
trigger_tags: &BTreeSet<String>,
|
|
input: &str,
|
|
limit: usize,
|
|
) -> Vec<WorldBookEntry> {
|
|
let effective_limit = limit.min(MAX_WORLD_BOOK_ENTRIES);
|
|
if effective_limit == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let normalized_tags = trigger_tags
|
|
.iter()
|
|
.map(|tag| normalize_trigger(tag))
|
|
.filter(|tag| !tag.is_empty())
|
|
.collect::<BTreeSet<_>>();
|
|
let normalized_input = input.to_lowercase();
|
|
let mut selected_ids = BTreeSet::new();
|
|
let mut selected = Vec::new();
|
|
|
|
for entry in entries {
|
|
if selected.len() == effective_limit {
|
|
break;
|
|
}
|
|
if !entry
|
|
.required_flags
|
|
.iter()
|
|
.all(|flag| active_flags.contains(flag))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let tag_matches = entry
|
|
.tags
|
|
.iter()
|
|
.map(|tag| normalize_trigger(tag))
|
|
.any(|tag| !tag.is_empty() && normalized_tags.contains(&tag));
|
|
let keyword_matches = entry.keywords.iter().any(|keyword| {
|
|
let keyword = keyword.trim().to_lowercase();
|
|
!keyword.is_empty() && normalized_input.contains(&keyword)
|
|
});
|
|
|
|
if (tag_matches || keyword_matches) && selected_ids.insert(entry.id.clone()) {
|
|
selected.push(entry.clone());
|
|
}
|
|
}
|
|
|
|
selected
|
|
}
|
|
|
|
pub fn load_default_lapp_profile() -> Result<openlapp::Profile, ProviderError> {
|
|
openlapp::load_default_profile().map_err(|error| ProviderError::Profile { code: error.code() })
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn provider_failure(message: impl Into<String>) -> TurnFailure {
|
|
TurnFailure {
|
|
code: TurnFailureCode::ProviderUnavailable,
|
|
message: message.into(),
|
|
retryable: true,
|
|
}
|
|
}
|
|
|
|
fn validate_turn_result(request: &TurnRequest, result: &TurnResult) -> Result<(), TurnFailure> {
|
|
if result.committed_node_id.trim().is_empty() {
|
|
return Err(invalid_model_output("committed node id is empty"));
|
|
}
|
|
if result.committed_node_id == request.expected_node_id {
|
|
return Err(invalid_model_output(
|
|
"provider returned the uncommitted expected node",
|
|
));
|
|
}
|
|
if result.player_view.story_id != request.story_id {
|
|
return Err(invalid_model_output(
|
|
"player view story does not match the request",
|
|
));
|
|
}
|
|
if result.player_view.branch_id != request.branch_id {
|
|
return Err(invalid_model_output(
|
|
"player view branch does not match the request",
|
|
));
|
|
}
|
|
if result.player_view.node_id != result.committed_node_id {
|
|
return Err(invalid_model_output(
|
|
"player view node does not match the committed node",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_turn_plan(request: &TurnRequest, plan: &TurnPlan) -> Result<(), TurnFailure> {
|
|
if plan.committed_node_id.trim().is_empty() {
|
|
return Err(invalid_model_output("committed node id is empty"));
|
|
}
|
|
if plan.committed_node_id == request.expected_node_id {
|
|
return Err(invalid_model_output(
|
|
"provider returned the uncommitted expected node",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn hash_runtime_state(state: &RuntimeState) -> Result<String, TurnFailure> {
|
|
serde_json::to_vec(state)
|
|
.map(|bytes| nana_domain::stable_json_hash(&bytes))
|
|
.map_err(|_| internal_failure("turn state could not be prepared"))
|
|
}
|
|
|
|
fn map_reduce_error(_error: ReduceError) -> TurnFailure {
|
|
invalid_model_output("turn plan could not be applied")
|
|
}
|
|
|
|
fn map_provider_error(error: &ProviderError) -> TurnFailure {
|
|
match error {
|
|
ProviderError::InvalidModelOutput { .. } => {
|
|
invalid_model_output("model returned an invalid turn plan")
|
|
}
|
|
ProviderError::FixtureExhausted
|
|
| ProviderError::Profile { .. }
|
|
| ProviderError::Configuration { .. }
|
|
| ProviderError::Upstream { .. }
|
|
| ProviderError::ContextEncoding => provider_unavailable(),
|
|
}
|
|
}
|
|
|
|
fn map_store_error(error: &StoreError) -> TurnFailure {
|
|
match error {
|
|
StoreError::StaleBranchHead { .. } => stale_node(),
|
|
StoreError::StoryNotFound(_) | StoreError::BranchNotFound { .. } => TurnFailure {
|
|
code: TurnFailureCode::InvalidInput,
|
|
message: "story or branch is unavailable".into(),
|
|
retryable: false,
|
|
},
|
|
StoreError::NodeAlreadyExists(_)
|
|
| StoreError::ParentNotFound(_)
|
|
| StoreError::StateMismatch(_)
|
|
| StoreError::StateHashMismatch { .. }
|
|
| StoreError::Sqlite(_)
|
|
| StoreError::Serialization(_)
|
|
| StoreError::Poisoned => internal_failure("turn could not be committed"),
|
|
}
|
|
}
|
|
|
|
fn validate_required_text(field: &str, value: &str) -> Result<(), TurnFailure> {
|
|
if value.trim().is_empty() {
|
|
Err(invalid_input(format!("{field} must not be empty")))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn invalid_input(message: impl Into<String>) -> TurnFailure {
|
|
TurnFailure {
|
|
code: TurnFailureCode::InvalidInput,
|
|
message: message.into(),
|
|
retryable: false,
|
|
}
|
|
}
|
|
|
|
fn invalid_model_output(message: impl Into<String>) -> TurnFailure {
|
|
TurnFailure {
|
|
code: TurnFailureCode::InvalidModelOutput,
|
|
message: message.into(),
|
|
retryable: true,
|
|
}
|
|
}
|
|
|
|
fn provider_unavailable() -> TurnFailure {
|
|
provider_failure("turn provider is unavailable")
|
|
}
|
|
|
|
fn stale_node() -> TurnFailure {
|
|
TurnFailure {
|
|
code: TurnFailureCode::StaleNode,
|
|
message: "story branch changed; refresh and retry".into(),
|
|
retryable: true,
|
|
}
|
|
}
|
|
|
|
fn internal_failure(message: impl Into<String>) -> TurnFailure {
|
|
TurnFailure {
|
|
code: TurnFailureCode::Internal,
|
|
message: message.into(),
|
|
retryable: true,
|
|
}
|
|
}
|
|
|
|
fn normalize_trigger(value: &str) -> String {
|
|
value.trim().to_lowercase()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::BTreeSet;
|
|
|
|
use nana_domain::{
|
|
PlayerView, RelationshipBand, RelationshipView, TurnFailureCode, TurnIntent, TurnRequest,
|
|
TurnResult, WorldBookEntry,
|
|
};
|
|
|
|
use super::{
|
|
FakeProvider, MAX_WORLD_BOOK_ENTRIES, ProviderError, TurnProvider, execute_turn,
|
|
select_world_book_entries, validate_turn_request,
|
|
};
|
|
|
|
fn request(intent: TurnIntent, input: &str) -> TurnRequest {
|
|
TurnRequest {
|
|
story_id: "story_1".into(),
|
|
branch_id: "branch_main".into(),
|
|
expected_node_id: "node_1".into(),
|
|
action_id: "action_1".into(),
|
|
intent,
|
|
input: input.into(),
|
|
}
|
|
}
|
|
|
|
fn player_view(node_id: &str) -> PlayerView {
|
|
PlayerView {
|
|
story_id: "story_1".into(),
|
|
node_id: node_id.into(),
|
|
branch_id: "branch_main".into(),
|
|
scene_id: "station".into(),
|
|
scene_title: "Station".into(),
|
|
character_name: "Nana".into(),
|
|
character_expression: None,
|
|
character_pose: None,
|
|
beats: Vec::new(),
|
|
suggestions: Vec::new(),
|
|
inventory: Vec::new(),
|
|
knowledge: Vec::new(),
|
|
promises: Vec::new(),
|
|
relationship: RelationshipView {
|
|
affinity: RelationshipBand::Warming,
|
|
trust: RelationshipBand::Guarded,
|
|
hope: RelationshipBand::Guarded,
|
|
respect: RelationshipBand::Warming,
|
|
intimacy: RelationshipBand::Guarded,
|
|
attachment: RelationshipBand::Warming,
|
|
updated_at_node: None,
|
|
},
|
|
history: Vec::new(),
|
|
can_continue: true,
|
|
}
|
|
}
|
|
|
|
fn result(node_id: &str) -> TurnResult {
|
|
TurnResult {
|
|
committed_node_id: node_id.into(),
|
|
player_view: player_view(node_id),
|
|
}
|
|
}
|
|
|
|
fn entry(
|
|
id: &str,
|
|
keywords: &[&str],
|
|
tags: &[&str],
|
|
required_flags: &[&str],
|
|
) -> WorldBookEntry {
|
|
WorldBookEntry {
|
|
id: id.into(),
|
|
title: id.into(),
|
|
content: format!("content for {id}"),
|
|
keywords: keywords.iter().map(ToString::to_string).collect(),
|
|
tags: tags.iter().map(ToString::to_string).collect(),
|
|
required_flags: required_flags.iter().map(ToString::to_string).collect(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn continue_allows_empty_input() {
|
|
assert!(validate_turn_request(&request(TurnIntent::Continue, "")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn speak_or_act_rejects_empty_input() {
|
|
let failure = validate_turn_request(&request(TurnIntent::SpeakOrAct, " \n "))
|
|
.expect_err("blank player input must be rejected");
|
|
|
|
assert_eq!(failure.code, TurnFailureCode::InvalidInput);
|
|
assert!(!failure.retryable);
|
|
assert_eq!(
|
|
failure.message,
|
|
"input must not be empty for a speak_or_act turn"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn required_request_identifiers_must_not_be_blank() {
|
|
for field in ["story_id", "branch_id", "expected_node_id", "action_id"] {
|
|
let mut request = request(TurnIntent::Continue, "");
|
|
match field {
|
|
"story_id" => request.story_id = " ".into(),
|
|
"branch_id" => request.branch_id = " ".into(),
|
|
"expected_node_id" => request.expected_node_id = " ".into(),
|
|
"action_id" => request.action_id = " ".into(),
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
let failure =
|
|
validate_turn_request(&request).expect_err("blank identifier must be rejected");
|
|
assert_eq!(failure.code, TurnFailureCode::InvalidInput);
|
|
assert_eq!(failure.message, format!("{field} must not be empty"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn regenerate_and_push_check_remain_valid_without_extra_rules() {
|
|
assert!(validate_turn_request(&request(TurnIntent::Regenerate, "")).is_ok());
|
|
assert!(validate_turn_request(&request(TurnIntent::PushCheck, "")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn fake_provider_returns_fixtures_fifo() {
|
|
let mut provider = FakeProvider::new([result("node_2"), result("node_3")]);
|
|
let request = request(TurnIntent::Continue, "");
|
|
|
|
assert_eq!(
|
|
provider
|
|
.complete_turn(&request)
|
|
.expect("first response")
|
|
.committed_node_id,
|
|
"node_2"
|
|
);
|
|
assert_eq!(
|
|
provider
|
|
.complete_turn(&request)
|
|
.expect("second response")
|
|
.committed_node_id,
|
|
"node_3"
|
|
);
|
|
assert!(matches!(
|
|
provider.complete_turn(&request),
|
|
Err(ProviderError::FixtureExhausted)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn execution_validates_before_consuming_the_provider() {
|
|
let mut provider = FakeProvider::new([result("node_2")]);
|
|
let invalid = request(TurnIntent::SpeakOrAct, "");
|
|
|
|
assert_eq!(
|
|
execute_turn(&mut provider, &invalid)
|
|
.expect_err("invalid request must fail before the provider")
|
|
.code,
|
|
TurnFailureCode::InvalidInput
|
|
);
|
|
|
|
let committed = execute_turn(
|
|
&mut provider,
|
|
&request(TurnIntent::SpeakOrAct, "I will return."),
|
|
)
|
|
.expect("the queued response must still be available");
|
|
assert_eq!(committed.committed_node_id, "node_2");
|
|
}
|
|
|
|
#[test]
|
|
fn execution_rejects_an_uncommitted_or_inconsistent_result() {
|
|
let request = request(TurnIntent::Continue, "");
|
|
|
|
let mut stale = FakeProvider::new([result("node_1")]);
|
|
let stale_failure =
|
|
execute_turn(&mut stale, &request).expect_err("old expected node is not a commit");
|
|
assert_eq!(stale_failure.code, TurnFailureCode::InvalidModelOutput);
|
|
|
|
let mut wrong_node = result("node_2");
|
|
wrong_node.player_view.node_id = "node_other".into();
|
|
let mut provider = FakeProvider::new([wrong_node]);
|
|
let failure =
|
|
execute_turn(&mut provider, &request).expect_err("view node must match committed node");
|
|
assert_eq!(failure.code, TurnFailureCode::InvalidModelOutput);
|
|
|
|
let mut wrong_story = result("node_2");
|
|
wrong_story.player_view.story_id = "story_other".into();
|
|
let mut provider = FakeProvider::new([wrong_story]);
|
|
assert_eq!(
|
|
execute_turn(&mut provider, &request)
|
|
.expect_err("view story must match request")
|
|
.code,
|
|
TurnFailureCode::InvalidModelOutput
|
|
);
|
|
|
|
let mut wrong_branch = result("node_2");
|
|
wrong_branch.player_view.branch_id = "branch_other".into();
|
|
let mut provider = FakeProvider::new([wrong_branch]);
|
|
assert_eq!(
|
|
execute_turn(&mut provider, &request)
|
|
.expect_err("view branch must match request")
|
|
.code,
|
|
TurnFailureCode::InvalidModelOutput
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn execution_redacts_provider_details() {
|
|
struct FailingProvider;
|
|
|
|
impl TurnProvider for FailingProvider {
|
|
fn complete_turn(
|
|
&mut self,
|
|
_request: &TurnRequest,
|
|
) -> Result<TurnResult, ProviderError> {
|
|
Err(ProviderError::Upstream {
|
|
code: Some(openlapp::ErrorCode::HttpStatus),
|
|
})
|
|
}
|
|
}
|
|
|
|
let upstream = ProviderError::Upstream {
|
|
code: Some(openlapp::ErrorCode::HttpStatus),
|
|
};
|
|
assert_eq!(upstream.to_string(), "LAPP chat request failed");
|
|
|
|
let failure = execute_turn(&mut FailingProvider, &request(TurnIntent::Continue, ""))
|
|
.expect_err("provider failure should be mapped");
|
|
|
|
assert_eq!(failure.code, TurnFailureCode::ProviderUnavailable);
|
|
assert_eq!(failure.message, "turn provider is unavailable");
|
|
assert!(!failure.message.contains("secret"));
|
|
assert!(failure.retryable);
|
|
}
|
|
|
|
#[test]
|
|
fn world_book_selection_applies_flags_keywords_and_tags() {
|
|
let entries = vec![
|
|
entry("public_station", &["station"], &[], &[]),
|
|
entry("flagged_station", &["station"], &[], &["clock_seen"]),
|
|
entry("rain_lore", &[], &[" Weather "], &[]),
|
|
entry("not_triggered", &["forest"], &["family"], &[]),
|
|
];
|
|
let flags = BTreeSet::new();
|
|
let tags = BTreeSet::from([String::from("weather")]);
|
|
|
|
let selected =
|
|
select_world_book_entries(&entries, &flags, &tags, "Return to the STATION", 8);
|
|
assert_eq!(
|
|
selected
|
|
.iter()
|
|
.map(|entry| entry.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["public_station", "rain_lore"]
|
|
);
|
|
|
|
let flags = BTreeSet::from([String::from("clock_seen")]);
|
|
let selected =
|
|
select_world_book_entries(&entries, &flags, &tags, "Return to the station", 8);
|
|
assert_eq!(
|
|
selected
|
|
.iter()
|
|
.map(|entry| entry.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["public_station", "flagged_station", "rain_lore"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn world_book_selection_is_stable_deduplicated_and_limited() {
|
|
let mut entries = vec![
|
|
entry("first", &["rain"], &[], &[]),
|
|
entry("duplicate", &["rain"], &[], &[]),
|
|
entry("duplicate", &["rain"], &[], &[]),
|
|
];
|
|
entries.extend(
|
|
(0..MAX_WORLD_BOOK_ENTRIES + 3)
|
|
.map(|index| entry(&format!("extra_{index}"), &["rain"], &[], &[])),
|
|
);
|
|
|
|
let selected =
|
|
select_world_book_entries(&entries, &BTreeSet::new(), &BTreeSet::new(), "rain", 3);
|
|
assert_eq!(
|
|
selected
|
|
.iter()
|
|
.map(|entry| entry.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["first", "duplicate", "extra_0"]
|
|
);
|
|
|
|
let capped = select_world_book_entries(
|
|
&entries,
|
|
&BTreeSet::new(),
|
|
&BTreeSet::new(),
|
|
"rain",
|
|
usize::MAX,
|
|
);
|
|
assert_eq!(capped.len(), MAX_WORLD_BOOK_ENTRIES);
|
|
|
|
let repeated =
|
|
select_world_book_entries(&entries, &BTreeSet::new(), &BTreeSet::new(), "rain", 3);
|
|
assert_eq!(selected, repeated);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod persistent_turn_tests {
|
|
use std::collections::{BTreeMap, VecDeque};
|
|
|
|
use nana_domain::{
|
|
ActionSuggestion, BeatKind, PlayerView, PresentationBeat, PresentationCharacter,
|
|
PresentationScene, PresentationSnapshot, RelationshipAdjustment, RelationshipBand,
|
|
RelationshipDimension, RelationshipView, RuntimeState, StateDelta, StateOp, StoryNode,
|
|
TurnFailureCode, TurnIntent, TurnRequest,
|
|
};
|
|
use nana_store::{InMemoryStoryStore, StoryStore};
|
|
|
|
use super::{
|
|
ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, hash_runtime_state,
|
|
};
|
|
|
|
struct RecordingPlanProvider {
|
|
responses: VecDeque<Result<TurnPlan, ProviderError>>,
|
|
calls: usize,
|
|
}
|
|
|
|
impl RecordingPlanProvider {
|
|
fn new(response: Result<TurnPlan, ProviderError>) -> Self {
|
|
Self {
|
|
responses: VecDeque::from([response]),
|
|
calls: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TurnPlanProvider for RecordingPlanProvider {
|
|
fn plan_turn(
|
|
&mut self,
|
|
_request: &TurnRequest,
|
|
_state: &RuntimeState,
|
|
) -> Result<TurnPlan, ProviderError> {
|
|
self.calls += 1;
|
|
self.responses
|
|
.pop_front()
|
|
.unwrap_or(Err(ProviderError::FixtureExhausted))
|
|
}
|
|
}
|
|
|
|
struct RecordingProjector<'store> {
|
|
store: &'store InMemoryStoryStore,
|
|
calls: usize,
|
|
}
|
|
|
|
impl TurnProjector for RecordingProjector<'_> {
|
|
fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView {
|
|
self.calls += 1;
|
|
|
|
let stored = self
|
|
.store
|
|
.load_state(&state.story_id, &state.current_branch)
|
|
.expect("projection must run after append");
|
|
assert_eq!(stored, *state);
|
|
assert_eq!(node.id, state.current_node);
|
|
|
|
PlayerView {
|
|
// Deliberately wrong: the engine normalizes commit identity.
|
|
story_id: "untrusted_story".into(),
|
|
node_id: "untrusted_node".into(),
|
|
branch_id: "untrusted_branch".into(),
|
|
scene_id: "station".into(),
|
|
scene_title: "Station".into(),
|
|
character_name: "Nana".into(),
|
|
character_expression: node.presentation.character.expression.clone(),
|
|
character_pose: node.presentation.character.pose.clone(),
|
|
beats: node.presentation.beats.clone(),
|
|
suggestions: node.presentation.suggestions.clone(),
|
|
inventory: Vec::new(),
|
|
knowledge: Vec::new(),
|
|
promises: Vec::new(),
|
|
relationship: RelationshipView {
|
|
affinity: RelationshipBand::Warming,
|
|
trust: RelationshipBand::Guarded,
|
|
hope: RelationshipBand::Guarded,
|
|
respect: RelationshipBand::Warming,
|
|
intimacy: RelationshipBand::Guarded,
|
|
attachment: RelationshipBand::Warming,
|
|
updated_at_node: Some(node.id.clone()),
|
|
},
|
|
history: Vec::new(),
|
|
can_continue: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn state(node: &str, branch: &str) -> RuntimeState {
|
|
RuntimeState {
|
|
story_id: "story_1".into(),
|
|
current_node: node.into(),
|
|
current_branch: branch.into(),
|
|
world_flags: BTreeMap::new(),
|
|
relationships: BTreeMap::new(),
|
|
relationship_states: Vec::new(),
|
|
promises: Vec::new(),
|
|
knowledge: Vec::new(),
|
|
items: Vec::new(),
|
|
clocks: Vec::new(),
|
|
checks: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn node(id: &str, parent_id: Option<&str>, branch: &str) -> StoryNode {
|
|
StoryNode {
|
|
id: id.into(),
|
|
story_id: "story_1".into(),
|
|
branch_id: branch.into(),
|
|
parent_id: parent_id.map(Into::into),
|
|
action_id: format!("action_{id}"),
|
|
user_input: String::new(),
|
|
presentation: PresentationSnapshot::default(),
|
|
delta: StateDelta { ops: Vec::new() },
|
|
state_hash: hash_runtime_state(&state(id, branch)).expect("serializable test state"),
|
|
}
|
|
}
|
|
|
|
fn seeded_store() -> InMemoryStoryStore {
|
|
let store = InMemoryStoryStore::new();
|
|
store
|
|
.append_node(
|
|
&node("node_1", None, "branch_main"),
|
|
&state("node_1", "branch_main"),
|
|
)
|
|
.expect("seed root");
|
|
store
|
|
}
|
|
|
|
fn request(expected_node_id: &str) -> TurnRequest {
|
|
TurnRequest {
|
|
story_id: "story_1".into(),
|
|
branch_id: "branch_main".into(),
|
|
expected_node_id: expected_node_id.into(),
|
|
action_id: "action_2".into(),
|
|
intent: TurnIntent::SpeakOrAct,
|
|
input: "I will return before dawn.".into(),
|
|
}
|
|
}
|
|
|
|
fn plan(node_id: &str, delta: StateDelta) -> TurnPlan {
|
|
TurnPlan {
|
|
committed_node_id: node_id.into(),
|
|
presentation: PresentationSnapshot {
|
|
scene: PresentationScene {
|
|
id: "station".into(),
|
|
title: "Station".into(),
|
|
},
|
|
character: PresentationCharacter {
|
|
id: "nana".into(),
|
|
name: "Nana".into(),
|
|
expression: Some("guarded".into()),
|
|
pose: Some("holding_coat".into()),
|
|
},
|
|
beats: vec![PresentationBeat {
|
|
id: "beat_1".into(),
|
|
kind: BeatKind::Dialogue,
|
|
speaker: Some("Nana".into()),
|
|
text: "Then I will wait.".into(),
|
|
visual: None,
|
|
}],
|
|
suggestions: vec![ActionSuggestion {
|
|
id: "suggestion_1".into(),
|
|
label: "Promise".into(),
|
|
draft: "I promise.".into(),
|
|
}],
|
|
can_continue: true,
|
|
},
|
|
delta,
|
|
}
|
|
}
|
|
|
|
fn projector(store: &InMemoryStoryStore) -> RecordingProjector<'_> {
|
|
RecordingProjector { store, calls: 0 }
|
|
}
|
|
|
|
#[test]
|
|
fn successful_turn_commits_before_projecting() {
|
|
let store = seeded_store();
|
|
let delta = StateDelta {
|
|
ops: vec![StateOp::SetWorldFlag {
|
|
key: "promise_spoken".into(),
|
|
value: true,
|
|
}],
|
|
};
|
|
let mut engine = TurnEngine::new(
|
|
&store,
|
|
RecordingPlanProvider::new(Ok(plan("node_2", delta))),
|
|
projector(&store),
|
|
);
|
|
|
|
let result = engine.submit_turn(&request("node_1")).expect("turn");
|
|
|
|
assert_eq!(result.committed_node_id, "node_2");
|
|
assert_eq!(result.player_view.story_id, "story_1");
|
|
assert_eq!(result.player_view.node_id, "node_2");
|
|
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.projector().calls, 1);
|
|
|
|
let committed = store
|
|
.load_state("story_1", "branch_main")
|
|
.expect("committed state");
|
|
assert_eq!(committed.current_node, "node_2");
|
|
assert_eq!(committed.world_flags.get("promise_spoken"), Some(&true));
|
|
}
|
|
|
|
#[test]
|
|
fn stale_request_does_not_call_provider_or_move_head() {
|
|
let store = seeded_store();
|
|
let mut engine = TurnEngine::new(
|
|
&store,
|
|
RecordingPlanProvider::new(Ok(plan("node_2", StateDelta { ops: Vec::new() }))),
|
|
projector(&store),
|
|
);
|
|
|
|
let failure = engine
|
|
.submit_turn(&request("node_stale"))
|
|
.expect_err("stale request");
|
|
|
|
assert_eq!(failure.code, TurnFailureCode::StaleNode);
|
|
assert!(failure.retryable);
|
|
assert_eq!(engine.provider().calls, 0);
|
|
assert_eq!(engine.projector().calls, 0);
|
|
assert_eq!(
|
|
store.branch_head("story_1", "branch_main").expect("head"),
|
|
Some("node_1".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reducer_failure_is_redacted_and_does_not_save() {
|
|
let store = seeded_store();
|
|
let delta = StateDelta {
|
|
ops: vec![StateOp::AdjustRelationship {
|
|
from: "nana".into(),
|
|
to: "player".into(),
|
|
adjustment: RelationshipAdjustment {
|
|
dimension: RelationshipDimension::Trust,
|
|
delta: 9,
|
|
cause: "secret model reasoning".into(),
|
|
judgment_rule: Some("secret.rule".into()),
|
|
},
|
|
}],
|
|
};
|
|
let mut engine = TurnEngine::new(
|
|
&store,
|
|
RecordingPlanProvider::new(Ok(plan("node_2", delta))),
|
|
projector(&store),
|
|
);
|
|
|
|
let failure = engine
|
|
.submit_turn(&request("node_1"))
|
|
.expect_err("invalid delta");
|
|
|
|
assert_eq!(failure.code, TurnFailureCode::InvalidModelOutput);
|
|
assert_eq!(failure.message, "turn plan could not be applied");
|
|
assert!(!failure.message.contains("secret"));
|
|
assert_eq!(engine.provider().calls, 1);
|
|
assert_eq!(engine.projector().calls, 0);
|
|
assert_eq!(
|
|
store.branch_head("story_1", "branch_main").expect("head"),
|
|
Some("node_1".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_failure_is_redacted_and_does_not_save() {
|
|
let store = seeded_store();
|
|
let mut engine = TurnEngine::new(
|
|
&store,
|
|
RecordingPlanProvider::new(Err(ProviderError::Upstream {
|
|
code: Some(openlapp::ErrorCode::HttpStatus),
|
|
})),
|
|
projector(&store),
|
|
);
|
|
|
|
let failure = engine
|
|
.submit_turn(&request("node_1"))
|
|
.expect_err("provider failure");
|
|
|
|
assert_eq!(failure.code, TurnFailureCode::ProviderUnavailable);
|
|
assert_eq!(failure.message, "turn provider is unavailable");
|
|
assert!(!failure.message.contains("secret"));
|
|
assert_eq!(engine.projector().calls, 0);
|
|
assert_eq!(
|
|
store.branch_head("story_1", "branch_main").expect("head"),
|
|
Some("node_1".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn append_failure_does_not_project_or_move_requested_branch() {
|
|
let store = seeded_store();
|
|
store
|
|
.append_node(
|
|
&node("node_duplicate", Some("node_1"), "branch_other"),
|
|
&state("node_duplicate", "branch_other"),
|
|
)
|
|
.expect("seed duplicate id on another branch");
|
|
let mut engine = TurnEngine::new(
|
|
&store,
|
|
RecordingPlanProvider::new(Ok(plan("node_duplicate", StateDelta { ops: Vec::new() }))),
|
|
projector(&store),
|
|
);
|
|
|
|
let failure = engine
|
|
.submit_turn(&request("node_1"))
|
|
.expect_err("duplicate node append");
|
|
|
|
assert_eq!(failure.code, TurnFailureCode::Internal);
|
|
assert_eq!(failure.message, "turn could not be committed");
|
|
assert_eq!(engine.projector().calls, 0);
|
|
assert_eq!(
|
|
store.branch_head("story_1", "branch_main").expect("head"),
|
|
Some("node_1".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_plan_is_non_view_provider_output_and_state_hash_is_stable() {
|
|
fn provider_output(
|
|
provider: &mut impl TurnPlanProvider,
|
|
request: &TurnRequest,
|
|
state: &RuntimeState,
|
|
) -> TurnPlan {
|
|
provider.plan_turn(request, state).expect("turn plan")
|
|
}
|
|
|
|
let current = state("node_1", "branch_main");
|
|
let expected = hash_runtime_state(¤t).expect("hash");
|
|
assert_eq!(hash_runtime_state(¤t).expect("repeat hash"), expected);
|
|
|
|
let mut provider =
|
|
RecordingPlanProvider::new(Ok(plan("node_2", StateDelta { ops: Vec::new() })));
|
|
let output = provider_output(&mut provider, &request("node_1"), ¤t);
|
|
assert_eq!(output.committed_node_id, "node_2");
|
|
assert_eq!(output.presentation.beats.len(), 1);
|
|
assert_eq!(output.presentation.suggestions.len(), 1);
|
|
}
|
|
}
|