restore: import verified wave3 baseline

This commit is contained in:
Codex
2026-07-28 03:16:01 -04:00
parent 4157f8790d
commit cf9507a9dd
41 changed files with 12251 additions and 982 deletions
+9 -3
View File
@@ -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"));
+229 -52
View File
@@ -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);