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::{
|
use nana_domain::{
|
||||||
CheckRecord, ItemAcquisition, PromiseStatus, RelationshipAxes, RelationshipDimension,
|
CheckRecord, ItemAcquisition, PromiseStatus, RelationshipAxes, RelationshipDimension,
|
||||||
RuntimeState, StateDelta, StateOp,
|
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