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
+208 -38
View File
@@ -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]