From 8fd1dbcec18ff0969f07cba8346d8632a1937125 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 13:16:40 +0800 Subject: [PATCH] feat(engine): project private state into PlayerView --- crates/nana-engine/src/lib.rs | 6 + crates/nana-engine/src/projection.rs | 529 +++++++++++++++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 crates/nana-engine/src/projection.rs diff --git a/crates/nana-engine/src/lib.rs b/crates/nana-engine/src/lib.rs index ee339d3..f286c1d 100644 --- a/crates/nana-engine/src/lib.rs +++ b/crates/nana-engine/src/lib.rs @@ -1,3 +1,9 @@ +mod projection; + +pub use projection::{ + project_player_view, relationship_band, PlayerViewProjectionContext, SceneMetadata, +}; + use nana_domain::{ CheckRecord, ItemAcquisition, PromiseStatus, RelationshipAxes, RelationshipDimension, RuntimeState, StateDelta, StateOp, diff --git a/crates/nana-engine/src/projection.rs b/crates/nana-engine/src/projection.rs new file mode 100644 index 0000000..2311425 --- /dev/null +++ b/crates/nana-engine/src/projection.rs @@ -0,0 +1,529 @@ +use nana_domain::{ + ActionSuggestion, HistoryNodeView, ItemSpec, PlayerItemView, PlayerKnowledgeView, + PlayerPromiseView, PlayerView, PresentationBeat, PromiseStatus, RelationshipAxes, + RelationshipBand, RelationshipView, RuntimeState, +}; + +/// Public scene data supplied by the presentation layer for the current node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SceneMetadata<'a> { + pub scene_id: &'a str, + pub scene_title: &'a str, + pub character_name: &'a str, +} + +/// The non-persistent, already-authorized inputs needed to build a [`PlayerView`]. +/// +/// Runtime facts always come from `RuntimeState`. Presentation data and public +/// resource definitions are passed separately so this projection remains pure and +/// callers do not need to expose the complete runtime state to the frontend. +#[derive(Debug, Clone, Copy)] +pub struct PlayerViewProjectionContext<'a> { + pub player_id: &'a str, + pub relationship_character_id: &'a str, + pub relationship_updated_at_node: Option<&'a str>, + pub item_specs: &'a [ItemSpec], + pub scene: SceneMetadata<'a>, + pub beats: &'a [PresentationBeat], + pub suggestions: &'a [ActionSuggestion], + pub history: &'a [HistoryNodeView], + pub can_continue: bool, +} + +/// Projects private runtime state into the only state shape safe for player UI. +#[must_use] +pub fn project_player_view( + state: &RuntimeState, + context: &PlayerViewProjectionContext<'_>, +) -> PlayerView { + let inventory = state + .items + .iter() + .filter(|item| item.holder == context.player_id) + .filter_map(|item| { + let spec = context + .item_specs + .iter() + .find(|spec| spec.header.id == item.spec_ref)?; + Some(PlayerItemView { + instance_id: item.id.clone(), + name: spec.name.clone(), + description: spec.description.clone(), + quantity: item.quantity, + placement: item.placement, + condition: item.condition.clone(), + }) + }) + .collect(); + + let knowledge = state + .knowledge + .iter() + .filter(|record| record.observer == context.player_id) + .map(|record| PlayerKnowledgeView { + id: record.id.clone(), + title: record + .subject + .clone() + .unwrap_or_else(|| "线索".to_owned()), + summary: record.fact.clone(), + certainty: record.certainty, + }) + .collect(); + + let promises = state + .promises + .iter() + .filter(|promise| is_visible_promise_status(promise.status)) + .map(|promise| PlayerPromiseView { + id: promise.id.clone(), + content: promise.content.clone(), + status: promise.status, + weight: promise.weight, + }) + .collect(); + + let relationship_key = format!( + "{}->{}", + context.relationship_character_id, context.player_id + ); + let relationship_axes = state + .relationships + .get(&relationship_key) + .copied() + .unwrap_or_else(RelationshipAxes::neutral); + + PlayerView { + story_id: state.story_id.clone(), + node_id: state.current_node.clone(), + branch_id: state.current_branch.clone(), + scene_id: context.scene.scene_id.to_owned(), + scene_title: context.scene.scene_title.to_owned(), + character_name: context.scene.character_name.to_owned(), + beats: context.beats.to_vec(), + suggestions: context.suggestions.to_vec(), + inventory, + knowledge, + promises, + relationship: relationship_view( + relationship_axes, + context.relationship_updated_at_node, + ), + history: context.history.to_vec(), + can_continue: context.can_continue, + } +} + +/// Converts an exact relationship value into its intentionally coarse player band. +/// +/// Runtime validation constrains values to 0..=100. Values outside that range are +/// still mapped to the nearest outer band so this boundary never exposes an exact +/// value merely because it received malformed state. +#[must_use] +pub const fn relationship_band(value: i16) -> RelationshipBand { + match value { + ..=19 => RelationshipBand::Distant, + 20..=39 => RelationshipBand::Guarded, + 40..=59 => RelationshipBand::Warming, + 60..=79 => RelationshipBand::Close, + 80.. => RelationshipBand::Bonded, + } +} + +fn relationship_view( + axes: RelationshipAxes, + updated_at_node: Option<&str>, +) -> RelationshipView { + RelationshipView { + affinity: relationship_band(axes.affinity), + trust: relationship_band(axes.trust), + hope: relationship_band(axes.hope), + respect: relationship_band(axes.respect), + intimacy: relationship_band(axes.intimacy), + attachment: relationship_band(axes.attachment), + updated_at_node: updated_at_node.map(str::to_owned), + } +} + +const fn is_visible_promise_status(status: PromiseStatus) -> bool { + matches!( + status, + PromiseStatus::Accepted + | PromiseStatus::Fulfilled + | PromiseStatus::Broken + | PromiseStatus::Released + | PromiseStatus::Impossible + ) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use nana_domain::{ + AcquisitionMode, ActionSuggestion, BeatKind, CheckDifficulty, CheckRecord, CheckResult, + HistoryNodeView, ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, + KnowledgeCertainty, KnowledgeRecord, PresentationBeat, Promise, PromiseStatus, + PromiseWeight, RelationshipAxes, RelationshipBand, ResourceHeader, ResourceId, + ResourceKind, RuntimeState, VisualDirective, + }; + + use super::{ + project_player_view, relationship_band, PlayerViewProjectionContext, SceneMetadata, + }; + + fn state() -> RuntimeState { + RuntimeState { + story_id: "story_demo".to_owned(), + current_node: "node_008".to_owned(), + current_branch: "branch_main".to_owned(), + 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 context<'a>( + item_specs: &'a [ItemSpec], + beats: &'a [PresentationBeat], + suggestions: &'a [ActionSuggestion], + history: &'a [HistoryNodeView], + ) -> PlayerViewProjectionContext<'a> { + PlayerViewProjectionContext { + player_id: "player", + relationship_character_id: "nana", + relationship_updated_at_node: Some("node_007"), + item_specs, + scene: SceneMetadata { + scene_id: "old_station", + scene_title: "旧青川站", + character_name: "娜娜", + }, + beats, + suggestions, + history, + can_continue: true, + } + } + + fn item_spec(id: &str, name: &str, description: &str, hidden_fact: &str) -> ItemSpec { + ItemSpec { + header: ResourceHeader { + id: ResourceId(id.to_owned()), + kind: ResourceKind::ItemSpec, + schema_version: 1, + revision: "1".to_owned(), + content_hash: format!("sha256:{}", "0".repeat(64)), + dependencies: Vec::new(), + }, + name: name.to_owned(), + description: description.to_owned(), + tags: vec!["public_tag".to_owned()], + lore_refs: vec!["private_lore_ref".to_owned()], + mechanics: ItemMechanics { + usable: true, + grants_tags: vec!["private_mechanic".to_owned()], + check_modifier: Some(2), + }, + hidden_facts: vec![hidden_fact.to_owned()], + } + } + + fn item(id: &str, spec_ref: &str, owner: &str, holder: &str) -> ItemInstance { + ItemInstance { + id: id.to_owned(), + spec_ref: ResourceId(spec_ref.to_owned()), + owner: owner.to_owned(), + holder: holder.to_owned(), + placement: ItemPlacement::Bag, + quantity: 1, + condition: "intact".to_owned(), + state_tags: vec!["private_instance_tag".to_owned()], + acquisition: ItemAcquisition { + mode: AcquisitionMode::Stolen, + from: Some("nana".to_owned()), + at_node: "node_006".to_owned(), + }, + } + } + + fn knowledge(id: &str, observer: &str, subject: Option<&str>, fact: &str) -> KnowledgeRecord { + KnowledgeRecord { + id: id.to_owned(), + observer: observer.to_owned(), + subject: subject.map(str::to_owned), + fact: fact.to_owned(), + certainty: KnowledgeCertainty::Confirmed, + source: "source detail".to_owned(), + learned_at: "node_004".to_owned(), + last_verified_at: None, + } + } + + fn promise(id: &str, status: PromiseStatus) -> Promise { + Promise { + id: id.to_owned(), + promiser: "player".to_owned(), + promisee: "nana".to_owned(), + content: format!("promise content {id}"), + status, + weight: PromiseWeight::Major, + created_at: "node_001".to_owned(), + accepted_at: (status != PromiseStatus::Proposed).then(|| "node_002".to_owned()), + resolved_at: (!matches!(status, PromiseStatus::Proposed | PromiseStatus::Accepted)) + .then(|| "node_005".to_owned()), + } + } + + #[test] + fn preserves_supplied_presentation_and_scene_metadata() { + let original = state(); + let beats = vec![PresentationBeat { + id: "beat_1".to_owned(), + kind: BeatKind::Dialogue, + speaker: Some("娜娜".to_owned()), + text: "你回来了。".to_owned(), + visual: Some(VisualDirective { + character: Some("nana".to_owned()), + expression: Some("relieved".to_owned()), + pose: None, + scene: Some("old_station_rain".to_owned()), + }), + }]; + let suggestions = vec![ActionSuggestion { + id: "answer".to_owned(), + label: "回答".to_owned(), + draft: "我回来了。".to_owned(), + }]; + let history = vec![HistoryNodeView { + id: "node_008".to_owned(), + parent_id: Some("node_007".to_owned()), + branch_id: "branch_main".to_owned(), + label: "重逢".to_owned(), + is_current: true, + }]; + + let view = project_player_view( + &original, + &context(&[], &beats, &suggestions, &history), + ); + + assert_eq!(view.story_id, original.story_id); + assert_eq!(view.node_id, original.current_node); + assert_eq!(view.branch_id, original.current_branch); + assert_eq!(view.scene_id, "old_station"); + assert_eq!(view.scene_title, "旧青川站"); + assert_eq!(view.character_name, "娜娜"); + assert_eq!(view.beats, beats); + assert_eq!(view.suggestions, suggestions); + assert_eq!(view.history, history); + assert!(view.can_continue); + assert_eq!( + view.relationship.updated_at_node.as_deref(), + Some("node_007") + ); + } + + #[test] + fn inventory_only_contains_player_held_items_with_public_spec_fields() { + const HIDDEN_FACT: &str = "HIDDEN_ITEM_FACT_CANARY"; + let specs = vec![ + item_spec( + "item.flashlight", + "旧手电筒", + "足以照清站台下方。", + HIDDEN_FACT, + ), + item_spec( + "item.hairpin", + "红色发卡", + "已经有些褪色。", + "NPC_HIDDEN_ITEM_FACT_CANARY", + ), + ]; + let mut original = state(); + original.items = vec![ + item( + "flashlight_1", + "item.flashlight", + "nana", + "player", + ), + item("hairpin_1", "item.hairpin", "nana", "nana"), + item("missing_spec_1", "item.missing", "player", "player"), + ]; + + let view = + project_player_view(&original, &context(&specs, &[], &[], &[])); + + assert_eq!(view.inventory.len(), 1); + assert_eq!(view.inventory[0].instance_id, "flashlight_1"); + assert_eq!(view.inventory[0].name, "旧手电筒"); + assert_eq!(view.inventory[0].description, "足以照清站台下方。"); + + let rendered = format!("{view:?}"); + assert!(!rendered.contains(HIDDEN_FACT)); + assert!(!rendered.contains("NPC_HIDDEN_ITEM_FACT_CANARY")); + assert!(!rendered.contains("hairpin_1")); + assert!(!rendered.contains("private_lore_ref")); + assert!(!rendered.contains("private_mechanic")); + assert!(!rendered.contains("private_instance_tag")); + } + + #[test] + fn knowledge_only_contains_records_observed_by_player() { + let mut original = state(); + original.knowledge = vec![ + knowledge( + "known", + "player", + Some("停摆的站钟"), + "站钟并非自然损坏。", + ), + knowledge( + "npc_secret", + "nana", + Some("妹妹的去向"), + "NPC_KNOWLEDGE_CANARY", + ), + knowledge("untitled", "player", None, "雨刚刚停了。"), + ]; + + let view = + project_player_view(&original, &context(&[], &[], &[], &[])); + + assert_eq!(view.knowledge.len(), 2); + assert_eq!(view.knowledge[0].id, "known"); + assert_eq!(view.knowledge[0].title, "停摆的站钟"); + assert_eq!(view.knowledge[0].summary, "站钟并非自然损坏。"); + assert_eq!(view.knowledge[1].title, "线索"); + assert!(!format!("{view:?}").contains("NPC_KNOWLEDGE_CANARY")); + } + + #[test] + fn promises_hide_proposals_and_include_accepted_and_terminal_states() { + let statuses = [ + PromiseStatus::Proposed, + PromiseStatus::Accepted, + PromiseStatus::Fulfilled, + PromiseStatus::Broken, + PromiseStatus::Released, + PromiseStatus::Impossible, + ]; + let mut original = state(); + original.promises = statuses + .into_iter() + .enumerate() + .map(|(index, status)| promise(&format!("promise_{index}"), status)) + .collect(); + + let view = + project_player_view(&original, &context(&[], &[], &[], &[])); + + assert_eq!(view.promises.len(), 5); + assert!( + view.promises + .iter() + .all(|promise| promise.status != PromiseStatus::Proposed) + ); + for status in [ + PromiseStatus::Accepted, + PromiseStatus::Fulfilled, + PromiseStatus::Broken, + PromiseStatus::Released, + PromiseStatus::Impossible, + ] { + assert!( + view.promises + .iter() + .any(|promise| promise.status == status) + ); + } + } + + #[test] + fn relationship_band_thresholds_are_inclusive_and_defensive() { + for (value, expected) in [ + (i16::MIN, RelationshipBand::Distant), + (0, RelationshipBand::Distant), + (19, RelationshipBand::Distant), + (20, RelationshipBand::Guarded), + (39, RelationshipBand::Guarded), + (40, RelationshipBand::Warming), + (59, RelationshipBand::Warming), + (60, RelationshipBand::Close), + (79, RelationshipBand::Close), + (80, RelationshipBand::Bonded), + (100, RelationshipBand::Bonded), + (i16::MAX, RelationshipBand::Bonded), + ] { + assert_eq!(relationship_band(value), expected, "value {value}"); + } + } + + #[test] + fn relationship_uses_character_to_player_edge_and_hides_exact_values_and_checks() { + let mut original = state(); + original.relationships.insert( + "nana->player".to_owned(), + RelationshipAxes { + affinity: 19, + trust: 20, + hope: 40, + respect: 60, + intimacy: 80, + attachment: 73, + }, + ); + original + .relationships + .insert("player->nana".to_owned(), RelationshipAxes::neutral()); + original.checks.push(CheckRecord { + id: "HIDDEN_CHECK_ID_CANARY".to_owned(), + action_id: "hidden_action".to_owned(), + actor: "player".to_owned(), + skill: "HIDDEN_CHECK_SKILL_CANARY".to_owned(), + target: 73, + difficulty: CheckDifficulty::Hard, + bonus_dice: 1, + roll: 24, + result: CheckResult::Success, + pushed_from: None, + node_id: "node_008".to_owned(), + }); + + let view = + project_player_view(&original, &context(&[], &[], &[], &[])); + + assert_eq!(view.relationship.affinity, RelationshipBand::Distant); + assert_eq!(view.relationship.trust, RelationshipBand::Guarded); + assert_eq!(view.relationship.hope, RelationshipBand::Warming); + assert_eq!(view.relationship.respect, RelationshipBand::Close); + assert_eq!(view.relationship.intimacy, RelationshipBand::Bonded); + assert_eq!(view.relationship.attachment, RelationshipBand::Close); + + let rendered = format!("{view:?}"); + assert!(!rendered.contains("HIDDEN_CHECK_ID_CANARY")); + assert!(!rendered.contains("HIDDEN_CHECK_SKILL_CANARY")); + } + + #[test] + fn missing_relationship_projects_to_neutral_bands() { + let view = project_player_view( + &state(), + &context(&[], &[], &[], &[]), + ); + + assert_eq!(view.relationship.affinity, RelationshipBand::Warming); + assert_eq!(view.relationship.trust, RelationshipBand::Warming); + assert_eq!(view.relationship.hope, RelationshipBand::Warming); + assert_eq!(view.relationship.respect, RelationshipBand::Warming); + assert_eq!(view.relationship.intimacy, RelationshipBand::Warming); + assert_eq!(view.relationship.attachment, RelationshipBand::Warming); + } +}