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;
|
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)]
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
pub enum ReduceError {
|
pub enum ReduceError {
|
||||||
#[error("state delta is not implemented yet: {0} operation(s)")]
|
#[error("relationship adjustment {delta} exceeds the per-operation limit of 8")]
|
||||||
NotImplemented(usize),
|
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
|
/// Applies a complete delta to a cloned state.
|
||||||
/// state mutation path in the application.
|
///
|
||||||
|
/// 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> {
|
pub fn apply_delta(state: &RuntimeState, delta: &StateDelta) -> Result<RuntimeState, ReduceError> {
|
||||||
if delta.ops.is_empty() {
|
let mut next = state.clone();
|
||||||
Ok(state.clone())
|
for op in &delta.ops {
|
||||||
} else {
|
apply_op(&mut next, op)?;
|
||||||
Err(ReduceError::NotImplemented(delta.ops.len()))
|
|
||||||
}
|
}
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
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 state() -> RuntimeState {
|
||||||
fn empty_delta_is_identity() {
|
RuntimeState {
|
||||||
let state = RuntimeState {
|
|
||||||
story_id: "story_demo".to_owned(),
|
story_id: "story_demo".to_owned(),
|
||||||
current_node: "node_001".to_owned(),
|
current_node: "node_001".to_owned(),
|
||||||
current_branch: "branch_main".to_owned(),
|
current_branch: "branch_main".to_owned(),
|
||||||
@@ -37,12 +281,506 @@ mod tests {
|
|||||||
promises: Vec::new(),
|
promises: Vec::new(),
|
||||||
knowledge: Vec::new(),
|
knowledge: Vec::new(),
|
||||||
items: 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(),
|
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!(
|
assert_eq!(
|
||||||
apply_delta(&state, &StateDelta { ops: Vec::new() }),
|
apply_delta(&original, &StateDelta { ops: Vec::new() }),
|
||||||
Ok(state)
|
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