1
This commit is contained in:
15
server-rs/crates/module-visual-novel/Cargo.toml
Normal file
15
server-rs/crates/module-visual-novel/Cargo.toml
Normal 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 }
|
||||
935
server-rs/crates/module-visual-novel/src/application.rs
Normal file
935
server-rs/crates/module-visual-novel/src/application.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
390
server-rs/crates/module-visual-novel/src/domain.rs
Normal file
390
server-rs/crates/module-visual-novel/src/domain.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
41
server-rs/crates/module-visual-novel/src/errors.rs
Normal file
41
server-rs/crates/module-visual-novel/src/errors.rs
Normal 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 {}
|
||||
7
server-rs/crates/module-visual-novel/src/lib.rs
Normal file
7
server-rs/crates/module-visual-novel/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod application;
|
||||
mod domain;
|
||||
mod errors;
|
||||
|
||||
pub use application::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
Reference in New Issue
Block a user