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,15 @@
[package]
name = "module-visual-novel"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

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"));
}
}

View File

@@ -0,0 +1,390 @@
//! 视觉小说模板的纯领域模型。
//!
//! 本 crate 只负责草稿校验、运行时 step 解析、状态推进、历史重生成边界
//! 和平台统一存档状态构造HTTP、SpacetimeDB、LLM、OSS 均由外层 adapter 处理。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use std::collections::BTreeMap;
pub const VISUAL_NOVEL_PROFILE_ID_PREFIX: &str = "vn-profile-";
pub const VISUAL_NOVEL_RUN_ID_PREFIX: &str = "vn-run-";
pub const VISUAL_NOVEL_HISTORY_ID_PREFIX: &str = "vn-history-";
pub const VISUAL_NOVEL_DEFAULT_MAX_HISTORY_ENTRIES: u32 = 80;
pub const VISUAL_NOVEL_DEFAULT_MAX_ASSISTANT_STEP_COUNT_PER_TURN: u32 = 8;
pub const VISUAL_NOVEL_MIN_INITIAL_CHOICE_COUNT: usize = 2;
pub const VISUAL_NOVEL_MAX_INITIAL_CHOICE_COUNT: usize = 4;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelSourceMode {
Idea,
Document,
Blank,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelCharacterRole {
Protagonist,
Main,
Supporting,
Antagonist,
Background,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelAssetSource {
PlatformAsset,
Generated,
External,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelSceneAvailability {
Opening,
Always,
PhaseLocked,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelAttributePanelMode {
Off,
PlatformWhitelist,
TemplateConfig,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelValidationSeverity {
Error,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelRunMode {
Test,
Play,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelRunStatus {
Active,
Completed,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelRuntimeActionKind {
Choice,
FreeText,
Continue,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelTransitionKind {
Fade,
Cut,
Flash,
None,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelHistorySource {
Player,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum VisualNovelFlagValue {
String(String),
Number(f64),
Bool(bool),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelValidationIssue {
pub issue_id: String,
pub code: String,
pub severity: VisualNovelValidationSeverity,
pub path: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelChoiceDraft {
pub choice_id: String,
pub text: String,
pub action_hint: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelCharacterImageAsset {
pub asset_id: String,
pub image_src: String,
pub expression: Option<String>,
pub source: VisualNovelAssetSource,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelWorldDraft {
pub title: String,
pub summary: String,
pub background: String,
pub premise: String,
pub literary_style: String,
pub player_role: String,
pub default_tone: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelCharacterDraft {
pub character_id: String,
pub name: String,
pub gender: Option<String>,
pub role: VisualNovelCharacterRole,
pub appearance: String,
pub personality: String,
pub tone: String,
pub background: String,
pub relationship_to_player: String,
pub image_assets: Vec<VisualNovelCharacterImageAsset>,
pub default_expression: Option<String>,
pub is_player_visible: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelSceneDraft {
pub scene_id: String,
pub name: String,
pub description: String,
pub background_image_src: Option<String>,
pub music_src: Option<String>,
pub ambient_sound_src: Option<String>,
pub availability: VisualNovelSceneAvailability,
pub phase_ids: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelStoryPhaseDraft {
pub phase_id: String,
pub title: String,
pub goal: String,
pub summary: String,
pub entry_condition: String,
pub exit_condition: String,
pub scene_ids: Vec<String>,
pub character_ids: Vec<String>,
pub suggested_choices: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelOpeningDraft {
pub scene_id: Option<String>,
pub narration: String,
pub speaker_character_id: Option<String>,
pub first_dialogue: Option<String>,
pub initial_choices: Vec<VisualNovelChoiceDraft>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeConfigDraft {
pub text_mode_enabled: bool,
pub default_text_mode: bool,
pub max_history_entries: u32,
pub max_assistant_step_count_per_turn: u32,
pub allow_free_text_action: bool,
pub allow_history_regeneration: bool,
pub attribute_panel_mode: VisualNovelAttributePanelMode,
pub save_archive_enabled: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelResultDraft {
pub profile_id: Option<String>,
pub work_title: String,
pub work_description: String,
pub work_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_mode: VisualNovelSourceMode,
pub source_asset_ids: Vec<String>,
pub world: VisualNovelWorldDraft,
pub characters: Vec<VisualNovelCharacterDraft>,
pub scenes: Vec<VisualNovelSceneDraft>,
pub story_phases: Vec<VisualNovelStoryPhaseDraft>,
pub opening: VisualNovelOpeningDraft,
pub runtime_config: VisualNovelRuntimeConfigDraft,
pub publish_ready: bool,
pub validation_issues: Vec<VisualNovelValidationIssue>,
pub updated_at: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelWorkProfile {
pub profile_id: String,
pub work_title: String,
pub work_description: String,
pub work_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_mode: VisualNovelSourceMode,
pub draft: VisualNovelResultDraft,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
pub enum VisualNovelRuntimeStep {
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,
},
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelHistoryEntry {
pub entry_id: String,
pub run_id: String,
pub turn_index: u32,
pub source: VisualNovelHistorySource,
pub action_text: Option<String>,
pub steps: Vec<VisualNovelRuntimeStep>,
pub snapshot_before_hash: Option<String>,
pub snapshot_after_hash: Option<String>,
pub created_at: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRunSnapshot {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub mode: VisualNovelRunMode,
pub status: VisualNovelRunStatus,
pub current_scene_id: Option<String>,
pub current_phase_id: Option<String>,
pub visible_character_ids: Vec<String>,
pub flags: BTreeMap<String, VisualNovelFlagValue>,
pub metrics: BTreeMap<String, f64>,
pub history: Vec<VisualNovelHistoryEntry>,
pub available_choices: Vec<VisualNovelChoiceDraft>,
pub text_mode_enabled: bool,
pub created_at: String,
pub updated_at: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeAction {
pub action_kind: VisualNovelRuntimeActionKind,
pub choice_id: Option<String>,
pub text: Option<String>,
pub client_event_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelSaveArchiveState {
pub runtime_kind: String,
pub profile_id: String,
pub run_id: String,
pub current_scene_id: Option<String>,
pub current_phase_id: Option<String>,
pub history_cursor: u32,
pub snapshot_hash: Option<String>,
}
impl Default for VisualNovelRuntimeConfigDraft {
fn default() -> Self {
Self {
text_mode_enabled: true,
default_text_mode: false,
max_history_entries: VISUAL_NOVEL_DEFAULT_MAX_HISTORY_ENTRIES,
max_assistant_step_count_per_turn:
VISUAL_NOVEL_DEFAULT_MAX_ASSISTANT_STEP_COUNT_PER_TURN,
allow_free_text_action: true,
allow_history_regeneration: true,
attribute_panel_mode: VisualNovelAttributePanelMode::Off,
save_archive_enabled: true,
}
}
}

View File

@@ -0,0 +1,41 @@
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VisualNovelDomainError {
MissingProfileId,
MissingRunId,
MissingOwnerUserId,
MissingClientEventId,
MissingActionText,
InvalidChoiceId,
FreeTextDisabled,
HistoryRegenerationDisabled,
HistoryEntryNotFound,
InvalidHistorySource,
InvalidRuntimeStep,
InvalidJson,
EmptyRuntimeSteps,
}
impl fmt::Display for VisualNovelDomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingProfileId => "visual novel profile_id 缺失",
Self::MissingRunId => "visual novel run_id 缺失",
Self::MissingOwnerUserId => "visual novel owner_user_id 缺失",
Self::MissingClientEventId => "visual novel client_event_id 缺失",
Self::MissingActionText => "visual novel 行动文本缺失",
Self::InvalidChoiceId => "visual novel choice_id 不合法",
Self::FreeTextDisabled => "visual novel 当前作品未开启自由行动",
Self::HistoryRegenerationDisabled => "visual novel 当前作品未开启历史重生成",
Self::HistoryEntryNotFound => "visual novel history entry 不存在",
Self::InvalidHistorySource => "visual novel 只能从助手历史节点重生成",
Self::InvalidRuntimeStep => "visual novel runtime step 不合法",
Self::InvalidJson => "visual novel JSON 解析失败",
Self::EmptyRuntimeSteps => "visual novel runtime step 不能为空",
};
write!(f, "{message}")
}
}
impl Error for VisualNovelDomainError {}

View File

@@ -0,0 +1,7 @@
mod application;
mod domain;
mod errors;
pub use application::*;
pub use domain::*;
pub use errors::*;