Files
Genarrative/server-rs/crates/shared-contracts/src/creation_entry_config.rs

471 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
/// 前台创作入口配置响应。
///
/// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取
/// `event_banners`,由后台表单配置多条公告并支持轮播。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryConfigResponse {
pub start_card: CreationEntryStartCardResponse,
pub type_modal: CreationEntryTypeModalResponse,
pub event_banner: CreationEntryEventBannerResponse,
pub event_banners: Vec<CreationEntryEventBannerResponse>,
pub creation_types: Vec<CreationEntryTypeResponse>,
}
/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryStartCardResponse {
pub title: String,
pub description: String,
pub idle_badge: String,
pub busy_badge: String,
}
/// 创作类型选择弹层的基础文案契约。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryTypeModalResponse {
pub title: String,
pub description: String,
}
/// 创作入口单条公告。
///
/// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示;
/// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryEventBannerResponse {
pub title: String,
pub description: String,
pub cover_image_src: String,
pub prize_pool_mud_points: u64,
pub starts_at_text: String,
pub ends_at_text: String,
#[serde(default = "default_creation_entry_event_banner_render_mode")]
pub render_mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_code: Option<String>,
}
/// 默认渲染模式使用受控结构化 UI用于旧数据兼容。
pub fn default_creation_entry_event_banner_render_mode() -> String {
"structured".to_string()
}
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryTypeResponse {
pub id: String,
pub title: String,
pub subtitle: String,
pub badge: String,
pub image_src: String,
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
}
/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationSpecResponse {
pub play_id: String,
pub title: String,
pub workspace_stage: String,
pub generation_stage: String,
pub result_stage: String,
pub fields: Vec<UnifiedCreationFieldResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationFieldResponse {
pub id: String,
pub kind: String,
pub label: String,
pub required: bool,
}
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
"rpg" => (
"agent-workspace",
"custom-world-generating",
"custom-world-result",
vec![unified_creation_field("message", "text", "创作想法", true)],
),
"big-fish" => (
"big-fish-agent-workspace",
"big-fish-generating",
"big-fish-result",
vec![unified_creation_field("message", "text", "玩法想法", true)],
),
"puzzle" => (
"puzzle-agent-workspace",
"puzzle-generating",
"puzzle-result",
vec![
unified_creation_field("pictureDescription", "text", "画面描述", true),
unified_creation_field("referenceImage", "image", "拼图画面", false),
unified_creation_field("promptReferenceImages", "image", "参考图", false),
],
),
"match3d" => (
"match3d-agent-workspace",
"match3d-generating",
"match3d-result",
vec![
unified_creation_field("themeText", "text", "题材", true),
unified_creation_field("difficulty", "select", "难度", true),
],
),
"jump-hop" => (
"jump-hop-workspace",
"jump-hop-generating",
"jump-hop-result",
vec![unified_creation_field("themeText", "text", "主题", true)],
),
"wooden-fish" => (
"wooden-fish-workspace",
"wooden-fish-generating",
"wooden-fish-result",
vec![
unified_creation_field("hitObjectPrompt", "text", "敲什么", false),
unified_creation_field("hitObjectReferenceImage", "image", "参考图", false),
unified_creation_field("hitSoundAsset", "audio", "敲击音效", false),
unified_creation_field("floatingWords", "text", "功德有什么", true),
],
),
"square-hole" => (
"square-hole-agent-workspace",
"square-hole-generating",
"square-hole-result",
vec![unified_creation_field("message", "text", "玩法想法", true)],
),
"bark-battle" => (
"bark-battle-workspace",
"bark-battle-generating",
"bark-battle-result",
vec![
unified_creation_field("title", "text", "作品标题", true),
unified_creation_field("themeDescription", "text", "主题/场景描述", true),
unified_creation_field("playerImageDescription", "text", "玩家形象描述", true),
unified_creation_field("opponentImageDescription", "text", "对手形象描述", true),
unified_creation_field("onomatopoeia", "text", "拟声词", false),
unified_creation_field("difficultyPreset", "select", "难度", true),
],
),
"visual-novel" => (
"visual-novel-agent-workspace",
"visual-novel-generating",
"visual-novel-result",
vec![
unified_creation_field("ideaText", "text", "一句话创作", true),
unified_creation_field("visualStyleId", "select", "视觉画风", true),
],
),
"baby-object-match" => (
"baby-object-match-workspace",
"baby-object-match-generating",
"baby-object-match-result",
vec![
unified_creation_field("itemAName", "text", "物品 A", true),
unified_creation_field("itemBName", "text", "物品 B", true),
],
),
"creative-agent" => (
"creative-agent-workspace",
"puzzle-generating",
"puzzle-result",
vec![
unified_creation_field("message", "text", "创作想法", true),
unified_creation_field("referenceImage", "image", "参考图", false),
],
),
_ => return None,
};
Some(UnifiedCreationSpecResponse {
play_id: play_id.to_string(),
title: default_unified_creation_title(play_id)?.to_string(),
workspace_stage: workspace_stage.to_string(),
generation_stage: generation_stage.to_string(),
result_stage: result_stage.to_string(),
fields,
})
}
pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> {
match play_id {
"rpg" => Some("文字冒险"),
"big-fish" => Some("摸鱼"),
"puzzle" => Some("拼图"),
"match3d" => Some("抓大鹅"),
"jump-hop" => Some("跳一跳"),
"wooden-fish" => Some("敲木鱼"),
"square-hole" => Some("方洞"),
"bark-battle" => Some("汪汪声浪"),
"visual-novel" => Some("视觉小说"),
"baby-object-match" => Some("宝贝识物"),
"creative-agent" => Some("智能体创作"),
_ => None,
}
}
pub fn validate_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> {
if spec.play_id.trim().is_empty() {
return Err("统一创作契约 playId 不能为空".to_string());
}
if spec.title.trim().is_empty() {
return Err("统一创作契约标题不能为空".to_string());
}
let workspace_stage = spec.workspace_stage.trim();
let generation_stage = spec.generation_stage.trim();
let result_stage = spec.result_stage.trim();
if workspace_stage.is_empty() || generation_stage.is_empty() || result_stage.is_empty() {
return Err("统一创作契约阶段不能为空".to_string());
}
if workspace_stage == generation_stage
|| workspace_stage == result_stage
|| generation_stage == result_stage
{
return Err("统一创作契约阶段不能重复".to_string());
}
if spec.fields.is_empty() {
return Err("统一创作契约 fields 不能为空".to_string());
}
let mut field_ids = BTreeSet::new();
for field in &spec.fields {
let field_id = field.id.trim();
if field_id.is_empty() {
return Err("统一创作契约字段 id 不能为空".to_string());
}
if !field_ids.insert(field_id.to_string()) {
return Err(format!("统一创作契约字段 id 重复:{field_id}"));
}
if field.label.trim().is_empty() {
return Err(format!("统一创作契约字段 {field_id} 标签不能为空"));
}
if !UNIFIED_CREATION_FIELD_KINDS.contains(&field.kind.trim()) {
return Err(format!(
"统一创作契约字段 {field_id} kind 非法:{}",
field.kind
));
}
}
Ok(())
}
pub fn validate_unified_creation_spec_for_play(
play_id: &str,
spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> {
if spec.play_id.trim() != play_id.trim() {
return Err(format!(
"统一创作契约 playId 必须与入口 ID 一致:{}",
play_id.trim()
));
}
validate_unified_creation_spec_response(spec)
}
pub fn encode_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse,
) -> Result<String, String> {
validate_unified_creation_spec_response(spec)?;
serde_json::to_string(spec).map_err(|error| format!("统一创作契约序列化失败:{error}"))
}
pub fn decode_unified_creation_spec_response(
value: &str,
) -> Result<UnifiedCreationSpecResponse, String> {
let spec = serde_json::from_str::<UnifiedCreationSpecResponse>(value)
.map_err(|error| format!("统一创作契约 JSON 非法:{error}"))?;
validate_unified_creation_spec_response(&spec)?;
Ok(spec)
}
pub fn resolve_unified_creation_spec_response(
play_id: &str,
value: Option<&str>,
) -> Option<UnifiedCreationSpecResponse> {
match value {
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
None => build_phase1_unified_creation_spec(play_id),
}
}
fn unified_creation_field(
id: &str,
kind: &str,
label: &str,
required: bool,
) -> UnifiedCreationFieldResponse {
UnifiedCreationFieldResponse {
id: id.to_string(),
kind: kind.to_string(),
label: label.to_string(),
required,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase1_unified_creation_specs_cover_existing_templates() {
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
assert_eq!(puzzle.title, "拼图");
assert_eq!(puzzle.fields[0].id, "pictureDescription");
assert_eq!(puzzle.fields[1].kind, "image");
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
assert_eq!(match3d.title, "抓大鹅");
assert_eq!(
match3d
.fields
.iter()
.filter(|field| field.kind == "select")
.count(),
1
);
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
assert_eq!(jump_hop.title, "跳一跳");
assert_eq!(jump_hop.fields.len(), 1);
assert_eq!(jump_hop.fields[0].id, "themeText");
let wooden_fish =
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio"));
let visual_novel =
build_phase1_unified_creation_spec("visual-novel").expect("visual-novel spec");
assert_eq!(visual_novel.workspace_stage, "visual-novel-agent-workspace");
let bark_battle =
build_phase1_unified_creation_spec("bark-battle").expect("bark-battle spec");
assert_eq!(bark_battle.generation_stage, "bark-battle-generating");
let baby_object_match = build_phase1_unified_creation_spec("baby-object-match")
.expect("baby-object-match spec");
assert_eq!(
baby_object_match
.fields
.iter()
.filter(|field| field.kind == "text")
.count(),
2
);
}
#[test]
fn unified_creation_spec_title_uses_contract_content() {
let raw = r#"{
"playId": "puzzle",
"title": "想做个什么玩法?",
"workspaceStage": "puzzle-agent-workspace",
"generationStage": "puzzle-generating",
"resultStage": "puzzle-result",
"fields": [
{
"id": "pictureDescription",
"kind": "text",
"label": "画面描述",
"required": true
}
]
}"#;
let spec =
resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec");
assert_eq!(spec.title, "想做个什么玩法?");
}
#[test]
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
r#"{
"title": "旧版横幅",
"description": "兼容旧字段",
"coverImageSrc": "/creation-type-references/puzzle.webp",
"prizePoolMudPoints": 1000,
"startsAtText": "2026-06-01",
"endsAtText": "2026-06-30"
}"#,
)
.expect("legacy banner json should decode");
assert_eq!(banner.render_mode, "structured");
assert!(banner.html_code.is_none());
}
#[test]
fn creation_entry_config_serializes_event_banners_contract() {
let response = CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
title: "新建作品".to_string(),
description: "选择模板".to_string(),
idle_badge: "模板".to_string(),
busy_badge: "开启中".to_string(),
},
type_modal: CreationEntryTypeModalResponse {
title: "选择创作类型".to_string(),
description: "先选玩法".to_string(),
},
event_banner: CreationEntryEventBannerResponse {
title: "第一条".to_string(),
description: "兼容单条".to_string(),
cover_image_src: "/creation-type-references/puzzle.webp".to_string(),
prize_pool_mud_points: 1000,
starts_at_text: "2026-06-01".to_string(),
ends_at_text: "2026-06-30".to_string(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners: vec![CreationEntryEventBannerResponse {
title: "HTML 条".to_string(),
description: "沙箱".to_string(),
cover_image_src: "/creation-type-references/match3d.webp".to_string(),
prize_pool_mud_points: 800,
starts_at_text: "2026-07-01".to_string(),
ends_at_text: "2026-07-31".to_string(),
render_mode: "html".to_string(),
html_code: Some("<section>ok</section>".to_string()),
}],
creation_types: Vec::new(),
};
let value = serde_json::to_value(response).expect("response should serialize");
assert_eq!(value["eventBanners"][0]["renderMode"], "html");
assert_eq!(
value["eventBanners"][0]["htmlCode"],
"<section>ok</section>"
);
assert!(value.get("event_banner").is_none());
assert!(value.get("eventBanner").is_some());
}
}