Merge remote-tracking branch 'origin/codex/wooden-fish-template'

This commit is contained in:
kdletters
2026-05-22 08:09:58 +08:00
617 changed files with 31612 additions and 237 deletions

View File

@@ -61,6 +61,7 @@ pub enum CreationAudioStoragePrefix {
PuzzleAssets,
#[serde(rename = "match3d_assets")]
Match3DAssets,
WoodenFishAssets,
CustomWorldScenes,
}
@@ -125,4 +126,20 @@ mod tests {
assert_eq!(payload["taskId"], json!("task-1"));
assert_eq!(payload["audioSrc"], json!("/generated-puzzle-assets/a.mp3"));
}
#[test]
fn creation_audio_contracts_support_wooden_fish_storage_prefix() {
let request = PublishGeneratedAudioAssetRequest {
entity_kind: "wooden_fish_work".to_string(),
entity_id: "wooden-fish-profile-1".to_string(),
slot: "hit_sound".to_string(),
asset_kind: "wooden_fish_hit_sound".to_string(),
profile_id: Some("wooden-fish-profile-1".to_string()),
storage_prefix: Some(CreationAudioStoragePrefix::WoodenFishAssets),
};
let payload = serde_json::to_value(request).expect("request should serialize");
assert_eq!(payload["storagePrefix"], json!("wooden_fish_assets"));
}
}

View File

@@ -29,3 +29,4 @@ pub mod square_hole_runtime;
pub mod square_hole_works;
pub mod story;
pub mod visual_novel;
pub mod wooden_fish;

View File

