936 lines
32 KiB
Rust
936 lines
32 KiB
Rust
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"));
|
|
}
|
|
}
|