use std::collections::{BTreeMap, BTreeSet}; use serde::Deserialize; use shared_kernel::{normalize_required_string, normalize_string_list}; use crate::{ VISUAL_NOVEL_MAX_INITIAL_CHOICE_COUNT, VISUAL_NOVEL_MIN_INITIAL_CHOICE_COUNT, VisualNovelCharacterRole, VisualNovelChoiceDraft, VisualNovelDomainError, VisualNovelFlagValue, VisualNovelHistoryEntry, VisualNovelHistorySource, VisualNovelResultDraft, VisualNovelRunSnapshot, VisualNovelRunStatus, VisualNovelRuntimeAction, VisualNovelRuntimeActionKind, VisualNovelRuntimeStep, VisualNovelSaveArchiveState, VisualNovelSceneAvailability, VisualNovelTransitionKind, VisualNovelValidationIssue, VisualNovelValidationSeverity, VisualNovelWorkProfile, }; pub fn validate_visual_novel_draft( draft: &VisualNovelResultDraft, ) -> Vec { let mut issues = Vec::new(); push_missing( &mut issues, "workTitle", &draft.work_title, "作品标题不能为空", ); push_missing( &mut issues, "workDescription", &draft.work_description, "作品简介不能为空", ); push_missing( &mut issues, "world.summary", &draft.world.summary, "世界观摘要不能为空", ); push_missing( &mut issues, "world.playerRole", &draft.world.player_role, "玩家身份不能为空", ); if !draft.characters.iter().any(|character| { matches!( character.role, VisualNovelCharacterRole::Main | VisualNovelCharacterRole::Supporting ) && !character.is_player_visible && normalize_required_string(&character.name).is_some() }) { push_issue( &mut issues, "characters", "MISSING_NON_PLAYER_MAIN_CHARACTER", "至少需要 1 个非玩家主要角色", ); } for character in &draft.characters { if normalize_required_string(&character.character_id).is_none() { push_issue( &mut issues, "characters[].characterId", "MISSING_CHARACTER_ID", "角色 ID 不能为空", ); } if normalize_required_string(&character.name).is_none() { push_issue( &mut issues, "characters[].name", "MISSING_CHARACTER_NAME", "角色名称不能为空", ); } for asset in &character.image_assets { if normalize_required_string(&asset.asset_id).is_none() || normalize_required_string(&asset.image_src).is_none() { push_issue( &mut issues, "characters[].imageAssets", "INVALID_CHARACTER_IMAGE_ASSET", "角色立绘必须使用有效的平台资产引用或受控图片 URL", ); } } } let scene_ids = draft .scenes .iter() .map(|scene| scene.scene_id.as_str()) .collect::>(); let phase_ids = draft .story_phases .iter() .map(|phase| phase.phase_id.as_str()) .collect::>(); if !draft .scenes .iter() .any(|scene| scene.availability == VisualNovelSceneAvailability::Opening) { push_issue( &mut issues, "scenes", "MISSING_OPENING_SCENE", "至少需要 1 个 opening 场景", ); } for scene in &draft.scenes { if normalize_required_string(&scene.scene_id).is_none() { push_issue( &mut issues, "scenes[].sceneId", "MISSING_SCENE_ID", "场景 ID 不能为空", ); } if normalize_required_string(&scene.name).is_none() { push_issue( &mut issues, "scenes[].name", "MISSING_SCENE_NAME", "场景名称不能为空", ); } if scene.availability == VisualNovelSceneAvailability::PhaseLocked && scene.phase_ids.is_empty() { push_issue( &mut issues, "scenes[].phaseIds", "MISSING_PHASE_LOCKED_SCENE_PHASE", "阶段锁定场景必须绑定剧情阶段", ); } for phase_id in &scene.phase_ids { if !phase_ids.contains(phase_id.as_str()) { push_issue( &mut issues, "scenes[].phaseIds", "UNKNOWN_SCENE_PHASE_ID", "场景绑定了不存在的剧情阶段", ); } } } if draft.story_phases.is_empty() { push_issue( &mut issues, "storyPhases", "MISSING_STORY_PHASE", "至少需要 1 个剧情阶段", ); } for phase in &draft.story_phases { if normalize_required_string(&phase.phase_id).is_none() { push_issue( &mut issues, "storyPhases[].phaseId", "MISSING_PHASE_ID", "剧情阶段 ID 不能为空", ); } if normalize_required_string(&phase.title).is_none() { push_issue( &mut issues, "storyPhases[].title", "MISSING_PHASE_TITLE", "剧情阶段标题不能为空", ); } if phase.scene_ids.is_empty() && phase.character_ids.is_empty() { push_issue( &mut issues, "storyPhases[]", "PHASE_WITHOUT_SCENE_OR_CHARACTER", "每个剧情阶段至少绑定一个场景或角色", ); } for scene_id in &phase.scene_ids { if !scene_ids.contains(scene_id.as_str()) { push_issue( &mut issues, "storyPhases[].sceneIds", "UNKNOWN_PHASE_SCENE_ID", "剧情阶段绑定了不存在的场景", ); } } } match draft.opening.scene_id.as_ref() { Some(scene_id) if scene_ids.contains(scene_id.as_str()) => {} _ => push_issue( &mut issues, "opening.sceneId", "INVALID_OPENING_SCENE", "开场场景必须指向有效场景", ), } push_missing( &mut issues, "opening.narration", &draft.opening.narration, "开场旁白不能为空", ); let choice_count = draft.opening.initial_choices.len(); if !(VISUAL_NOVEL_MIN_INITIAL_CHOICE_COUNT..=VISUAL_NOVEL_MAX_INITIAL_CHOICE_COUNT) .contains(&choice_count) { push_issue( &mut issues, "opening.initialChoices", "INVALID_INITIAL_CHOICE_COUNT", "初始选项必须为 2 到 4 个", ); } for choice in &draft.opening.initial_choices { if normalize_required_string(&choice.choice_id).is_none() || normalize_required_string(&choice.text).is_none() { push_issue( &mut issues, "opening.initialChoices[]", "INVALID_INITIAL_CHOICE", "初始选项 ID 和文本不能为空", ); } } issues } pub fn compile_visual_novel_profile( draft: &VisualNovelResultDraft, ) -> Result { let profile_id = normalize_required_string(draft.profile_id.as_deref().unwrap_or("")) .ok_or(VisualNovelDomainError::MissingProfileId)?; let mut normalized_draft = draft.clone(); normalized_draft.work_tags = normalize_string_list(normalized_draft.work_tags); normalized_draft.validation_issues = validate_visual_novel_draft(&normalized_draft); normalized_draft.publish_ready = normalized_draft.validation_issues.is_empty(); Ok(VisualNovelWorkProfile { profile_id, work_title: normalized_draft.work_title.clone(), work_description: normalized_draft.work_description.clone(), work_tags: normalized_draft.work_tags.clone(), cover_image_src: normalized_draft.cover_image_src.clone(), source_mode: normalized_draft.source_mode, draft: normalized_draft, }) } pub fn parse_runtime_steps( model_output: &str, ) -> Result, VisualNovelDomainError> { let text = normalize_required_string(model_output).ok_or(VisualNovelDomainError::InvalidJson)?; let steps = serde_json::from_str::>(&text) .or_else(|_| serde_json::from_str::(&text).map(|value| value.steps)) .map_err(|_| VisualNovelDomainError::InvalidJson)?; let parsed = steps .into_iter() .map(RuntimeStepInput::try_into) .collect::, _>>()?; if parsed.is_empty() { return Err(VisualNovelDomainError::EmptyRuntimeSteps); } Ok(parsed) } pub fn apply_runtime_steps( snapshot: &VisualNovelRunSnapshot, steps: &[VisualNovelRuntimeStep], history_entry_id: &str, created_at: &str, ) -> Result { if steps.is_empty() { return Err(VisualNovelDomainError::EmptyRuntimeSteps); } let history_entry_id = normalize_required_string(history_entry_id) .ok_or(VisualNovelDomainError::InvalidRuntimeStep)?; let created_at = normalize_required_string(created_at).ok_or(VisualNovelDomainError::InvalidRuntimeStep)?; let mut next = snapshot.clone(); let max_history_entries = snapshot.history.len().saturating_add(1); for step in steps { apply_step_to_snapshot(&mut next, step)?; } let turn_index = next .history .last() .map(|entry| entry.turn_index.saturating_add(1)) .unwrap_or(0); next.history.push(VisualNovelHistoryEntry { entry_id: history_entry_id, run_id: next.run_id.clone(), turn_index, source: VisualNovelHistorySource::Assistant, action_text: None, steps: steps.to_vec(), snapshot_before_hash: snapshot.history.last().and_then(|entry| { entry .snapshot_after_hash .clone() .or_else(|| Some(format!("turn-{}", entry.turn_index))) }), snapshot_after_hash: Some(format!("turn-{turn_index}")), created_at: created_at.clone(), }); if next.history.len() > max_history_entries { next.history.remove(0); } next.updated_at = created_at; Ok(next) } pub fn build_runtime_prompt_context( profile: &VisualNovelWorkProfile, snapshot: &VisualNovelRunSnapshot, action: &VisualNovelRuntimeAction, ) -> Result { validate_runtime_action(snapshot, action, &profile.draft.runtime_config)?; let action_text = resolve_action_text(snapshot, action)?; let scene_name = snapshot .current_scene_id .as_ref() .and_then(|scene_id| { profile .draft .scenes .iter() .find(|scene| scene.scene_id == *scene_id) }) .map(|scene| scene.name.as_str()) .unwrap_or("未指定场景"); Ok(format!( "作品:{}\n世界观:{}\n玩家身份:{}\n当前场景:{}\n玩家行动:{}", profile.work_title, profile.draft.world.summary, profile.draft.world.player_role, scene_name, action_text, )) } pub fn regenerate_from_history( snapshot: &VisualNovelRunSnapshot, history_entry_id: &str, allow_history_regeneration: bool, updated_at: &str, ) -> Result { if !allow_history_regeneration { return Err(VisualNovelDomainError::HistoryRegenerationDisabled); } let history_entry_id = normalize_required_string(history_entry_id) .ok_or(VisualNovelDomainError::HistoryEntryNotFound)?; let target_index = snapshot .history .iter() .position(|entry| entry.entry_id == history_entry_id) .ok_or(VisualNovelDomainError::HistoryEntryNotFound)?; if snapshot.history[target_index].source != VisualNovelHistorySource::Assistant { return Err(VisualNovelDomainError::InvalidHistorySource); } let mut next = snapshot.clone(); next.history.truncate(target_index); next.current_scene_id = None; next.visible_character_ids.clear(); next.available_choices.clear(); next.flags.clear(); next.metrics.clear(); let steps_to_restore = next .history .iter() .flat_map(|entry| entry.steps.clone()) .collect::>(); for step in steps_to_restore { apply_step_to_snapshot(&mut next, &step)?; } next.updated_at = updated_at.to_string(); Ok(next) } pub fn build_save_archive_state(snapshot: &VisualNovelRunSnapshot) -> VisualNovelSaveArchiveState { VisualNovelSaveArchiveState { runtime_kind: "visual-novel".to_string(), profile_id: snapshot.profile_id.clone(), run_id: snapshot.run_id.clone(), current_scene_id: snapshot.current_scene_id.clone(), current_phase_id: snapshot.current_phase_id.clone(), history_cursor: snapshot .history .last() .map(|entry| entry.turn_index) .unwrap_or(0), snapshot_hash: snapshot .history .last() .and_then(|entry| entry.snapshot_after_hash.clone()), } } pub fn validate_runtime_action( snapshot: &VisualNovelRunSnapshot, action: &VisualNovelRuntimeAction, config: &crate::VisualNovelRuntimeConfigDraft, ) -> Result<(), VisualNovelDomainError> { if normalize_required_string(&action.client_event_id).is_none() { return Err(VisualNovelDomainError::MissingClientEventId); } match action.action_kind { VisualNovelRuntimeActionKind::Choice => { let choice_id = action .choice_id .as_ref() .and_then(normalize_required_string) .ok_or(VisualNovelDomainError::InvalidChoiceId)?; if snapshot .available_choices .iter() .any(|choice| choice.choice_id == choice_id) { Ok(()) } else { Err(VisualNovelDomainError::InvalidChoiceId) } } VisualNovelRuntimeActionKind::FreeText => { if !config.allow_free_text_action { return Err(VisualNovelDomainError::FreeTextDisabled); } action .text .as_ref() .and_then(normalize_required_string) .map(|_| ()) .ok_or(VisualNovelDomainError::MissingActionText) } VisualNovelRuntimeActionKind::Continue => Ok(()), } } fn apply_step_to_snapshot( snapshot: &mut VisualNovelRunSnapshot, step: &VisualNovelRuntimeStep, ) -> Result<(), VisualNovelDomainError> { match step { VisualNovelRuntimeStep::SceneChange { scene_id, .. } => { let scene_id = normalize_required_string(scene_id) .ok_or(VisualNovelDomainError::InvalidRuntimeStep)?; snapshot.current_scene_id = Some(scene_id); snapshot.visible_character_ids.clear(); } VisualNovelRuntimeStep::Narration { text } => { if normalize_required_string(text).is_none() { return Err(VisualNovelDomainError::InvalidRuntimeStep); } } VisualNovelRuntimeStep::Dialogue { character_id, character_name, text, .. } => { let character_id = normalize_required_string(character_id) .ok_or(VisualNovelDomainError::InvalidRuntimeStep)?; if normalize_required_string(character_name).is_none() || normalize_required_string(text).is_none() { return Err(VisualNovelDomainError::InvalidRuntimeStep); } if !snapshot.visible_character_ids.contains(&character_id) { snapshot.visible_character_ids.push(character_id); } } VisualNovelRuntimeStep::Transition { .. } => {} VisualNovelRuntimeStep::Choice { choices } => { if choices.is_empty() { return Err(VisualNovelDomainError::InvalidRuntimeStep); } for choice in choices { if normalize_required_string(&choice.choice_id).is_none() || normalize_required_string(&choice.text).is_none() { return Err(VisualNovelDomainError::InvalidRuntimeStep); } } snapshot.available_choices = choices.clone(); } VisualNovelRuntimeStep::Flag { key, value } => { let key = normalize_required_string(key).ok_or(VisualNovelDomainError::InvalidRuntimeStep)?; snapshot.flags.insert(key, value.clone()); } VisualNovelRuntimeStep::Metric { key, delta } => { let key = normalize_required_string(key).ok_or(VisualNovelDomainError::InvalidRuntimeStep)?; let entry = snapshot.metrics.entry(key).or_insert(0.0); *entry += *delta; } } Ok(()) } fn resolve_action_text( snapshot: &VisualNovelRunSnapshot, action: &VisualNovelRuntimeAction, ) -> Result { match action.action_kind { VisualNovelRuntimeActionKind::Choice => { let choice_id = action .choice_id .as_ref() .and_then(normalize_required_string) .ok_or(VisualNovelDomainError::InvalidChoiceId)?; snapshot .available_choices .iter() .find(|choice| choice.choice_id == choice_id) .map(|choice| choice.text.clone()) .ok_or(VisualNovelDomainError::InvalidChoiceId) } VisualNovelRuntimeActionKind::FreeText => action .text .as_ref() .and_then(normalize_required_string) .ok_or(VisualNovelDomainError::MissingActionText), VisualNovelRuntimeActionKind::Continue => Ok("继续".to_string()), } } fn push_missing( issues: &mut Vec, path: &str, value: &str, message: &str, ) { if normalize_required_string(value).is_none() { let code = format!("MISSING_{}", path_to_code(path)); push_issue(issues, path, &code, message); } } fn push_issue(issues: &mut Vec, path: &str, code: &str, message: &str) { issues.push(VisualNovelValidationIssue { issue_id: format!("vn-issue-{}", issues.len() + 1), code: code.to_string(), severity: VisualNovelValidationSeverity::Error, path: path.to_string(), message: message.to_string(), }); } fn path_to_code(path: &str) -> String { path.chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_uppercase() } else { '_' } }) .collect() } #[derive(Debug, Deserialize)] struct RuntimeStepsEnvelope { steps: Vec, } #[derive(Debug, Deserialize)] #[serde( tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase" )] enum RuntimeStepInput { SceneChange { scene_id: String, background_image_src: Option, music_src: Option, }, Narration { text: String, }, Dialogue { character_id: String, character_name: String, expression: Option, text: String, }, Transition { transition_kind: VisualNovelTransitionKind, text: Option, }, Choice { choices: Vec, }, Flag { key: String, value: VisualNovelFlagValue, }, Metric { key: String, delta: f64, }, } impl TryFrom for VisualNovelRuntimeStep { type Error = VisualNovelDomainError; fn try_from(value: RuntimeStepInput) -> Result { let step = match value { RuntimeStepInput::SceneChange { scene_id, background_image_src, music_src, } => VisualNovelRuntimeStep::SceneChange { scene_id, background_image_src, music_src, }, RuntimeStepInput::Narration { text } => VisualNovelRuntimeStep::Narration { text }, RuntimeStepInput::Dialogue { character_id, character_name, expression, text, } => VisualNovelRuntimeStep::Dialogue { character_id, character_name, expression, text, }, RuntimeStepInput::Transition { transition_kind, text, } => VisualNovelRuntimeStep::Transition { transition_kind, text, }, RuntimeStepInput::Choice { choices } => VisualNovelRuntimeStep::Choice { choices }, RuntimeStepInput::Flag { key, value } => VisualNovelRuntimeStep::Flag { key, value }, RuntimeStepInput::Metric { key, delta } => { VisualNovelRuntimeStep::Metric { key, delta } } }; let mut probe = empty_run_for_validation(); apply_step_to_snapshot(&mut probe, &step)?; Ok(step) } } fn empty_run_for_validation() -> VisualNovelRunSnapshot { VisualNovelRunSnapshot { run_id: "vn-run-validation".to_string(), owner_user_id: "user-validation".to_string(), profile_id: "vn-profile-validation".to_string(), mode: crate::VisualNovelRunMode::Test, status: VisualNovelRunStatus::Active, current_scene_id: None, current_phase_id: None, visible_character_ids: Vec::new(), flags: BTreeMap::new(), metrics: BTreeMap::new(), history: Vec::new(), available_choices: Vec::new(), text_mode_enabled: true, created_at: "2026-05-05T00:00:00Z".to_string(), updated_at: "2026-05-05T00:00:00Z".to_string(), } } #[cfg(test)] mod tests { use super::*; use crate::{ VisualNovelAttributePanelMode, VisualNovelCharacterDraft, VisualNovelOpeningDraft, VisualNovelRunMode, VisualNovelRuntimeConfigDraft, VisualNovelSceneDraft, VisualNovelSourceMode, VisualNovelStoryPhaseDraft, VisualNovelWorldDraft, }; fn valid_draft() -> VisualNovelResultDraft { VisualNovelResultDraft { profile_id: Some("vn-profile-1".to_string()), work_title: "雨夜书店".to_string(), work_description: "在午夜书店里找回名字的视觉小说。".to_string(), work_tags: vec!["悬疑".to_string(), "都市奇谈".to_string()], cover_image_src: None, source_mode: VisualNovelSourceMode::Idea, source_asset_ids: Vec::new(), world: VisualNovelWorldDraft { title: "雨夜书店".to_string(), summary: "主角在雨夜进入一间只在午夜出现的书店。".to_string(), background: "旧街区里隐藏着交换记忆的书店。".to_string(), premise: "找回遗失的名字。".to_string(), literary_style: "细腻、轻悬疑".to_string(), player_role: "误入书店的读者".to_string(), default_tone: "克制而温柔".to_string(), }, characters: vec![VisualNovelCharacterDraft { character_id: "char-clerk".to_string(), name: "店员".to_string(), gender: None, role: VisualNovelCharacterRole::Main, appearance: "银灰色长发,黑色围裙。".to_string(), personality: "温和但回避关键问题。".to_string(), tone: "轻声慢语。".to_string(), background: "守着午夜书店的人。".to_string(), relationship_to_player: "向玩家递出第一本书。".to_string(), image_assets: Vec::new(), default_expression: None, is_player_visible: false, }], scenes: vec![VisualNovelSceneDraft { scene_id: "scene-bookstore".to_string(), name: "午夜书店".to_string(), description: "暖灯、旧木地板和窗外雨声。".to_string(), background_image_src: None, music_src: None, ambient_sound_src: None, availability: VisualNovelSceneAvailability::Opening, phase_ids: vec!["phase-opening".to_string()], }], story_phases: vec![VisualNovelStoryPhaseDraft { phase_id: "phase-opening".to_string(), title: "进入书店".to_string(), goal: "理解书店规则。".to_string(), summary: "玩家第一次见到店员。".to_string(), entry_condition: "开场".to_string(), exit_condition: "选择第一本书".to_string(), scene_ids: vec!["scene-bookstore".to_string()], character_ids: vec!["char-clerk".to_string()], suggested_choices: vec!["询问店员".to_string()], }], opening: VisualNovelOpeningDraft { scene_id: Some("scene-bookstore".to_string()), narration: "门铃响起,雨声被关在门外。".to_string(), speaker_character_id: Some("char-clerk".to_string()), first_dialogue: Some("欢迎回来。".to_string()), initial_choices: vec![ VisualNovelChoiceDraft { choice_id: "choice-ask".to_string(), text: "询问店员为什么说回来".to_string(), action_hint: None, }, VisualNovelChoiceDraft { choice_id: "choice-look".to_string(), text: "环顾书店".to_string(), action_hint: None, }, ], }, runtime_config: VisualNovelRuntimeConfigDraft { text_mode_enabled: true, default_text_mode: false, max_history_entries: 80, max_assistant_step_count_per_turn: 8, allow_free_text_action: true, allow_history_regeneration: true, attribute_panel_mode: VisualNovelAttributePanelMode::Off, save_archive_enabled: true, }, publish_ready: false, validation_issues: Vec::new(), updated_at: "2026-05-05T00:00:00Z".to_string(), } } fn empty_run() -> VisualNovelRunSnapshot { VisualNovelRunSnapshot { run_id: "vn-run-1".to_string(), owner_user_id: "user-1".to_string(), profile_id: "vn-profile-1".to_string(), mode: VisualNovelRunMode::Test, status: VisualNovelRunStatus::Active, current_scene_id: None, current_phase_id: None, visible_character_ids: Vec::new(), flags: BTreeMap::new(), metrics: BTreeMap::new(), history: Vec::new(), available_choices: Vec::new(), text_mode_enabled: true, created_at: "2026-05-05T00:00:00Z".to_string(), updated_at: "2026-05-05T00:00:00Z".to_string(), } } #[test] fn publish_validation_catches_missing_opening_requirements() { let mut draft = valid_draft(); draft.opening.scene_id = Some("missing-scene".to_string()); draft.opening.initial_choices = vec![VisualNovelChoiceDraft { choice_id: "only-one".to_string(), text: "只有一个选项".to_string(), action_hint: None, }]; let issues = validate_visual_novel_draft(&draft); assert!( issues .iter() .any(|issue| issue.code == "INVALID_OPENING_SCENE") ); assert!( issues .iter() .any(|issue| issue.code == "INVALID_INITIAL_CHOICE_COUNT") ); } #[test] fn valid_draft_compiles_to_profile() { let profile = compile_visual_novel_profile(&valid_draft()).expect("profile"); assert_eq!(profile.profile_id, "vn-profile-1"); assert!(profile.draft.publish_ready); assert!(profile.draft.validation_issues.is_empty()); } #[test] fn step_parser_rejects_empty_dialogue_text() { let error = parse_runtime_steps( r#"[{"type":"dialogue","characterId":"char-a","characterName":"A","text":" "}]"#, ) .expect_err("blank dialogue text should fail"); assert_eq!(error, VisualNovelDomainError::InvalidRuntimeStep); } #[test] fn apply_runtime_steps_advances_scene_character_choices_and_metrics() { let steps = parse_runtime_steps( r#"{ "steps": [ {"type":"scene_change","sceneId":"scene-bookstore","backgroundImageSrc":null,"musicSrc":null}, {"type":"dialogue","characterId":"char-clerk","characterName":"店员","expression":null,"text":"欢迎回来。"}, {"type":"choice","choices":[{"choiceId":"choice-ask","text":"询问","actionHint":null}]}, {"type":"flag","key":"metClerk","value":true}, {"type":"metric","key":"curiosity","delta":2} ] }"#, ) .expect("steps"); let next = apply_runtime_steps(&empty_run(), &steps, "vn-history-1", "2026-05-05T00:00:01Z") .expect("run"); assert_eq!(next.current_scene_id.as_deref(), Some("scene-bookstore")); assert_eq!(next.visible_character_ids, vec!["char-clerk".to_string()]); assert_eq!(next.available_choices[0].choice_id, "choice-ask"); assert_eq!( next.flags.get("metClerk"), Some(&VisualNovelFlagValue::Bool(true)) ); assert_eq!(next.metrics.get("curiosity"), Some(&2.0)); assert_eq!(next.history.len(), 1); } #[test] fn regeneration_truncates_after_assistant_history_node() { let first = apply_runtime_steps( &empty_run(), &[VisualNovelRuntimeStep::SceneChange { scene_id: "scene-a".to_string(), background_image_src: None, music_src: None, }], "vn-history-1", "2026-05-05T00:00:01Z", ) .expect("first"); let second = apply_runtime_steps( &first, &[VisualNovelRuntimeStep::SceneChange { scene_id: "scene-b".to_string(), background_image_src: None, music_src: None, }], "vn-history-2", "2026-05-05T00:00:02Z", ) .expect("second"); let regenerated = regenerate_from_history(&second, "vn-history-2", true, "2026-05-05T00:00:03Z") .expect("regenerated"); assert_eq!(regenerated.history.len(), 1); assert_eq!(regenerated.current_scene_id.as_deref(), Some("scene-a")); } #[test] fn save_archive_state_uses_platform_runtime_kind() { let run = apply_runtime_steps( &empty_run(), &[VisualNovelRuntimeStep::SceneChange { scene_id: "scene-bookstore".to_string(), background_image_src: None, music_src: None, }], "vn-history-1", "2026-05-05T00:00:01Z", ) .expect("run"); let archive_state = build_save_archive_state(&run); assert_eq!(archive_state.runtime_kind, "visual-novel"); assert_eq!(archive_state.profile_id, "vn-profile-1"); assert_eq!(archive_state.run_id, "vn-run-1"); assert_eq!( archive_state.current_scene_id.as_deref(), Some("scene-bookstore") ); assert_eq!(archive_state.history_cursor, 0); assert_eq!(archive_state.snapshot_hash.as_deref(), Some("turn-0")); } }