From 5e1c0e74b4804643d78be0bc6668e51bdc3590d0 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 13:04:53 +0800 Subject: [PATCH] feat(engine): add deterministic state reducer --- crates/nana-engine/src/lib.rs | 774 +++++++++++++++++++++++++++++++++- 1 file changed, 756 insertions(+), 18 deletions(-) diff --git a/crates/nana-engine/src/lib.rs b/crates/nana-engine/src/lib.rs index d919a06..ee339d3 100644 --- a/crates/nana-engine/src/lib.rs +++ b/crates/nana-engine/src/lib.rs @@ -1,33 +1,277 @@ -use nana_domain::{RuntimeState, StateDelta}; +use nana_domain::{ + CheckRecord, ItemAcquisition, PromiseStatus, RelationshipAxes, RelationshipDimension, + RuntimeState, StateDelta, StateOp, +}; use thiserror::Error; +const MAX_RELATIONSHIP_ADJUSTMENT: u16 = 8; +const MIN_RELATIONSHIP_VALUE: i16 = 0; +const MAX_RELATIONSHIP_VALUE: i16 = 100; + #[derive(Debug, Error, PartialEq, Eq)] pub enum ReduceError { - #[error("state delta is not implemented yet: {0} operation(s)")] - NotImplemented(usize), + #[error("relationship adjustment {delta} exceeds the per-operation limit of 8")] + RelationshipAdjustmentOutOfRange { delta: i16 }, + + #[error("promise id already exists: {id}")] + DuplicatePromiseId { id: String }, + + #[error("promise must be created as proposed or accepted: {id}")] + InvalidInitialPromiseStatus { id: String }, + + #[error("promise not found: {id}")] + PromiseNotFound { id: String }, + + #[error("invalid promise transition for {id}: {from:?} -> {to:?}")] + InvalidPromiseTransition { + id: String, + from: PromiseStatus, + to: PromiseStatus, + }, + + #[error("knowledge id already exists: {id}")] + DuplicateKnowledgeId { id: String }, + + #[error("item id already exists: {id}")] + DuplicateItemId { id: String }, + + #[error("item not found: {id}")] + ItemNotFound { id: String }, + + #[error("item {id} is held by {actual}, not {expected}")] + ItemHolderMismatch { + id: String, + expected: String, + actual: String, + }, + + #[error("check id already exists: {id}")] + DuplicateCheckId { id: String }, + + #[error("action id already has a different check record: {action_id}")] + CheckActionConflict { action_id: String }, + + #[error("clock not found: {id}")] + ClockNotFound { id: String }, } -/// M0 contract seam. M1 replaces this placeholder with the only authoritative -/// state mutation path in the application. +/// Applies a complete delta to a cloned state. +/// +/// The input is never mutated. If any operation fails, the partially reduced clone +/// is discarded so callers observe all-or-nothing behavior. pub fn apply_delta(state: &RuntimeState, delta: &StateDelta) -> Result { - if delta.ops.is_empty() { - Ok(state.clone()) - } else { - Err(ReduceError::NotImplemented(delta.ops.len())) + let mut next = state.clone(); + for op in &delta.ops { + apply_op(&mut next, op)?; } + Ok(next) +} + +fn apply_op(state: &mut RuntimeState, op: &StateOp) -> Result<(), ReduceError> { + match op { + StateOp::SetWorldFlag { key, value } => { + state.world_flags.insert(key.clone(), *value); + } + StateOp::AdjustRelationship { + from, + to, + adjustment, + } => { + if adjustment.delta.unsigned_abs() > MAX_RELATIONSHIP_ADJUSTMENT { + return Err(ReduceError::RelationshipAdjustmentOutOfRange { + delta: adjustment.delta, + }); + } + + let axes = state + .relationships + .entry(relationship_key(from, to)) + .or_insert_with(RelationshipAxes::neutral); + let value = relationship_dimension_mut(axes, adjustment.dimension); + *value = value + .saturating_add(adjustment.delta) + .clamp(MIN_RELATIONSHIP_VALUE, MAX_RELATIONSHIP_VALUE); + } + StateOp::CreatePromise { promise } => { + if state.promises.iter().any(|item| item.id == promise.id) { + return Err(ReduceError::DuplicatePromiseId { + id: promise.id.clone(), + }); + } + if !matches!( + promise.status, + PromiseStatus::Proposed | PromiseStatus::Accepted + ) { + return Err(ReduceError::InvalidInitialPromiseStatus { + id: promise.id.clone(), + }); + } + state.promises.push(promise.clone()); + } + StateOp::UpdatePromise { + promise_id, + status, + resolved_at, + } => { + let promise = state + .promises + .iter_mut() + .find(|promise| promise.id == *promise_id) + .ok_or_else(|| ReduceError::PromiseNotFound { + id: promise_id.clone(), + })?; + + if !valid_promise_transition(promise.status, *status) { + return Err(ReduceError::InvalidPromiseTransition { + id: promise_id.clone(), + from: promise.status, + to: *status, + }); + } + + if matches!(*status, PromiseStatus::Accepted) && promise.accepted_at.is_none() { + promise.accepted_at = Some(state.current_node.clone()); + } + promise.status = *status; + promise.resolved_at.clone_from(resolved_at); + } + StateOp::AddKnowledge { record } => { + if state.knowledge.iter().any(|item| item.id == record.id) { + return Err(ReduceError::DuplicateKnowledgeId { + id: record.id.clone(), + }); + } + state.knowledge.push(record.clone()); + } + StateOp::AddItem { item } => { + if state.items.iter().any(|existing| existing.id == item.id) { + return Err(ReduceError::DuplicateItemId { + id: item.id.clone(), + }); + } + state.items.push(item.clone()); + } + StateOp::TransferItem { + item_id, + from, + to, + placement, + mode, + } => { + let item = state + .items + .iter_mut() + .find(|item| item.id == *item_id) + .ok_or_else(|| ReduceError::ItemNotFound { + id: item_id.clone(), + })?; + if item.holder != *from { + return Err(ReduceError::ItemHolderMismatch { + id: item_id.clone(), + expected: from.clone(), + actual: item.holder.clone(), + }); + } + + item.holder.clone_from(to); + item.placement = *placement; + item.acquisition = ItemAcquisition { + mode: *mode, + from: Some(from.clone()), + at_node: state.current_node.clone(), + }; + } + StateOp::RecordCheck { check } => { + record_check(state, check)?; + } + StateOp::AdvanceClock { clock_id, delta } => { + let clock = state + .clocks + .iter_mut() + .find(|clock| clock.id == *clock_id) + .ok_or_else(|| ReduceError::ClockNotFound { + id: clock_id.clone(), + })?; + clock.value = clock.value.saturating_add(*delta).min(clock.max); + } + } + Ok(()) +} + +fn relationship_key(from: &str, to: &str) -> String { + format!("{from}->{to}") +} + +fn relationship_dimension_mut( + axes: &mut RelationshipAxes, + dimension: RelationshipDimension, +) -> &mut i16 { + match dimension { + RelationshipDimension::Affinity => &mut axes.affinity, + RelationshipDimension::Trust => &mut axes.trust, + RelationshipDimension::Hope => &mut axes.hope, + RelationshipDimension::Respect => &mut axes.respect, + RelationshipDimension::Intimacy => &mut axes.intimacy, + RelationshipDimension::Attachment => &mut axes.attachment, + } +} + +fn valid_promise_transition(from: PromiseStatus, to: PromiseStatus) -> bool { + match from { + PromiseStatus::Proposed => matches!(to, PromiseStatus::Accepted), + PromiseStatus::Accepted => matches!( + to, + PromiseStatus::Fulfilled + | PromiseStatus::Broken + | PromiseStatus::Released + | PromiseStatus::Impossible + ), + PromiseStatus::Fulfilled + | PromiseStatus::Broken + | PromiseStatus::Released + | PromiseStatus::Impossible => false, + } +} + +fn record_check(state: &mut RuntimeState, check: &CheckRecord) -> Result<(), ReduceError> { + if let Some(existing) = state + .checks + .iter() + .find(|existing| existing.action_id == check.action_id) + { + if existing == check { + return Ok(()); + } + return Err(ReduceError::CheckActionConflict { + action_id: check.action_id.clone(), + }); + } + + if state.checks.iter().any(|existing| existing.id == check.id) { + return Err(ReduceError::DuplicateCheckId { + id: check.id.clone(), + }); + } + + state.checks.push(check.clone()); + Ok(()) } #[cfg(test)] mod tests { use std::collections::BTreeMap; - use nana_domain::{RuntimeState, StateDelta}; + use nana_domain::{ + AcquisitionMode, CheckDifficulty, CheckRecord, CheckResult, ClockState, ItemAcquisition, + ItemInstance, ItemPlacement, KnowledgeCertainty, KnowledgeRecord, Promise, PromiseStatus, + PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipDimension, ResourceId, + RuntimeState, StateDelta, StateOp, + }; - use super::apply_delta; + use super::{apply_delta, ReduceError}; - #[test] - fn empty_delta_is_identity() { - let state = RuntimeState { + fn state() -> RuntimeState { + RuntimeState { story_id: "story_demo".to_owned(), current_node: "node_001".to_owned(), current_branch: "branch_main".to_owned(), @@ -37,12 +281,506 @@ mod tests { promises: Vec::new(), knowledge: Vec::new(), items: Vec::new(), - clocks: Vec::new(), + clocks: vec![ClockState { + id: "dawn".to_owned(), + label: "Dawn".to_owned(), + value: 2, + max: 4, + }], checks: Vec::new(), - }; + } + } + + fn adjustment(dimension: RelationshipDimension, delta: i16) -> RelationshipAdjustment { + RelationshipAdjustment { + dimension, + delta, + cause: "test".to_owned(), + judgment_rule: None, + } + } + + fn promise(id: &str, status: PromiseStatus) -> Promise { + Promise { + id: id.to_owned(), + promiser: "player".to_owned(), + promisee: "nana".to_owned(), + content: "Return before dawn".to_owned(), + status, + weight: PromiseWeight::Major, + created_at: "node_001".to_owned(), + accepted_at: (status == PromiseStatus::Accepted).then(|| "node_001".to_owned()), + resolved_at: None, + } + } + + fn item(id: &str, owner: &str, holder: &str) -> ItemInstance { + ItemInstance { + id: id.to_owned(), + spec_ref: ResourceId("item.hairpin".to_owned()), + owner: owner.to_owned(), + holder: holder.to_owned(), + placement: ItemPlacement::Bag, + quantity: 1, + condition: "intact".to_owned(), + state_tags: Vec::new(), + acquisition: ItemAcquisition { + mode: AcquisitionMode::Initial, + from: None, + at_node: "node_000".to_owned(), + }, + } + } + + fn knowledge(id: &str) -> KnowledgeRecord { + KnowledgeRecord { + id: id.to_owned(), + observer: "player".to_owned(), + subject: Some("nana".to_owned()), + fact: "Nana has a hairpin".to_owned(), + certainty: KnowledgeCertainty::Confirmed, + source: "seen".to_owned(), + learned_at: "node_001".to_owned(), + last_verified_at: Some("node_001".to_owned()), + } + } + + fn check(id: &str, action_id: &str, roll: u8) -> CheckRecord { + CheckRecord { + id: id.to_owned(), + action_id: action_id.to_owned(), + actor: "player".to_owned(), + skill: "persuade".to_owned(), + target: 55, + difficulty: CheckDifficulty::Regular, + bonus_dice: 0, + roll, + result: CheckResult::Success, + pushed_from: None, + node_id: "node_001".to_owned(), + } + } + + #[test] + fn empty_delta_is_identity() { + let original = state(); assert_eq!( - apply_delta(&state, &StateDelta { ops: Vec::new() }), - Ok(state) + apply_delta(&original, &StateDelta { ops: Vec::new() }), + Ok(original) ); } + + #[test] + fn applies_world_flags_directed_relationships_and_keeps_cursor() { + let original = state(); + let next = apply_delta( + &original, + &StateDelta { + ops: vec![ + StateOp::SetWorldFlag { + key: "truth_known".to_owned(), + value: true, + }, + StateOp::AdjustRelationship { + from: "nana".to_owned(), + to: "player".to_owned(), + adjustment: adjustment(RelationshipDimension::Hope, 4), + }, + StateOp::AdjustRelationship { + from: "player".to_owned(), + to: "nana".to_owned(), + adjustment: adjustment(RelationshipDimension::Trust, -3), + }, + ], + }, + ) + .expect("delta should apply"); + + assert_eq!(next.world_flags.get("truth_known"), Some(&true)); + assert_eq!(next.relationships["nana->player"].hope, 54); + assert_eq!(next.relationships["player->nana"].trust, 47); + assert_eq!(next.current_node, original.current_node); + assert_eq!(next.current_branch, original.current_branch); + assert!(original.world_flags.is_empty()); + assert!(original.relationships.is_empty()); + } + + #[test] + fn clamps_relationship_values_but_rejects_excessive_single_adjustments() { + let mut original = state(); + original.relationships.insert( + "nana->player".to_owned(), + RelationshipAxes { + affinity: 99, + trust: 1, + ..RelationshipAxes::neutral() + }, + ); + let clamped = apply_delta( + &original, + &StateDelta { + ops: vec![ + StateOp::AdjustRelationship { + from: "nana".to_owned(), + to: "player".to_owned(), + adjustment: adjustment(RelationshipDimension::Affinity, 8), + }, + StateOp::AdjustRelationship { + from: "nana".to_owned(), + to: "player".to_owned(), + adjustment: adjustment(RelationshipDimension::Trust, -8), + }, + ], + }, + ) + .expect("bounded adjustments should apply"); + assert_eq!(clamped.relationships["nana->player"].affinity, 100); + assert_eq!(clamped.relationships["nana->player"].trust, 0); + + assert_eq!( + apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::AdjustRelationship { + from: "nana".to_owned(), + to: "player".to_owned(), + adjustment: adjustment(RelationshipDimension::Hope, i16::MIN), + }], + }, + ), + Err(ReduceError::RelationshipAdjustmentOutOfRange { delta: i16::MIN }) + ); + } + + #[test] + fn rejects_duplicate_ids_for_promises_knowledge_and_items() { + let mut original = state(); + original.promises.push(promise("promise_1", PromiseStatus::Proposed)); + original.knowledge.push(knowledge("knowledge_1")); + original.items.push(item("item_1", "nana", "nana")); + + assert_eq!( + apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::CreatePromise { + promise: promise("promise_1", PromiseStatus::Proposed), + }], + }, + ), + Err(ReduceError::DuplicatePromiseId { + id: "promise_1".to_owned() + }) + ); + assert_eq!( + apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::AddKnowledge { + record: knowledge("knowledge_1"), + }], + }, + ), + Err(ReduceError::DuplicateKnowledgeId { + id: "knowledge_1".to_owned() + }) + ); + assert_eq!( + apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::AddItem { + item: item("item_1", "nana", "nana"), + }], + }, + ), + Err(ReduceError::DuplicateItemId { + id: "item_1".to_owned() + }) + ); + } + + #[test] + fn creates_supported_records_in_operation_order() { + let original = state(); + let accepted = promise("promise_1", PromiseStatus::Accepted); + let known = knowledge("knowledge_1"); + let carried = item("item_1", "player", "player"); + let rolled = check("check_1", "action_1", 24); + + let next = apply_delta( + &original, + &StateDelta { + ops: vec![ + StateOp::CreatePromise { + promise: accepted.clone(), + }, + StateOp::AddKnowledge { + record: known.clone(), + }, + StateOp::AddItem { + item: carried.clone(), + }, + StateOp::RecordCheck { + check: rolled.clone(), + }, + ], + }, + ) + .expect("unique records should be appended"); + + assert_eq!(next.promises, vec![accepted]); + assert_eq!(next.knowledge, vec![known]); + assert_eq!(next.items, vec![carried]); + assert_eq!(next.checks, vec![rolled]); + assert!(original.promises.is_empty()); + assert!(original.knowledge.is_empty()); + assert!(original.items.is_empty()); + assert!(original.checks.is_empty()); + } + + #[test] + fn enforces_promise_lifecycle_and_terminal_immutability() { + let mut original = state(); + original + .promises + .push(promise("promise_1", PromiseStatus::Proposed)); + let accepted = apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::UpdatePromise { + promise_id: "promise_1".to_owned(), + status: PromiseStatus::Accepted, + resolved_at: None, + }], + }, + ) + .expect("proposed should become accepted"); + assert_eq!(accepted.promises[0].status, PromiseStatus::Accepted); + assert_eq!( + accepted.promises[0].accepted_at.as_deref(), + Some("node_001") + ); + + let fulfilled = apply_delta( + &accepted, + &StateDelta { + ops: vec![StateOp::UpdatePromise { + promise_id: "promise_1".to_owned(), + status: PromiseStatus::Fulfilled, + resolved_at: Some("node_002".to_owned()), + }], + }, + ) + .expect("accepted should become terminal"); + assert_eq!(fulfilled.promises[0].status, PromiseStatus::Fulfilled); + assert_eq!( + fulfilled.promises[0].resolved_at.as_deref(), + Some("node_002") + ); + + assert_eq!( + apply_delta( + &fulfilled, + &StateDelta { + ops: vec![StateOp::UpdatePromise { + promise_id: "promise_1".to_owned(), + status: PromiseStatus::Broken, + resolved_at: Some("node_003".to_owned()), + }], + }, + ), + Err(ReduceError::InvalidPromiseTransition { + id: "promise_1".to_owned(), + from: PromiseStatus::Fulfilled, + to: PromiseStatus::Broken, + }) + ); + } + + #[test] + fn rejects_skipped_and_invalid_initial_promise_states() { + let mut original = state(); + original + .promises + .push(promise("promise_1", PromiseStatus::Proposed)); + assert_eq!( + apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::UpdatePromise { + promise_id: "promise_1".to_owned(), + status: PromiseStatus::Fulfilled, + resolved_at: Some("node_001".to_owned()), + }], + }, + ), + Err(ReduceError::InvalidPromiseTransition { + id: "promise_1".to_owned(), + from: PromiseStatus::Proposed, + to: PromiseStatus::Fulfilled, + }) + ); + assert_eq!( + apply_delta( + &state(), + &StateDelta { + ops: vec![StateOp::CreatePromise { + promise: promise("promise_terminal", PromiseStatus::Broken), + }], + }, + ), + Err(ReduceError::InvalidInitialPromiseStatus { + id: "promise_terminal".to_owned() + }) + ); + } + + #[test] + fn transfers_holder_without_changing_owner_and_updates_acquisition() { + let mut original = state(); + original.items.push(item("hairpin", "nana", "nana")); + let next = apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::TransferItem { + item_id: "hairpin".to_owned(), + from: "nana".to_owned(), + to: "player".to_owned(), + placement: ItemPlacement::Hidden, + mode: AcquisitionMode::Stolen, + }], + }, + ) + .expect("holder should match"); + + let transferred = &next.items[0]; + assert_eq!(transferred.owner, "nana"); + assert_eq!(transferred.holder, "player"); + assert_eq!(transferred.placement, ItemPlacement::Hidden); + assert_eq!(transferred.acquisition.mode, AcquisitionMode::Stolen); + assert_eq!(transferred.acquisition.from.as_deref(), Some("nana")); + assert_eq!(transferred.acquisition.at_node, "node_001"); + } + + #[test] + fn rejects_transfer_from_non_holder() { + let mut original = state(); + original.items.push(item("hairpin", "nana", "nana")); + assert_eq!( + apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::TransferItem { + item_id: "hairpin".to_owned(), + from: "player".to_owned(), + to: "nana".to_owned(), + placement: ItemPlacement::Hand, + mode: AcquisitionMode::Returned, + }], + }, + ), + Err(ReduceError::ItemHolderMismatch { + id: "hairpin".to_owned(), + expected: "player".to_owned(), + actual: "nana".to_owned(), + }) + ); + } + + #[test] + fn record_check_is_idempotent_by_action_and_rejects_conflicts() { + let original = state(); + let first = check("check_1", "action_1", 24); + let once = apply_delta( + &original, + &StateDelta { + ops: vec![StateOp::RecordCheck { + check: first.clone(), + }], + }, + ) + .expect("first check should be recorded"); + let twice = apply_delta( + &once, + &StateDelta { + ops: vec![StateOp::RecordCheck { + check: first.clone(), + }], + }, + ) + .expect("identical check should be idempotent"); + assert_eq!(twice.checks, vec![first]); + + assert_eq!( + apply_delta( + &once, + &StateDelta { + ops: vec![StateOp::RecordCheck { + check: check("check_2", "action_1", 25), + }], + }, + ), + Err(ReduceError::CheckActionConflict { + action_id: "action_1".to_owned() + }) + ); + assert_eq!( + apply_delta( + &once, + &StateDelta { + ops: vec![StateOp::RecordCheck { + check: check("check_1", "action_2", 24), + }], + }, + ), + Err(ReduceError::DuplicateCheckId { + id: "check_1".to_owned() + }) + ); + } + + #[test] + fn advances_clock_without_exceeding_max() { + let next = apply_delta( + &state(), + &StateDelta { + ops: vec![StateOp::AdvanceClock { + clock_id: "dawn".to_owned(), + delta: u16::MAX, + }], + }, + ) + .expect("known clock should advance"); + assert_eq!(next.clocks[0].value, 4); + } + + #[test] + fn a_failed_late_operation_rolls_back_the_entire_delta() { + let original = state(); + let result = apply_delta( + &original, + &StateDelta { + ops: vec![ + StateOp::SetWorldFlag { + key: "must_not_commit".to_owned(), + value: true, + }, + StateOp::AdvanceClock { + clock_id: "missing".to_owned(), + delta: 1, + }, + ], + }, + ); + + assert_eq!( + result, + Err(ReduceError::ClockNotFound { + id: "missing".to_owned() + }) + ); + assert!(original.world_flags.is_empty()); + assert_eq!(original.clocks[0].value, 2); + } }