feat(engine): add deterministic state reducer
This commit is contained in:
+756
-18
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user