feat(engine): project private state into PlayerView
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user