restore: import verified wave3 baseline
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
mod projection;
|
||||
|
||||
pub use projection::{
|
||||
project_player_view, relationship_band, PlayerViewProjectionContext, SceneMetadata,
|
||||
PlayerViewProjectionContext, SceneMetadata, StoryNodePlayerViewProjectionContext,
|
||||
project_player_view, project_story_node_player_view, relationship_band,
|
||||
};
|
||||
|
||||
use nana_domain::{
|
||||
@@ -73,6 +74,9 @@ pub fn apply_delta(state: &RuntimeState, delta: &StateDelta) -> Result<RuntimeSt
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
// The exhaustive match intentionally keeps every atomic state operation at one
|
||||
// reviewable reducer boundary.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn apply_op(state: &mut RuntimeState, op: &StateOp) -> Result<(), ReduceError> {
|
||||
match op {
|
||||
StateOp::SetWorldFlag { key, value } => {
|
||||
@@ -274,7 +278,7 @@ mod tests {
|
||||
RuntimeState, StateDelta, StateOp,
|
||||
};
|
||||
|
||||
use super::{apply_delta, ReduceError};
|
||||
use super::{ReduceError, apply_delta};
|
||||
|
||||
fn state() -> RuntimeState {
|
||||
RuntimeState {
|
||||
@@ -461,7 +465,9 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_duplicate_ids_for_promises_knowledge_and_items() {
|
||||
let mut original = state();
|
||||
original.promises.push(promise("promise_1", PromiseStatus::Proposed));
|
||||
original
|
||||
.promises
|
||||
.push(promise("promise_1", PromiseStatus::Proposed));
|
||||
original.knowledge.push(knowledge("knowledge_1"));
|
||||
original.items.push(item("item_1", "nana", "nana"));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use nana_domain::{
|
||||
ActionSuggestion, HistoryNodeView, ItemSpec, PlayerItemView, PlayerKnowledgeView,
|
||||
PlayerPromiseView, PlayerView, PresentationBeat, PromiseStatus, RelationshipAxes,
|
||||
RelationshipBand, RelationshipView, RuntimeState,
|
||||
RelationshipBand, RelationshipView, RuntimeState, StoryNode,
|
||||
};
|
||||
|
||||
/// Public scene data supplied by the presentation layer for the current node.
|
||||
@@ -30,11 +30,79 @@ pub struct PlayerViewProjectionContext<'a> {
|
||||
pub can_continue: bool,
|
||||
}
|
||||
|
||||
/// Trusted non-presentation inputs for restoring a committed story node.
|
||||
///
|
||||
/// All dynamic presentation is read from `StoryNode::presentation`. The legacy
|
||||
/// scene is consulted only for saves created before presentation snapshots
|
||||
/// existed, whose defaulted scene and character identifiers are empty.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StoryNodePlayerViewProjectionContext<'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 history: &'a [HistoryNodeView],
|
||||
pub legacy_scene: SceneMetadata<'a>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
project_player_view_with_visual_state(state, context, None, None)
|
||||
}
|
||||
|
||||
/// Restores the player-safe view for a committed node from its persisted
|
||||
/// presentation snapshot.
|
||||
///
|
||||
/// This function is deterministic for the same `RuntimeState`, `StoryNode`,
|
||||
/// public item definitions and history. It never derives presentation from
|
||||
/// private runtime fields, and hidden runtime data still passes through the
|
||||
/// same filtering boundary as a live turn projection.
|
||||
#[must_use]
|
||||
pub fn project_story_node_player_view(
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
context: &StoryNodePlayerViewProjectionContext<'_>,
|
||||
) -> PlayerView {
|
||||
let snapshot = &node.presentation;
|
||||
let scene_id = non_empty_or(&snapshot.scene.id, context.legacy_scene.scene_id);
|
||||
let scene_title = non_empty_or(&snapshot.scene.title, context.legacy_scene.scene_title);
|
||||
let character_name = non_empty_or(
|
||||
&snapshot.character.name,
|
||||
context.legacy_scene.character_name,
|
||||
);
|
||||
let projection_context = PlayerViewProjectionContext {
|
||||
player_id: context.player_id,
|
||||
relationship_character_id: context.relationship_character_id,
|
||||
relationship_updated_at_node: context.relationship_updated_at_node,
|
||||
item_specs: context.item_specs,
|
||||
scene: SceneMetadata {
|
||||
scene_id,
|
||||
scene_title,
|
||||
character_name,
|
||||
},
|
||||
beats: &snapshot.beats,
|
||||
suggestions: &snapshot.suggestions,
|
||||
history: context.history,
|
||||
can_continue: snapshot.can_continue,
|
||||
};
|
||||
|
||||
project_player_view_with_visual_state(
|
||||
state,
|
||||
&projection_context,
|
||||
snapshot.character.expression.as_deref(),
|
||||
snapshot.character.pose.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn project_player_view_with_visual_state(
|
||||
state: &RuntimeState,
|
||||
context: &PlayerViewProjectionContext<'_>,
|
||||
character_expression: Option<&str>,
|
||||
character_pose: Option<&str>,
|
||||
) -> PlayerView {
|
||||
let inventory = state
|
||||
.items
|
||||
@@ -62,10 +130,7 @@ pub fn project_player_view(
|
||||
.filter(|record| record.observer == context.player_id)
|
||||
.map(|record| PlayerKnowledgeView {
|
||||
id: record.id.clone(),
|
||||
title: record
|
||||
.subject
|
||||
.clone()
|
||||
.unwrap_or_else(|| "线索".to_owned()),
|
||||
title: record.subject.clone().unwrap_or_else(|| "线索".to_owned()),
|
||||
summary: record.fact.clone(),
|
||||
certainty: record.certainty,
|
||||
})
|
||||
@@ -76,8 +141,7 @@ pub fn project_player_view(
|
||||
.iter()
|
||||
.filter(|promise| {
|
||||
is_visible_promise_status(promise.status)
|
||||
&& (promise.promiser == context.player_id
|
||||
|| promise.promisee == context.player_id)
|
||||
&& (promise.promiser == context.player_id || promise.promisee == context.player_id)
|
||||
})
|
||||
.map(|promise| PlayerPromiseView {
|
||||
id: promise.id.clone(),
|
||||
@@ -104,20 +168,23 @@ pub fn project_player_view(
|
||||
scene_id: context.scene.scene_id.to_owned(),
|
||||
scene_title: context.scene.scene_title.to_owned(),
|
||||
character_name: context.scene.character_name.to_owned(),
|
||||
character_expression: character_expression.map(str::to_owned),
|
||||
character_pose: character_pose.map(str::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,
|
||||
),
|
||||
relationship: relationship_view(relationship_axes, context.relationship_updated_at_node),
|
||||
history: context.history.to_vec(),
|
||||
can_continue: context.can_continue,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
|
||||
if value.is_empty() { fallback } else { value }
|
||||
}
|
||||
|
||||
/// Converts an exact relationship value into its intentionally coarse player band.
|
||||
///
|
||||
/// Runtime validation constrains values to 0..=100. Values outside that range are
|
||||
@@ -134,10 +201,7 @@ pub const fn relationship_band(value: i16) -> RelationshipBand {
|
||||
}
|
||||
}
|
||||
|
||||
fn relationship_view(
|
||||
axes: RelationshipAxes,
|
||||
updated_at_node: Option<&str>,
|
||||
) -> RelationshipView {
|
||||
fn relationship_view(axes: RelationshipAxes, updated_at_node: Option<&str>) -> RelationshipView {
|
||||
RelationshipView {
|
||||
affinity: relationship_band(axes.affinity),
|
||||
trust: relationship_band(axes.trust),
|
||||
@@ -167,13 +231,15 @@ mod tests {
|
||||
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,
|
||||
KnowledgeCertainty, KnowledgeRecord, PresentationBeat, PresentationCharacter,
|
||||
PresentationScene, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight,
|
||||
RelationshipAxes, RelationshipBand, ResourceHeader, ResourceId, ResourceKind, RuntimeState,
|
||||
StateDelta, StoryNode, VisualDirective,
|
||||
};
|
||||
|
||||
use super::{
|
||||
project_player_view, relationship_band, PlayerViewProjectionContext, SceneMetadata,
|
||||
PlayerViewProjectionContext, SceneMetadata, StoryNodePlayerViewProjectionContext,
|
||||
project_player_view, project_story_node_player_view, relationship_band,
|
||||
};
|
||||
|
||||
fn state() -> RuntimeState {
|
||||
@@ -312,10 +378,7 @@ mod tests {
|
||||
is_current: true,
|
||||
}];
|
||||
|
||||
let view = project_player_view(
|
||||
&original,
|
||||
&context(&[], &beats, &suggestions, &history),
|
||||
);
|
||||
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);
|
||||
@@ -323,6 +386,8 @@ mod tests {
|
||||
assert_eq!(view.scene_id, "old_station");
|
||||
assert_eq!(view.scene_title, "旧青川站");
|
||||
assert_eq!(view.character_name, "娜娜");
|
||||
assert!(view.character_expression.is_none());
|
||||
assert!(view.character_pose.is_none());
|
||||
assert_eq!(view.beats, beats);
|
||||
assert_eq!(view.suggestions, suggestions);
|
||||
assert_eq!(view.history, history);
|
||||
@@ -333,6 +398,139 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_dynamic_presentation_from_story_node_snapshot() {
|
||||
let mut original = state();
|
||||
original.knowledge = vec![
|
||||
knowledge(
|
||||
"known",
|
||||
"player",
|
||||
Some("站台"),
|
||||
"雨水已经漫过了第一节台阶。",
|
||||
),
|
||||
knowledge(
|
||||
"private",
|
||||
"nana",
|
||||
Some("妹妹"),
|
||||
"HIDDEN_RUNTIME_KNOWLEDGE_CANARY",
|
||||
),
|
||||
];
|
||||
let beats = vec![PresentationBeat {
|
||||
id: "beat_restore".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: Some("lowered_hands".to_owned()),
|
||||
scene: Some("old_station_after_rain".to_owned()),
|
||||
}),
|
||||
}];
|
||||
let suggestions = vec![ActionSuggestion {
|
||||
id: "ask_why".to_owned(),
|
||||
label: "问她原因".to_owned(),
|
||||
draft: "你为什么一直等在这里?".to_owned(),
|
||||
}];
|
||||
let node = StoryNode {
|
||||
id: "node_008".to_owned(),
|
||||
story_id: "story_demo".to_owned(),
|
||||
branch_id: "branch_main".to_owned(),
|
||||
parent_id: Some("node_007".to_owned()),
|
||||
action_id: "action_return".to_owned(),
|
||||
user_input: "我回来了。".to_owned(),
|
||||
presentation: PresentationSnapshot {
|
||||
scene: PresentationScene {
|
||||
id: "old_station_after_rain".to_owned(),
|
||||
title: "雨后的旧青川站".to_owned(),
|
||||
},
|
||||
character: PresentationCharacter {
|
||||
id: "nana".to_owned(),
|
||||
name: "娜娜".to_owned(),
|
||||
expression: Some("relieved".to_owned()),
|
||||
pose: Some("lowered_hands".to_owned()),
|
||||
},
|
||||
beats: beats.clone(),
|
||||
suggestions: suggestions.clone(),
|
||||
can_continue: false,
|
||||
},
|
||||
delta: StateDelta { ops: Vec::new() },
|
||||
state_hash: "sha256:test".to_owned(),
|
||||
};
|
||||
|
||||
let view = project_story_node_player_view(
|
||||
&original,
|
||||
&node,
|
||||
&StoryNodePlayerViewProjectionContext {
|
||||
player_id: "player",
|
||||
relationship_character_id: "nana",
|
||||
relationship_updated_at_node: Some("node_007"),
|
||||
item_specs: &[],
|
||||
history: &[],
|
||||
legacy_scene: SceneMetadata {
|
||||
scene_id: "legacy_scene",
|
||||
scene_title: "旧场景",
|
||||
character_name: "旧角色",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(view.scene_id, "old_station_after_rain");
|
||||
assert_eq!(view.scene_title, "雨后的旧青川站");
|
||||
assert_eq!(view.character_name, "娜娜");
|
||||
assert_eq!(view.character_expression.as_deref(), Some("relieved"));
|
||||
assert_eq!(view.character_pose.as_deref(), Some("lowered_hands"));
|
||||
assert_eq!(view.beats, beats);
|
||||
assert_eq!(view.suggestions, suggestions);
|
||||
assert!(!view.can_continue);
|
||||
assert_eq!(view.knowledge.len(), 1);
|
||||
assert!(!format!("{view:?}").contains("HIDDEN_RUNTIME_KNOWLEDGE_CANARY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_snapshot_uses_public_scene_fallback() {
|
||||
let mut node = StoryNode {
|
||||
id: "node_008".to_owned(),
|
||||
story_id: "story_demo".to_owned(),
|
||||
branch_id: "branch_main".to_owned(),
|
||||
parent_id: Some("node_007".to_owned()),
|
||||
action_id: "legacy".to_owned(),
|
||||
user_input: String::new(),
|
||||
presentation: PresentationSnapshot::default(),
|
||||
delta: StateDelta { ops: Vec::new() },
|
||||
state_hash: "sha256:test".to_owned(),
|
||||
};
|
||||
node.presentation.beats.push(PresentationBeat {
|
||||
id: "legacy_beat".to_owned(),
|
||||
kind: BeatKind::Narration,
|
||||
speaker: None,
|
||||
text: "雨还在下。".to_owned(),
|
||||
visual: None,
|
||||
});
|
||||
|
||||
let view = project_story_node_player_view(
|
||||
&state(),
|
||||
&node,
|
||||
&StoryNodePlayerViewProjectionContext {
|
||||
player_id: "player",
|
||||
relationship_character_id: "nana",
|
||||
relationship_updated_at_node: None,
|
||||
item_specs: &[],
|
||||
history: &[],
|
||||
legacy_scene: SceneMetadata {
|
||||
scene_id: "old_station",
|
||||
scene_title: "旧青川站",
|
||||
character_name: "娜娜",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(view.scene_id, "old_station");
|
||||
assert_eq!(view.character_name, "娜娜");
|
||||
assert_eq!(view.beats[0].id, "legacy_beat");
|
||||
assert!(view.can_continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_only_contains_player_held_items_with_public_spec_fields() {
|
||||
const HIDDEN_FACT: &str = "HIDDEN_ITEM_FACT_CANARY";
|
||||
@@ -352,18 +550,12 @@ mod tests {
|
||||
];
|
||||
let mut original = state();
|
||||
original.items = vec![
|
||||
item(
|
||||
"flashlight_1",
|
||||
"item.flashlight",
|
||||
"nana",
|
||||
"player",
|
||||
),
|
||||
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, &[], &[], &[]));
|
||||
let view = project_player_view(&original, &context(&specs, &[], &[], &[]));
|
||||
|
||||
assert_eq!(view.inventory.len(), 1);
|
||||
assert_eq!(view.inventory[0].instance_id, "flashlight_1");
|
||||
@@ -383,12 +575,7 @@ mod tests {
|
||||
fn knowledge_only_contains_records_observed_by_player() {
|
||||
let mut original = state();
|
||||
original.knowledge = vec![
|
||||
knowledge(
|
||||
"known",
|
||||
"player",
|
||||
Some("停摆的站钟"),
|
||||
"站钟并非自然损坏。",
|
||||
),
|
||||
knowledge("known", "player", Some("停摆的站钟"), "站钟并非自然损坏。"),
|
||||
knowledge(
|
||||
"npc_secret",
|
||||
"nana",
|
||||
@@ -398,8 +585,7 @@ mod tests {
|
||||
knowledge("untitled", "player", None, "雨刚刚停了。"),
|
||||
];
|
||||
|
||||
let view =
|
||||
project_player_view(&original, &context(&[], &[], &[], &[]));
|
||||
let view = project_player_view(&original, &context(&[], &[], &[], &[]));
|
||||
|
||||
assert_eq!(view.knowledge.len(), 2);
|
||||
assert_eq!(view.knowledge[0].id, "known");
|
||||
@@ -438,8 +624,7 @@ mod tests {
|
||||
resolved_at: None,
|
||||
});
|
||||
|
||||
let view =
|
||||
project_player_view(&original, &context(&[], &[], &[], &[]));
|
||||
let view = project_player_view(&original, &context(&[], &[], &[], &[]));
|
||||
|
||||
assert_eq!(view.promises.len(), 5);
|
||||
assert!(!format!("{view:?}").contains(NPC_PROMISE_CANARY));
|
||||
@@ -455,11 +640,7 @@ mod tests {
|
||||
PromiseStatus::Released,
|
||||
PromiseStatus::Impossible,
|
||||
] {
|
||||
assert!(
|
||||
view.promises
|
||||
.iter()
|
||||
.any(|promise| promise.status == status)
|
||||
);
|
||||
assert!(view.promises.iter().any(|promise| promise.status == status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,8 +695,7 @@ mod tests {
|
||||
node_id: "node_008".to_owned(),
|
||||
});
|
||||
|
||||
let view =
|
||||
project_player_view(&original, &context(&[], &[], &[], &[]));
|
||||
let view = project_player_view(&original, &context(&[], &[], &[], &[]));
|
||||
|
||||
assert_eq!(view.relationship.affinity, RelationshipBand::Distant);
|
||||
assert_eq!(view.relationship.trust, RelationshipBand::Guarded);
|
||||
@@ -531,10 +711,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn missing_relationship_projects_to_neutral_bands() {
|
||||
let view = project_player_view(
|
||||
&state(),
|
||||
&context(&[], &[], &[], &[]),
|
||||
);
|
||||
let view = project_player_view(&state(), &context(&[], &[], &[], &[]));
|
||||
|
||||
assert_eq!(view.relationship.affinity, RelationshipBand::Warming);
|
||||
assert_eq!(view.relationship.trust, RelationshipBand::Warming);
|
||||
|
||||
Reference in New Issue
Block a user