restore: import verified wave3 baseline
This commit is contained in:
@@ -12,5 +12,8 @@ sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
ts-rs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+208
-38
@@ -11,7 +11,7 @@ pub const DOMAIN_SCHEMA_VERSION: u32 = 1;
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, TS,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
#[schemars(transparent)]
|
||||
pub struct ResourceId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
@@ -240,6 +240,7 @@ pub struct RelationshipAxes {
|
||||
}
|
||||
|
||||
impl RelationshipAxes {
|
||||
#[must_use]
|
||||
pub const fn neutral() -> Self {
|
||||
Self {
|
||||
affinity: 50,
|
||||
@@ -566,6 +567,66 @@ pub struct ActionSuggestion {
|
||||
pub draft: String,
|
||||
}
|
||||
|
||||
/// Public scene identity frozen at a committed story node.
|
||||
///
|
||||
/// This is presentation data, not authoritative world state. In particular it
|
||||
/// must never contain unrevealed scene facts or trigger conditions.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
pub struct PresentationScene {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Resolved public visual state for the primary character at a committed node.
|
||||
///
|
||||
/// `expression` and `pose` are the final values after the node's beats have
|
||||
/// played. Persisting the resolved values means restoring a node does not need
|
||||
/// to replay an earlier branch to reconstruct the current portrait.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
pub struct PresentationCharacter {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub expression: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pose: Option<String>,
|
||||
}
|
||||
|
||||
/// Player-authorized presentation output committed with a [`StoryNode`].
|
||||
///
|
||||
/// The snapshot deliberately contains no runtime facts. Exact relationships,
|
||||
/// hidden checks, NPC knowledge and private inventory remain in `RuntimeState`
|
||||
/// and must pass through the normal `PlayerView` projection boundary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
pub struct PresentationSnapshot {
|
||||
#[serde(default)]
|
||||
pub scene: PresentationScene,
|
||||
#[serde(default)]
|
||||
pub character: PresentationCharacter,
|
||||
#[serde(default)]
|
||||
pub beats: Vec<PresentationBeat>,
|
||||
#[serde(default)]
|
||||
pub suggestions: Vec<ActionSuggestion>,
|
||||
#[serde(default = "default_can_continue")]
|
||||
pub can_continue: bool,
|
||||
}
|
||||
|
||||
impl Default for PresentationSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scene: PresentationScene::default(),
|
||||
character: PresentationCharacter::default(),
|
||||
beats: Vec::new(),
|
||||
suggestions: Vec::new(),
|
||||
can_continue: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_can_continue() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
@@ -619,6 +680,10 @@ pub struct PlayerView {
|
||||
pub scene_id: String,
|
||||
pub scene_title: String,
|
||||
pub character_name: String,
|
||||
#[serde(default)]
|
||||
pub character_expression: Option<String>,
|
||||
#[serde(default)]
|
||||
pub character_pose: Option<String>,
|
||||
pub beats: Vec<PresentationBeat>,
|
||||
#[serde(default)]
|
||||
pub suggestions: Vec<ActionSuggestion>,
|
||||
@@ -684,6 +749,7 @@ pub struct TurnFailure {
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
/// An immutable branch event with its state delta and player-facing presentation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
pub struct StoryNode {
|
||||
pub id: String,
|
||||
@@ -692,11 +758,106 @@ pub struct StoryNode {
|
||||
pub parent_id: Option<String>,
|
||||
pub action_id: String,
|
||||
pub user_input: String,
|
||||
pub beats: Vec<PresentationBeat>,
|
||||
/// Flattening keeps the existing top-level `beats` wire field intact while
|
||||
/// grouping all presentation fields into one immutable Rust snapshot.
|
||||
/// Missing fields use defaults, so pre-snapshot saves still deserialize.
|
||||
#[serde(flatten, default)]
|
||||
pub presentation: PresentationSnapshot,
|
||||
pub delta: StateDelta,
|
||||
pub state_hash: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod presentation_snapshot_tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::StoryNode;
|
||||
|
||||
#[test]
|
||||
fn legacy_story_node_beats_deserialize_into_defaulted_snapshot() {
|
||||
let legacy = json!({
|
||||
"id": "node_001",
|
||||
"story_id": "story_demo",
|
||||
"branch_id": "branch_main",
|
||||
"parent_id": null,
|
||||
"action_id": "story_created",
|
||||
"user_input": "",
|
||||
"beats": [{
|
||||
"id": "beat_001",
|
||||
"kind": "dialogue",
|
||||
"speaker": "娜娜",
|
||||
"text": "你来了。",
|
||||
"visual": null
|
||||
}],
|
||||
"delta": { "ops": [] },
|
||||
"state_hash": "sha256:legacy"
|
||||
});
|
||||
|
||||
let node: StoryNode =
|
||||
serde_json::from_value(legacy).expect("legacy StoryNode should remain readable");
|
||||
|
||||
assert_eq!(node.presentation.beats.len(), 1);
|
||||
assert_eq!(node.presentation.beats[0].id, "beat_001");
|
||||
assert!(node.presentation.scene.id.is_empty());
|
||||
assert!(node.presentation.character.expression.is_none());
|
||||
assert!(node.presentation.suggestions.is_empty());
|
||||
assert!(node.presentation.can_continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_serializes_as_stable_story_node_wire_fields() {
|
||||
let node: StoryNode = serde_json::from_value(json!({
|
||||
"id": "node_002",
|
||||
"story_id": "story_demo",
|
||||
"branch_id": "branch_main",
|
||||
"parent_id": "node_001",
|
||||
"action_id": "answer",
|
||||
"user_input": "我回来了。",
|
||||
"scene": { "id": "old_station", "title": "旧青川站" },
|
||||
"character": {
|
||||
"id": "nana",
|
||||
"name": "娜娜",
|
||||
"expression": "relieved",
|
||||
"pose": "holding_coat"
|
||||
},
|
||||
"beats": [],
|
||||
"suggestions": [{
|
||||
"id": "ask",
|
||||
"label": "追问",
|
||||
"draft": "发生了什么?"
|
||||
}],
|
||||
"can_continue": false,
|
||||
"delta": { "ops": [] },
|
||||
"state_hash": "sha256:snapshot"
|
||||
}))
|
||||
.expect("snapshot fixture should deserialize");
|
||||
|
||||
let serialized = serde_json::to_value(&node).expect("StoryNode should serialize");
|
||||
|
||||
assert_eq!(serialized["scene"]["id"], "old_station");
|
||||
assert_eq!(serialized["character"]["expression"], "relieved");
|
||||
assert_eq!(serialized["suggestions"][0]["id"], "ask");
|
||||
assert!(!serialized["can_continue"].as_bool().expect("boolean"));
|
||||
assert_eq!(serialized["presentation"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_presentation_snapshot_fixture_is_readable() {
|
||||
let fixture = include_str!("../../../fixtures/story-nodes/presentation-snapshot.json");
|
||||
let node: StoryNode =
|
||||
serde_json::from_str(fixture).expect("canonical presentation fixture should parse");
|
||||
|
||||
assert_eq!(node.presentation.scene.id, "old_station_platform_rain");
|
||||
assert_eq!(
|
||||
node.presentation.character.expression.as_deref(),
|
||||
Some("relieved")
|
||||
);
|
||||
assert_eq!(node.presentation.beats.len(), 2);
|
||||
assert_eq!(node.presentation.suggestions.len(), 2);
|
||||
assert!(node.presentation.can_continue);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
@@ -759,6 +920,7 @@ pub enum BundleError {
|
||||
Invalid(ValidationReport),
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn validate_bundle(bundle: &ResourceBundle) -> ValidationReport {
|
||||
let mut issues = Vec::new();
|
||||
if bundle.schema_version != DOMAIN_SCHEMA_VERSION {
|
||||
@@ -827,63 +989,67 @@ pub fn validate_bundle(bundle: &ResourceBundle) -> ValidationReport {
|
||||
}
|
||||
}
|
||||
|
||||
validate_resource_collections(bundle, &mut issues);
|
||||
|
||||
ValidationReport { issues }
|
||||
}
|
||||
|
||||
fn validate_resource_collections(bundle: &ResourceBundle, issues: &mut Vec<ValidationIssue>) {
|
||||
for (index, character) in bundle.characters.iter().enumerate() {
|
||||
validate_expected_kind(
|
||||
&character.header,
|
||||
ResourceKind::Character,
|
||||
format!("characters[{index}].header.kind"),
|
||||
&mut issues,
|
||||
&ResourceKind::Character,
|
||||
&format!("characters[{index}].header.kind"),
|
||||
issues,
|
||||
);
|
||||
validate_axes(
|
||||
character.initial_relationship,
|
||||
format!("characters[{index}].initial_relationship"),
|
||||
&mut issues,
|
||||
&format!("characters[{index}].initial_relationship"),
|
||||
issues,
|
||||
);
|
||||
validate_skills(
|
||||
&character.skills,
|
||||
format!("characters[{index}].skills"),
|
||||
&mut issues,
|
||||
&format!("characters[{index}].skills"),
|
||||
issues,
|
||||
);
|
||||
}
|
||||
for (index, world_book) in bundle.world_books.iter().enumerate() {
|
||||
validate_expected_kind(
|
||||
&world_book.header,
|
||||
ResourceKind::WorldBook,
|
||||
format!("world_books[{index}].header.kind"),
|
||||
&mut issues,
|
||||
&ResourceKind::WorldBook,
|
||||
&format!("world_books[{index}].header.kind"),
|
||||
issues,
|
||||
);
|
||||
}
|
||||
for (index, persona) in bundle.personas.iter().enumerate() {
|
||||
validate_expected_kind(
|
||||
&persona.header,
|
||||
ResourceKind::Persona,
|
||||
format!("personas[{index}].header.kind"),
|
||||
&mut issues,
|
||||
&ResourceKind::Persona,
|
||||
&format!("personas[{index}].header.kind"),
|
||||
issues,
|
||||
);
|
||||
validate_skills(
|
||||
&persona.skills,
|
||||
format!("personas[{index}].skills"),
|
||||
&mut issues,
|
||||
&format!("personas[{index}].skills"),
|
||||
issues,
|
||||
);
|
||||
}
|
||||
for (index, plot) in bundle.plot_modules.iter().enumerate() {
|
||||
validate_expected_kind(
|
||||
&plot.header,
|
||||
ResourceKind::PlotModule,
|
||||
format!("plot_modules[{index}].header.kind"),
|
||||
&mut issues,
|
||||
&ResourceKind::PlotModule,
|
||||
&format!("plot_modules[{index}].header.kind"),
|
||||
issues,
|
||||
);
|
||||
}
|
||||
for (index, item) in bundle.item_specs.iter().enumerate() {
|
||||
validate_expected_kind(
|
||||
&item.header,
|
||||
ResourceKind::ItemSpec,
|
||||
format!("item_specs[{index}].header.kind"),
|
||||
&mut issues,
|
||||
&ResourceKind::ItemSpec,
|
||||
&format!("item_specs[{index}].header.kind"),
|
||||
issues,
|
||||
);
|
||||
}
|
||||
|
||||
ValidationReport { issues }
|
||||
}
|
||||
|
||||
pub fn validate_bundle_or_error(bundle: &ResourceBundle) -> Result<(), BundleError> {
|
||||
@@ -916,9 +1082,13 @@ fn bundle_resources(bundle: &ResourceBundle) -> Vec<(String, &dyn Resource)> {
|
||||
.enumerate()
|
||||
.map(|(index, value)| (format!("world_books[{index}]"), value as &dyn Resource)),
|
||||
);
|
||||
resources.extend(bundle.personas.iter().enumerate().map(|(index, value)| {
|
||||
(format!("personas[{index}]"), value as &dyn Resource)
|
||||
}));
|
||||
resources.extend(
|
||||
bundle
|
||||
.personas
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (format!("personas[{index}]"), value as &dyn Resource)),
|
||||
);
|
||||
resources.extend(
|
||||
bundle
|
||||
.plot_modules
|
||||
@@ -969,11 +1139,11 @@ fn validate_header(path: &str, header: &ResourceHeader, issues: &mut Vec<Validat
|
||||
|
||||
fn validate_expected_kind(
|
||||
header: &ResourceHeader,
|
||||
expected: ResourceKind,
|
||||
path: String,
|
||||
expected: &ResourceKind,
|
||||
path: &str,
|
||||
issues: &mut Vec<ValidationIssue>,
|
||||
) {
|
||||
if header.kind != expected {
|
||||
if &header.kind != expected {
|
||||
issues.push(issue(
|
||||
ValidationCode::WrongResourceKind,
|
||||
path,
|
||||
@@ -982,11 +1152,7 @@ fn validate_expected_kind(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_axes(
|
||||
axes: RelationshipAxes,
|
||||
path: String,
|
||||
issues: &mut Vec<ValidationIssue>,
|
||||
) {
|
||||
fn validate_axes(axes: RelationshipAxes, path: &str, issues: &mut Vec<ValidationIssue>) {
|
||||
let values = [
|
||||
("affinity", axes.affinity),
|
||||
("trust", axes.trust),
|
||||
@@ -1006,7 +1172,7 @@ fn validate_axes(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_skills(skills: &[SkillValue], path: String, issues: &mut Vec<ValidationIssue>) {
|
||||
fn validate_skills(skills: &[SkillValue], path: &str, issues: &mut Vec<ValidationIssue>) {
|
||||
for (index, skill) in skills.iter().enumerate() {
|
||||
if skill.value > 100 {
|
||||
issues.push(issue(
|
||||
@@ -1083,7 +1249,11 @@ mod tests {
|
||||
assert!(is_resource_id("nana.item.red_hairpin"));
|
||||
assert!(!is_resource_id("Nana.Character"));
|
||||
assert!(!is_resource_id("nana..item"));
|
||||
let _typed = ResourceId("nana.character.nana".to_owned());
|
||||
let typed = ResourceId("nana.character.nana".to_owned());
|
||||
assert_eq!(
|
||||
serde_json::to_value(typed).expect("serializable resource id"),
|
||||
"nana.character.nana"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user