@@ -0,0 +1,507 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum WoodenFishGenerationStatus {
Draft,
Generating,
Ready,
Failed,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum WoodenFishActionType {
CompileDraft,
RegenerateHitObject,
GenerateHitSound,
ReplaceHitSound,
UpdateWorkMeta,
UpdateFloatingWords,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum WoodenFishRunStatus {
Playing,
Finished,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishImageAsset {
pub asset_id: String,
pub image_src: String,
pub image_object_key: String,
pub asset_object_id: String,
pub generation_provider: String,
pub prompt: String,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishAudioAsset {
pub asset_id: String,
pub audio_src: String,
pub audio_object_key: String,
pub asset_object_id: String,
pub source: String,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub duration_ms: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishWorkspaceCreateRequest {
pub template_id: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
pub hit_object_prompt: String,
#[serde(default)]
pub hit_object_reference_image_src: Option<String>,
#[serde(default)]
pub hit_sound_prompt: Option<String>,
#[serde(default)]
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
pub floating_words: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishActionRequest {
pub action_type: WoodenFishActionType,
#[serde(default, skip_deserializing)]
pub profile_id: Option<String>,
#[serde(default)]
pub work_title: Option<String>,
#[serde(default)]
pub work_description: Option<String>,
#[serde(default)]
pub theme_tags: Option<Vec<String>>,
#[serde(default)]
pub hit_object_prompt: Option<String>,
#[serde(default)]
pub hit_object_reference_image_src: Option<String>,
#[serde(default, skip_deserializing)]
pub hit_object_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
#[serde(skip_deserializing)]
pub background_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
pub hit_sound_prompt: Option<String>,
#[serde(default)]
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
#[serde(default)]
pub floating_words: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishWordCounter {
pub text: String,
pub count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishDraftResponse {
pub template_id: String,
pub template_name: String,
#[serde(default)]
pub profile_id: Option<String>,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
pub hit_object_prompt: String,
#[serde(default)]
pub hit_object_reference_image_src: Option<String>,
#[serde(default)]
pub hit_sound_prompt: Option<String>,
pub floating_words: Vec<String>,
#[serde(default)]
pub hit_object_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
pub background_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
#[serde(default)]
pub cover_image_src: Option<String>,
pub generation_status: WoodenFishGenerationStatus,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishSessionSnapshotResponse {
pub session_id: String,
pub owner_user_id: String,
pub status: WoodenFishGenerationStatus,
#[serde(default)]
pub draft: Option<WoodenFishDraftResponse>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishSessionResponse {
pub session: WoodenFishSessionSnapshotResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishActionResponse {
pub action_type: WoodenFishActionType,
pub session: WoodenFishSessionSnapshotResponse,
#[serde(default)]
pub work: Option<WoodenFishWorkProfileResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishWorkSummaryResponse {
pub runtime_kind: String,
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
#[serde(default)]
pub source_session_id: Option<String>,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
#[serde(default)]
pub cover_image_src: Option<String>,
pub publication_status: String,
pub play_count: u32,
pub updated_at: String,
#[serde(default)]
pub published_at: Option<String>,
pub publish_ready: bool,
pub generation_status: WoodenFishGenerationStatus,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishWorkProfileResponse {
pub summary: WoodenFishWorkSummaryResponse,
pub draft: WoodenFishDraftResponse,
pub hit_object_asset: WoodenFishImageAsset,
#[serde(default)]
pub background_asset: Option<WoodenFishImageAsset>,
pub hit_sound_asset: WoodenFishAudioAsset,
pub floating_words: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishWorkDetailResponse {
pub item: WoodenFishWorkProfileResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishWorkMutationResponse {
pub item: WoodenFishWorkProfileResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishGalleryCardResponse {
pub public_work_code: String,
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
#[serde(default)]
pub cover_image_src: Option<String>,
pub theme_tags: Vec<String>,
pub publication_status: String,
pub play_count: u32,
pub updated_at: String,
#[serde(default)]
pub published_at: Option<String>,
pub generation_status: WoodenFishGenerationStatus,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishGalleryResponse {
pub items: Vec<WoodenFishGalleryCardResponse>,
pub has_more: bool,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishGalleryDetailResponse {
pub item: WoodenFishWorkProfileResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishRuntimeRunSnapshotResponse {
pub run_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub status: WoodenFishRunStatus,
pub total_tap_count: u32,
pub word_counters: Vec<WoodenFishWordCounter>,
pub started_at_ms: u64,
pub updated_at_ms: u64,
#[serde(default)]
pub finished_at_ms: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishRunResponse {
pub run: WoodenFishRuntimeRunSnapshotResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishStartRunRequest {
pub profile_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishCheckpointRunRequest {
pub total_tap_count: u32,
pub word_counters: Vec<WoodenFishWordCounter>,
pub client_event_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WoodenFishFinishRunRequest {
pub total_tap_count: u32,
pub word_counters: Vec<WoodenFishWordCounter>,
pub client_event_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn wooden_fish_workspace_request_uses_camel_case_and_default_words() {
let payload = serde_json::to_value(WoodenFishWorkspaceCreateRequest {
template_id: "wooden-fish".to_string(),
work_title: "今日敲木鱼".to_string(),
work_description: "轻松敲击".to_string(),
theme_tags: vec!["休闲".to_string()],
hit_object_prompt: "卡通木鱼".to_string(),
hit_object_reference_image_src: None,
hit_sound_prompt: Some("清脆木鱼声".to_string()),
hit_sound_asset: None,
floating_words: vec![
"幸运".to_string(),
"健康".to_string(),
"财富".to_string(),
"姻缘".to_string(),
"幸福".to_string(),
"事业".to_string(),
"成功".to_string(),
"功德".to_string(),
],
})
.expect("payload should serialize");
assert_eq!(payload["templateId"], json!("wooden-fish"));
assert_eq!(payload["hitObjectPrompt"], json!("卡通木鱼"));
assert_eq!(payload["hitSoundPrompt"], json!("清脆木鱼声"));
assert_eq!(payload["floatingWords"][7], json!("功德"));
}
#[test]
fn wooden_fish_runtime_snapshot_counts_words_inside_one_run() {
let payload = serde_json::to_value(WoodenFishRuntimeRunSnapshotResponse {
run_id: "wooden-fish-run-1".to_string(),
profile_id: "wooden-fish-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
status: WoodenFishRunStatus::Playing,
total_tap_count: 3,
word_counters: vec![
WoodenFishWordCounter {
text: "幸运".to_string(),
count: 2,
},
WoodenFishWordCounter {
text: "功德".to_string(),
count: 1,
},
],
started_at_ms: 100,
updated_at_ms: 130,
finished_at_ms: None,
})
.expect("payload should serialize");
assert_eq!(payload["status"], json!("playing"));
assert_eq!(payload["totalTapCount"], json!(3));
assert_eq!(payload["wordCounters"][0]["text"], json!("幸运"));
}
#[test]
fn wooden_fish_action_request_serializes_audio_and_image_fields() {
let payload = serde_json::to_value(WoodenFishActionRequest {
action_type: WoodenFishActionType::ReplaceHitSound,
profile_id: Some("wooden-fish-profile-1".to_string()),
work_title: None,
work_description: None,
theme_tags: None,
hit_object_prompt: Some("卡通铜钹".to_string()),
hit_object_reference_image_src: Some("/uploads/reference.png".to_string()),
hit_object_asset: Some(WoodenFishImageAsset {
asset_id: "image-1".to_string(),
image_src: "/generated-wooden-fish-assets/profile/hit-object/image.png".to_string(),
image_object_key: "generated-wooden-fish-assets/profile/hit-object/image.png"
.to_string(),
asset_object_id: "image-object-1".to_string(),
generation_provider: "image2".to_string(),
prompt: "卡通铜钹".to_string(),
width: 1024,
height: 1024,
}),
background_asset: Some(WoodenFishImageAsset {
asset_id: "background-1".to_string(),
image_src: "/generated-wooden-fish-assets/profile/background/image.png"
.to_string(),
image_object_key: "generated-wooden-fish-assets/profile/background/image.png"
.to_string(),
asset_object_id: "background-object-1".to_string(),
generation_provider: "image2".to_string(),
prompt: "赛博莲花背景".to_string(),
width: 1024,
height: 1536,
}),
hit_sound_prompt: Some("短促木鱼声".to_string()),
hit_sound_asset: Some(WoodenFishAudioAsset {
asset_id: "sound-1".to_string(),
audio_src: "/generated/wooden-fish.mp3".to_string(),
audio_object_key: "generated/wooden-fish.mp3".to_string(),
asset_object_id: "asset-object-1".to_string(),
source: "upload".to_string(),
prompt: None,
duration_ms: Some(800),
}),
floating_words: Some(vec!["功德".to_string()]),
})
.expect("payload should serialize");
assert_eq!(payload["actionType"], json!("replace-hit-sound"));
assert_eq!(payload["profileId"], json!("wooden-fish-profile-1"));
assert_eq!(payload["hitObjectPrompt"], json!("卡通铜钹"));
assert_eq!(
payload["hitObjectAsset"]["imageObjectKey"],
json!("generated-wooden-fish-assets/profile/hit-object/image.png")
);
assert_eq!(payload["backgroundAsset"]["height"], json!(1536));
assert_eq!(payload["hitSoundAsset"]["source"], json!("upload"));
assert_eq!(payload["hitSoundAsset"]["durationMs"], json!(800));
}
#[test]
fn wooden_fish_action_request_ignores_client_hit_object_asset() {
let payload = serde_json::from_value::<WoodenFishActionRequest>(json!({
"actionType": "compile-draft",
"hitObjectPrompt": "卡通铜钹",
"hitObjectAsset": {
"assetId": "client-image",
"imageSrc": "/generated-wooden-fish-assets/client/image.png",
"imageObjectKey": "generated-wooden-fish-assets/client/image.png",
"assetObjectId": "client-asset-object",
"generationProvider": "client",
"prompt": "跳过生成",
"width": 1024,
"height": 1024
}
}))
.expect("payload should deserialize");
assert_eq!(payload.action_type, WoodenFishActionType::CompileDraft);
assert_eq!(payload.hit_object_prompt.as_deref(), Some("卡通铜钹"));
assert_eq!(payload.hit_object_asset, None);
}
#[test]
fn wooden_fish_work_profile_keeps_summary_and_runtime_assets() {
let image = WoodenFishImageAsset {
asset_id: "image-1".to_string(),
image_src: "/generated/wooden-fish.png".to_string(),
image_object_key: "generated/wooden-fish.png".to_string(),
asset_object_id: "image-object-1".to_string(),
generation_provider: "image2".to_string(),
prompt: "卡通木鱼".to_string(),
width: 1024,
height: 1024,
};
let audio = WoodenFishAudioAsset {
asset_id: "sound-1".to_string(),
audio_src: "/generated/wooden-fish.mp3".to_string(),
audio_object_key: "generated/wooden-fish.mp3".to_string(),
asset_object_id: "sound-object-1".to_string(),
source: "generated".to_string(),
prompt: Some("清脆木鱼".to_string()),
duration_ms: Some(600),
};
let profile = WoodenFishWorkProfileResponse {
summary: WoodenFishWorkSummaryResponse {
runtime_kind: "wooden-fish".to_string(),
work_id: "wooden-fish-profile-1".to_string(),
profile_id: "wooden-fish-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("wooden-fish-session-1".to_string()),
work_title: "敲木鱼".to_string(),
work_description: String::new(),
theme_tags: vec!["休闲".to_string()],
cover_image_src: Some(image.image_src.clone()),
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-20T00:00:00Z".to_string(),
published_at: None,
publish_ready: true,
generation_status: WoodenFishGenerationStatus::Ready,
},
draft: WoodenFishDraftResponse {
template_id: "wooden-fish".to_string(),
template_name: "敲木鱼".to_string(),
profile_id: Some("wooden-fish-profile-1".to_string()),
work_title: "敲木鱼".to_string(),
work_description: String::new(),
theme_tags: vec!["休闲".to_string()],
hit_object_prompt: "卡通木鱼".to_string(),
hit_object_reference_image_src: None,
hit_sound_prompt: Some("清脆木鱼".to_string()),
floating_words: vec!["功德".to_string()],
hit_object_asset: Some(image.clone()),
background_asset: None,
hit_sound_asset: Some(audio.clone()),
cover_image_src: Some(image.image_src.clone()),
generation_status: WoodenFishGenerationStatus::Ready,
},
hit_object_asset: image,
background_asset: None,
hit_sound_asset: audio,
floating_words: vec!["功德".to_string()],
};
let payload = serde_json::to_value(profile).expect("profile should serialize");
assert_eq!(payload["summary"]["runtimeKind"], json!("wooden-fish"));
assert_eq!(
payload["hitObjectAsset"]["generationProvider"],
json!("image2")
);
assert_eq!(payload["hitSoundAsset"]["source"], json!("generated"));
}
}