feat(engine): project private state into PlayerView

This commit is contained in:
Codex
2026-07-28 13:19:16 +08:00
parent dbc4d2981c
commit 8fd1dbcec1
2 changed files with 535 additions and 0 deletions
+6
View File
@@ -1,3 +1,9 @@
mod projection;
pub use projection::{
project_player_view, relationship_band, PlayerViewProjectionContext, SceneMetadata,
};
use nana_domain::{ use nana_domain::{
CheckRecord, ItemAcquisition, PromiseStatus, RelationshipAxes, RelationshipDimension, CheckRecord, ItemAcquisition, PromiseStatus, RelationshipAxes, RelationshipDimension,
RuntimeState, StateDelta, StateOp, RuntimeState, StateDelta, StateOp,
+529
View File
@@ -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);
}
}