feat(engine): add deterministic state reducer

This commit is contained in:
Codex
2026-07-28 13:05:29 +08:00
parent 62f01aca25
commit 5e1c0e74b4
+756 -18
View File
@@ -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<RuntimeState, ReduceError> {
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);
}
}