This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -0,0 +1,935 @@
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<VisualNovelValidationIssue> {
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::<BTreeSet<_>>();
let phase_ids = draft
.story_phases
.iter()
.map(|phase| phase.phase_id.as_str())
.collect::<BTreeSet<_>>();
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<VisualNovelWorkProfile, VisualNovelDomainError> {
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<Vec<VisualNovelRuntimeStep>, VisualNovelDomainError> {
let text =
normalize_required_string(model_output).ok_or(VisualNovelDomainError::InvalidJson)?;
let steps = serde_json::from_str::<Vec<RuntimeStepInput>>(&text)
.or_else(|_| serde_json::from_str::<RuntimeStepsEnvelope>(&text).map(|value| value.steps))
.map_err(|_| VisualNovelDomainError::InvalidJson)?;
let parsed = steps
.into_iter()
.map(RuntimeStepInput::try_into)
.collect::<Result<Vec<_>, _>>()?;
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<VisualNovelRunSnapshot, VisualNovelDomainError> {
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<String, VisualNovelDomainError> {
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<VisualNovelRunSnapshot, VisualNovelDomainError> {
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::<Vec<_>>();
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<String, VisualNovelDomainError> {
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<VisualNovelValidationIssue>,
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<VisualNovelValidationIssue>, 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<RuntimeStepInput>,
}
#[derive(Debug, Deserialize)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
enum RuntimeStepInput {
SceneChange {
scene_id: String,
background_image_src: Option<String>,
music_src: Option<String>,
},
Narration {
text: String,
},
Dialogue {
character_id: String,
character_name: String,
expression: Option<String>,
text: String,
},
Transition {
transition_kind: VisualNovelTransitionKind,
text: Option<String>,
},
Choice {
choices: Vec<VisualNovelChoiceDraft>,
},
Flag {
key: String,
value: VisualNovelFlagValue,
},
Metric {
key: String,
delta: f64,
},
}
impl TryFrom<RuntimeStepInput> for VisualNovelRuntimeStep {
type Error = VisualNovelDomainError;
fn try_from(value: RuntimeStepInput) -> Result<Self, Self::Error> {
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"));
}
}