diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index c3e5ad97..fbd1e81c 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -31,6 +31,22 @@ - 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 自定义世界旧公开作品不要用 published_at 判断是否存在 + +- 现象:RPG / 自定义世界作品详情能打开,但点赞时报 `custom_world 已发布作品不存在,无法点赞`,错误来源是 `作品详情 CW-*` 或其它自定义世界历史公开号。 +- 原因:部分历史 `custom_world_profile` 已是 `publication_status=Published`,但 `published_at` 为空;统一公开详情会用 `updated_at` 兜底展示,旧点赞 / 游玩 / Remix 判断却额外要求 `published_at.is_some()`。 +- 处理:公开互动存在性统一按 `Published + deleted_at=None + visible=true` 判断;`custom_world_gallery_entry` 同步和公开展示时间在 `published_at` 缺失时回退 `updated_at`。 +- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 + +## 推荐页 WF 点赞不要落到 RPG / custom-world + +- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`。 +- 原因:推荐页点赞统一走 `likePublicWork`,但敲木鱼尚未接入点赞后端;缺少 `wooden-fish` 分支时会落入默认 RPG / custom-world 点赞路径,把敲木鱼的 owner/profile 传给 custom-world reducer。 +- 处理:所有公开作品互动必须先按 `packages/shared/src/contracts/playTypes.ts` 中的全局 `sourceType` 分流;暂未接入点赞的玩法直接报“该作品类型暂不支持点赞”,禁止显示开放兜底文案,也禁止用默认 RPG / custom-world 分支兜底。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation wooden fish like does not call RPG gallery like"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 + ## 暗色创作进度卡不要被 platform-remap-surface 改成深色文字 - 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。 diff --git a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md index 76c22a5f..bd116756 100644 --- a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -77,6 +77,7 @@ - 旧 view 退到底层 source / 兼容职责。 - 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。 - 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。 +- RPG / 自定义世界旧数据可能缺少 `published_at`。统一公开详情可以用 `updated_at` 作为展示和排序兜底;点赞、游玩、Remix 等写入路径也必须按 `publication_status=Published + visible=true + 未删除` 判断作品存在,不能额外要求 `published_at` 非空。 - 临时运行约束:SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view` 的 `publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic;为避免后台隐藏作品打爆 module instance,统一 `public_work_*` view 暂不级联抓大鹅 source view,抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。 - 旧 `/api/runtime//gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。 - 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ce40f879..f54145fd 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -377,6 +377,7 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldProfile` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` - 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 兼容约束:历史公开 RPG / 自定义世界 profile 可能存在 `publication_status=Published` 但 `published_at=None`。公开详情、点赞、游玩、Remix 和 `custom_world_gallery_entry` 同步都以 `Published + deleted_at=None + visible=true` 判断作品可公开互动;展示和 gallery 同步时间在 `published_at` 缺失时回退 `updated_at`,不得仅因 `published_at` 为空返回“已发布作品不存在”。 ### `custom_world_session` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 561a690c..618a6f07 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -135,6 +135,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 推荐页嵌入拼图运行态时,“下一关”必须走推荐页统一相邻作品切换流程,不得由拼图 runtime 自己传递 `preferSimilarWork` 或私自把当前 run handoff 到其它拼图作品。点击后应与推荐页底部“下一个”使用同一套 `activeRecommendEntryKey` / 推荐队列切换和新作品启动语义,推荐卡标题、分享 / 点赞 / 改造基准都由统一推荐切换结果决定。切换发起前仍必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;后续局部同步状态由推荐页启动新作品的统一 busy 表现承接。 - 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 +- 推荐页作品点赞必须按前端全局公开作品 `sourceType` 联合类型明确分流;暂未接入点赞后端的玩法直接报“该作品类型暂不支持点赞”,不能显示开放兜底文案,也不能落入 RPG / custom-world 默认点赞路径。特别是 `WF-*` 敲木鱼作品不得调用 `/api/runtime/custom-world-gallery/.../like`。前端全局创作类型 / 公开作品类型定义以 `packages/shared/src/contracts/playTypes.ts` 为准,新增玩法必须先补类型再补推荐页、详情页、分类页和公开互动分支。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 ## 跳一跳 diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 5ba8e295..4d42e963 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -4,6 +4,7 @@ export type * from './hyper3d'; export type * from './jumpHop'; export type * from './puzzleCreativeTemplate'; export type * from './puzzleClear'; +export * from './playTypes'; export type * from './publicWork'; export type * from './visualNovel'; export type * from './barkBattle'; diff --git a/packages/shared/src/contracts/playTypes.ts b/packages/shared/src/contracts/playTypes.ts new file mode 100644 index 00000000..e81383ab --- /dev/null +++ b/packages/shared/src/contracts/playTypes.ts @@ -0,0 +1,72 @@ +export const PLATFORM_CREATION_TYPE_IDS = [ + 'rpg', + 'big-fish', + 'puzzle', + 'puzzle-clear', + 'match3d', + 'jump-hop', + 'wooden-fish', + 'square-hole', + 'bark-battle', + 'visual-novel', + 'baby-object-match', + 'creative-agent', + 'airp', +] as const; + +export type PlatformCreationTypeId = + (typeof PLATFORM_CREATION_TYPE_IDS)[number]; + +const PLATFORM_CREATION_TYPE_ID_SET: ReadonlySet = new Set( + PLATFORM_CREATION_TYPE_IDS, +); + +export function isPlatformCreationTypeId( + value: string, +): value is PlatformCreationTypeId { + return PLATFORM_CREATION_TYPE_ID_SET.has(value); +} + +export function assertPlatformCreationTypeId( + value: string, +): PlatformCreationTypeId { + if (isPlatformCreationTypeId(value)) { + return value; + } + + throw new Error(`未知创作类型:${value}`); +} + +export const PUBLIC_WORK_SOURCE_TYPES = [ + 'custom-world', + 'big-fish', + 'puzzle', + 'puzzle-clear', + 'jump-hop', + 'wooden-fish', + 'match3d', + 'square-hole', + 'visual-novel', + 'bark-battle', + 'edutainment', +] as const; + +export type PublicWorkSourceType = (typeof PUBLIC_WORK_SOURCE_TYPES)[number]; + +const PUBLIC_WORK_SOURCE_TYPE_SET: ReadonlySet = new Set( + PUBLIC_WORK_SOURCE_TYPES, +); + +export function isPublicWorkSourceType( + value: string, +): value is PublicWorkSourceType { + return PUBLIC_WORK_SOURCE_TYPE_SET.has(value); +} + +export function assertPublicWorkSourceType(value: string): PublicWorkSourceType { + if (isPublicWorkSourceType(value)) { + return value; + } + + throw new Error(`未知公开作品类型:${value}`); +} diff --git a/packages/shared/src/contracts/publicWork.ts b/packages/shared/src/contracts/publicWork.ts index 71aae5d9..c7a3065a 100644 --- a/packages/shared/src/contracts/publicWork.ts +++ b/packages/shared/src/contracts/publicWork.ts @@ -1,5 +1,7 @@ +import type { PublicWorkSourceType } from './playTypes'; + export interface PublicWorkGalleryEntryResponse { - sourceType: string; + sourceType: PublicWorkSourceType; workId: string; profileId: string; sourceSessionId?: string | null; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3c8b36f3..cfffc0a4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,6 +12,7 @@ export type * from './contracts/hyper3d'; export * from './contracts/match3dAgent'; export * from './contracts/match3dRuntime'; export * from './contracts/match3dWorks'; +export * from './contracts/playTypes'; export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentSession'; diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 6e88121e..646c12ac 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -1662,9 +1662,7 @@ fn get_custom_world_gallery_detail_record( .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id - && row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible + && is_custom_world_profile_publicly_interactive(row) }); let gallery_entry = ctx @@ -1712,8 +1710,7 @@ fn get_custom_world_gallery_detail_record_by_code( .find(&row.profile_id) .filter(|profile_row| { profile_row.owner_user_id == row.owner_user_id - && profile_row.publication_status == CustomWorldPublicationStatus::Published - && profile_row.deleted_at.is_none() + && is_custom_world_profile_publicly_interactive(profile_row) }) }); @@ -1756,12 +1753,7 @@ fn remix_custom_world_profile_record( .profile_id() .find(&source_profile_id.to_string()) .filter(|row| row.owner_user_id == source_owner_user_id) - .filter(|row| { - row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible - && row.published_at.is_some() - }) + .filter(is_custom_world_profile_publicly_interactive) .ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?; let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); @@ -1859,12 +1851,7 @@ fn record_custom_world_profile_play_record( .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) - .filter(|row| { - row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible - && row.published_at.is_some() - }) + .filter(is_custom_world_profile_publicly_interactive) .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); @@ -1932,12 +1919,7 @@ fn record_custom_world_profile_like_record( .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) - .filter(|row| { - row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible - && row.published_at.is_some() - }) + .filter(is_custom_world_profile_publicly_interactive) .ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?; let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); @@ -1998,6 +1980,18 @@ fn record_custom_world_profile_like_record( )) } +fn is_custom_world_profile_publicly_interactive(row: &CustomWorldProfile) -> bool { + // 历史公开作品可能缺少 published_at;公开互动只按发布、未删除、可见判断。 + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.visible +} + +fn resolve_custom_world_published_at(row: &CustomWorldProfile) -> Timestamp { + // gallery 展示与同步兼容旧数据,用 updated_at 兜底公开时间。 + row.published_at.unwrap_or(row.updated_at) +} + fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, @@ -4832,9 +4826,10 @@ fn sync_custom_world_gallery_entry_from_profile( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> Result { - let published_at = profile - .published_at - .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; + if profile.publication_status != CustomWorldPublicationStatus::Published { + return Err("custom_world profile 未发布,无法同步 gallery".to_string()); + } + let published_at = resolve_custom_world_published_at(profile); ctx.db .custom_world_gallery_entry() @@ -4881,10 +4876,6 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), .collect::>(); for profile in published_profiles { - if profile.published_at.is_none() { - continue; - } - let existing_gallery_entry = ctx .db .custom_world_gallery_entry() @@ -5483,6 +5474,78 @@ mod tests { )); } + #[test] + fn custom_world_public_interactions_accept_legacy_missing_published_at() { + fn build_profile( + publication_status: CustomWorldPublicationStatus, + published_at: Option, + deleted_at: Option, + visible: bool, + ) -> CustomWorldProfile { + CustomWorldProfile { + profile_id: "profile-legacy".to_string(), + owner_user_id: "user-legacy".to_string(), + public_work_code: Some("CW-3A9EC89B".to_string()), + author_public_user_code: Some("SY-00000001".to_string()), + source_agent_session_id: Some("session-legacy".to_string()), + publication_status, + world_name: "旧公开世界".to_string(), + subtitle: String::new(), + summary_text: String::new(), + theme_mode: CustomWorldThemeMode::Mythic, + cover_image_src: None, + profile_payload_json: "{}".to_string(), + playable_npc_count: 0, + landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, + author_display_name: "玩家".to_string(), + published_at, + deleted_at, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(20), + visible, + } + } + + let legacy_published = + build_profile(CustomWorldPublicationStatus::Published, None, None, true); + assert!(is_custom_world_profile_publicly_interactive( + &legacy_published + )); + assert_eq!( + resolve_custom_world_published_at(&legacy_published).to_micros_since_unix_epoch(), + 20 + ); + + let current_published = build_profile( + CustomWorldPublicationStatus::Published, + Some(Timestamp::from_micros_since_unix_epoch(10)), + None, + true, + ); + assert_eq!( + resolve_custom_world_published_at(¤t_published).to_micros_since_unix_epoch(), + 10 + ); + + assert!(!is_custom_world_profile_publicly_interactive( + &build_profile(CustomWorldPublicationStatus::Draft, None, None, true,) + )); + assert!(!is_custom_world_profile_publicly_interactive( + &build_profile( + CustomWorldPublicationStatus::Published, + None, + Some(Timestamp::from_micros_since_unix_epoch(30)), + true, + ) + )); + assert!(!is_custom_world_profile_publicly_interactive( + &build_profile(CustomWorldPublicationStatus::Published, None, None, false,) + )); + } + #[test] fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() { fn build_test_custom_world_profile( diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index cc600047..743d62f9 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -1573,11 +1573,6 @@ mod tests { 5, 1_000, &existing )); } -} - -#[cfg(test)] -mod tests { - use super::*; #[test] fn jump_hop_delete_input_carries_owner_and_profile() { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 812e70ed..982a10f7 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -395,6 +395,7 @@ import { buildPlatformPublicGalleryCardKey, isBarkBattleGalleryEntry, isBigFishGalleryEntry, + isCustomWorldGalleryEntry, isEdutainmentGalleryEntry, isJumpHopGalleryEntry, isMatch3DGalleryEntry, @@ -701,6 +702,10 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { function getPlatformRecommendRuntimeKind( entry: PlatformPublicGalleryCard, ): RecommendRuntimeKind { + if (isCustomWorldGalleryEntry(entry)) { + return 'rpg'; + } + if (isBigFishGalleryEntry(entry)) { return 'big-fish'; } @@ -741,7 +746,7 @@ function getPlatformRecommendRuntimeKind( return 'edutainment'; } - return 'rpg'; + throw new Error('未知公开作品类型,无法启动推荐玩法。'); } function resolveRecommendEntryShareStage( @@ -758,6 +763,14 @@ function resolveRecommendEntryShareStage( return 'work-detail'; } +function resolveUnsupportedPublicWorkActionMessage( + entry: PlatformPublicGalleryCard, + actionLabel: string, +) { + const sourceType = 'sourceType' in entry ? entry.sourceType : 'custom-world'; + return `作品类型 ${sourceType} 暂不支持${actionLabel}。`; +} + function isRecommendRuntimeReadyForEntry( entry: PlatformPublicGalleryCard, state: RecommendRuntimeState, @@ -800,8 +813,11 @@ function isRecommendRuntimeReadyForEntry( if (expectedKind === 'edutainment') { return Boolean(state.babyObjectMatchDraft); } + if (expectedKind === 'rpg') { + return true; + } - return true; + throw new Error('未知推荐玩法类型。'); } function isSamePlatformPublicGalleryEntry( @@ -834,7 +850,10 @@ function mergePlatformPublicGalleryEntries( function mapRpgGalleryCardToPublicWorkDetail( entry: CustomWorldGalleryCard, ): PlatformPublicGalleryCard { - return entry; + return { + ...entry, + sourceType: 'custom-world', + }; } function mapPuzzleWorkToPublicWorkDetail( @@ -13521,54 +13540,55 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isEdutainmentGalleryEntry(entry)) { - setPublicWorkDetailError('宝贝识物点赞将在后续版本开放。'); + if (isCustomWorldGalleryEntry(entry)) { + void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) + .then((updatedEntry) => { + setSelectedDetailEntry((current) => + current?.profileId === updatedEntry.profileId + ? updatedEntry + : current, + ); + platformBootstrap.setPublishedGalleryEntries((current) => + current.map((item) => + item.profileId === updatedEntry.profileId + ? updatedEntry + : item, + ), + ); + syncUpdatedPublicWorkDetail( + mapRpgGalleryCardToPublicWorkDetail(updatedEntry), + ); + }) + .catch((error) => { + setPublicWorkDetailError( + resolveRpgCreationErrorMessage(error, '点赞 RPG 作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + return; + } + + if ( + isPuzzleClearGalleryEntry(entry) || + isJumpHopGalleryEntry(entry) || + isWoodenFishGalleryEntry(entry) || + isMatch3DGalleryEntry(entry) || + isEdutainmentGalleryEntry(entry) || + isBarkBattleGalleryEntry(entry) || + isSquareHoleGalleryEntry(entry) || + isVisualNovelGalleryEntry(entry) + ) { + setPublicWorkDetailError( + resolveUnsupportedPublicWorkActionMessage(entry, '点赞'), + ); setIsPublicWorkDetailBusy(false); return; } - if (isBarkBattleGalleryEntry(entry)) { - setPublicWorkDetailError('汪汪声浪点赞将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isSquareHoleGalleryEntry(entry)) { - setPublicWorkDetailError('方洞挑战点赞将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isVisualNovelGalleryEntry(entry)) { - setPublicWorkDetailError('视觉小说点赞将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) - .then((updatedEntry) => { - setSelectedDetailEntry((current) => - current?.profileId === updatedEntry.profileId - ? updatedEntry - : current, - ); - platformBootstrap.setPublishedGalleryEntries((current) => - current.map((item) => - item.profileId === updatedEntry.profileId ? updatedEntry : item, - ), - ); - syncUpdatedPublicWorkDetail( - mapRpgGalleryCardToPublicWorkDetail(updatedEntry), - ); - }) - .catch((error) => { - setPublicWorkDetailError( - resolveRpgCreationErrorMessage(error, '点赞 RPG 作品失败。'), - ); - }) - .finally(() => { - setIsPublicWorkDetailBusy(false); - }); + setPublicWorkDetailError('未知公开作品类型,无法点赞。'); + setIsPublicWorkDetailBusy(false); }); }, [ @@ -14189,7 +14209,12 @@ export function PlatformEntryFlowShellImpl({ return; } - void openRpgPublicWorkDetail(entry); + if (isCustomWorldGalleryEntry(entry)) { + void openRpgPublicWorkDetail(entry); + return; + } + + setPublicWorkDetailError('未知公开作品类型,无法打开作品详情。'); }, [ openPuzzlePublicWorkDetail, @@ -15506,14 +15531,6 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, { - returnStage: 'work-detail', - }); - return; - } - if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) { setPublicWorkDetailError(null); void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, { @@ -15588,6 +15605,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (!isCustomWorldGalleryEntry(selectedPublicWorkDetail)) { + setPublicWorkDetailError('未知公开作品类型,无法进入玩法。'); + return; + } + const launchEntry = selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId ? selectedDetailEntry @@ -15759,8 +15781,10 @@ export function PlatformEntryFlowShellImpl({ embedded: true, }, ); - } else { + } else if (isCustomWorldGalleryEntry(entry)) { started = true; + } else { + throw new Error('未知公开作品类型,无法启动推荐玩法。'); } if (!isCurrentStartRequest()) { @@ -16403,74 +16427,49 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isPuzzleClearGalleryEntry(entry)) { - setPublicWorkDetailError('拼消消作品改造将在后续版本开放。'); + if ( + isPuzzleClearGalleryEntry(entry) || + isMatch3DGalleryEntry(entry) || + isSquareHoleGalleryEntry(entry) || + isJumpHopGalleryEntry(entry) || + isWoodenFishGalleryEntry(entry) || + isVisualNovelGalleryEntry(entry) || + isEdutainmentGalleryEntry(entry) || + isBarkBattleGalleryEntry(entry) + ) { + setPublicWorkDetailError( + resolveUnsupportedPublicWorkActionMessage(entry, '改造'), + ); setIsPublicWorkDetailBusy(false); return; } - if (isMatch3DGalleryEntry(entry)) { - setPublicWorkDetailError('抓大鹅作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); + if (isCustomWorldGalleryEntry(entry)) { + void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) + .then((response) => { + const nextEntry = response.entry; + setSelectedDetailEntry(nextEntry); + platformBootstrap.setSavedCustomWorldEntries([ + nextEntry, + ...platformBootstrap.savedCustomWorldEntries.filter( + (entry) => entry.profileId !== nextEntry.profileId, + ), + ]); + void detailNavigation.openSavedCustomWorldEditor(nextEntry); + }) + .catch((error) => { + setPublicWorkDetailError( + resolveRpgCreationErrorMessage(error, 'Remix RPG 作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); return; } - if (isSquareHoleGalleryEntry(entry)) { - setPublicWorkDetailError('方洞挑战作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isJumpHopGalleryEntry(entry)) { - setPublicWorkDetailError('跳一跳作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isWoodenFishGalleryEntry(entry)) { - setPublicWorkDetailError('敲木鱼作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isVisualNovelGalleryEntry(entry)) { - setPublicWorkDetailError('视觉小说作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isEdutainmentGalleryEntry(entry)) { - setPublicWorkDetailError('宝贝识物作品改造将在创作链路接入后开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isBarkBattleGalleryEntry(entry)) { - setPublicWorkDetailError('汪汪声浪作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) - .then((response) => { - const nextEntry = response.entry; - setSelectedDetailEntry(nextEntry); - platformBootstrap.setSavedCustomWorldEntries([ - nextEntry, - ...platformBootstrap.savedCustomWorldEntries.filter( - (entry) => entry.profileId !== nextEntry.profileId, - ), - ]); - void detailNavigation.openSavedCustomWorldEditor(nextEntry); - }) - .catch((error) => { - setPublicWorkDetailError( - resolveRpgCreationErrorMessage(error, 'Remix RPG 作品失败。'), - ); - }) - .finally(() => { - setIsPublicWorkDetailBusy(false); - }); + setPublicWorkDetailError('未知公开作品类型,无法改造。'); + setIsPublicWorkDetailBusy(false); }); }, [ @@ -16630,6 +16629,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (!isCustomWorldGalleryEntry(entry)) { + setPublicWorkDetailError('未知公开作品类型,无法编辑。'); + return; + } + const editEntry = selectedDetailEntry?.profileId === entry.profileId ? selectedDetailEntry @@ -16737,6 +16741,7 @@ export function PlatformEntryFlowShellImpl({ const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword); const card = { + sourceType: 'custom-world', ownerUserId: entry.ownerUserId, profileId: entry.profileId, publicWorkCode: entry.publicWorkCode, @@ -16755,7 +16760,7 @@ export function PlatformEntryFlowShellImpl({ playCount: entry.playCount ?? 0, remixCount: entry.remixCount ?? 0, likeCount: entry.likeCount ?? 0, - } satisfies CustomWorldGalleryCard; + } satisfies PlatformPublicGalleryCard; if (!canExposePublicWork(card)) { throw new Error(EDUTAINMENT_HIDDEN_MESSAGE); } diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index 5390415a..9f8e3cd4 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -24,7 +24,11 @@ import { formatPlatformWorkDisplayTags, formatPlatformWorldTime, isBarkBattleGalleryEntry, + isCustomWorldGalleryEntry, isEdutainmentGalleryEntry, + isJumpHopGalleryEntry, + isPuzzleClearGalleryEntry, + isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, resolvePlatformWorkAuthorDisplayName, resolvePlatformPublicWorkCode, @@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) { if ('sourceType' in entry && entry.sourceType === 'puzzle') { return '拼图'; } + if (isPuzzleClearGalleryEntry(entry)) { + return '拼消消'; + } if ('sourceType' in entry && entry.sourceType === 'big-fish') { return '大鱼吃小鱼'; } + if (isJumpHopGalleryEntry(entry)) { + return '跳一跳'; + } + if (isWoodenFishGalleryEntry(entry)) { + return '敲木鱼'; + } if ('sourceType' in entry && entry.sourceType === 'match3d') { return '抓大鹅'; } @@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) { if (isEdutainmentGalleryEntry(entry)) { return entry.templateName; } - return 'RPG'; + if (isCustomWorldGalleryEntry(entry)) { + return 'RPG'; + } + + throw new Error('未知公开作品类型。'); } function getAuthorAvatarLabel(authorDisplayName: string) { diff --git a/src/components/platform-entry/platformEntryCreationTypes.test.ts b/src/components/platform-entry/platformEntryCreationTypes.test.ts index 4daf6829..c1b065e0 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.test.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test, vi } from 'vitest'; +import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService'; import { derivePlatformCreationTypes, groupVisiblePlatformCreationTypes, @@ -81,7 +82,7 @@ test('database entry config controls visibility open state and display order', ( test('visible platform creation types hide invisible cards and put locked cards last', () => { const cards = derivePlatformCreationTypes([ { - id: 'hidden', + id: 'airp', title: '隐藏', subtitle: '隐藏', badge: '隐藏', @@ -95,7 +96,7 @@ test('visible platform creation types hide invisible cards and put locked cards updatedAtMicros: 1, }, { - id: 'locked', + id: 'visual-novel', title: '锁定', subtitle: '锁定', badge: '即将开放', @@ -109,7 +110,7 @@ test('visible platform creation types hide invisible cards and put locked cards updatedAtMicros: 1, }, { - id: 'open', + id: 'rpg', title: '开放', subtitle: '开放', badge: '可创建', @@ -125,13 +126,13 @@ test('visible platform creation types hide invisible cards and put locked cards ]); expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual( - ['open', 'locked'], + ['rpg', 'visual-novel'], ); - expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false); - expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true); - expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false); - expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false); - expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true); + expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false); + expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true); + expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false); + expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false); + expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true); expect( cards.every((item) => item.imageSrc.startsWith('/creation-type-references/'), @@ -288,7 +289,7 @@ test('groups visible platform creation types by backend category metadata', () = updatedAtMicros: 1, }, { - id: 'hidden', + id: 'airp', title: '隐藏入口', subtitle: '隐藏', badge: '隐藏', @@ -319,7 +320,7 @@ test('groups visible platform creation types by backend category metadata', () = test('falls back when backend creation type category metadata is missing', () => { const cards = derivePlatformCreationTypes([ { - id: 'legacy-entry', + id: 'creative-agent', title: '历史入口', subtitle: '旧数据缺少分类字段', badge: '可创建', @@ -336,7 +337,7 @@ test('falls back when backend creation type category metadata is missing', () => expect(cards[0]).toEqual( expect.objectContaining({ - id: 'legacy-entry', + id: 'creative-agent', categoryId: 'recommended', categoryLabel: '热门推荐', }), @@ -348,3 +349,24 @@ test('falls back when backend creation type category metadata is missing', () => }), ]); }); + +test('throws when backend sends an unknown creation type id', () => { + const unknownEntry = { + id: 'unknown-play', + title: '未知玩法', + subtitle: '未知', + badge: '未知', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 10, + categoryId: 'recommended', + categoryLabel: '热门推荐', + categorySortOrder: 20, + updatedAtMicros: 1, + } as unknown as CreationEntryTypeConfig; + + expect(() => derivePlatformCreationTypes([unknownEntry])).toThrow( + '未知创作类型:unknown-play', + ); +}); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index a87ae967..4c8b7143 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -1,7 +1,11 @@ +import { + assertPlatformCreationTypeId, + type PlatformCreationTypeId, +} from '../../../packages/shared/src/contracts/playTypes'; import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService'; import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility'; -export type PlatformCreationTypeId = string; +export type { PlatformCreationTypeId }; export type PlatformCreationTypeCard = { id: PlatformCreationTypeId; @@ -117,21 +121,25 @@ export function derivePlatformCreationTypes( ): PlatformCreationTypeCard[] { const orderedCards = [...creationTypes] .sort((left, right) => left.sortOrder - right.sortOrder) - .map((item) => ({ - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - imageSrc: item.imageSrc, - locked: !item.open, - categoryId: normalizeCategoryId(item.categoryId), - categoryLabel: normalizeCategoryLabel(item.categoryLabel), - categorySortOrder: item.categorySortOrder, - sortOrder: item.sortOrder, - hidden: - !item.visible || - (item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()), - })); + .map((item) => { + const id = assertPlatformCreationTypeId(item.id); + + return { + id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + imageSrc: item.imageSrc, + locked: !item.open, + categoryId: normalizeCategoryId(item.categoryId), + categoryLabel: normalizeCategoryLabel(item.categoryLabel), + categorySortOrder: item.categorySortOrder, + sortOrder: item.sortOrder, + hidden: + !item.visible || + (id === 'baby-object-match' && !isEdutainmentEntryEnabled()), + }; + }); return [ ...orderedCards.filter((item) => !item.hidden && !item.locked), diff --git a/src/components/platform-entry/platformRecommendation.ts b/src/components/platform-entry/platformRecommendation.ts index 15d45631..ccd6022f 100644 --- a/src/components/platform-entry/platformRecommendation.ts +++ b/src/components/platform-entry/platformRecommendation.ts @@ -1,6 +1,8 @@ import { buildPlatformPublicGalleryCardKey, + isEdutainmentGalleryEntry, type PlatformPublicGalleryCard, + resolvePlatformPublicWorkSourceType, } from '../rpg-entry/rpgEntryWorldPresentation'; const MS_PER_DAY = 86_400_000; @@ -70,19 +72,11 @@ function getRecommendationMetric( } function getRecommendationSourceType(entry: PlatformPublicGalleryCard) { - if ('sourceType' in entry) { - if ( - entry.sourceType === 'edutainment' && - 'templateId' in entry && - entry.templateId - ) { - return `edutainment:${entry.templateId}`; - } - - return entry.sourceType; + if (isEdutainmentGalleryEntry(entry)) { + return `edutainment:${entry.templateId}`; } - return 'rpg'; + return resolvePlatformPublicWorkSourceType(entry); } function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index cc7efee6..751c4ed3 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -42,7 +42,10 @@ import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; -import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; +import type { + WoodenFishGalleryCardResponse, + WoodenFishWorkSummaryResponse, +} from '../../../packages/shared/src/contracts/woodenFish'; import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { @@ -155,6 +158,7 @@ import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient, getRpgEntryWorldGalleryDetailByCode, + likeRpgEntryWorldGallery, recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; @@ -538,6 +542,7 @@ const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({ getRpgEntryWorldGalleryDetail: vi.fn(), getRpgEntryWorldGalleryDetailByCode: vi.fn(), getRpgEntryWorldLibraryDetail: vi.fn(), + likeRpgEntryWorldGallery: vi.fn(), listRpgEntryWorldGallery: vi.fn(), listRpgEntryWorldLibrary: vi.fn(), publishRpgEntryWorldProfile: vi.fn(), @@ -7365,6 +7370,75 @@ test('home recommendation share opens publish share modal', async () => { .toBeTruthy(); }); +test('home recommendation wooden fish like does not call RPG gallery like', async () => { + const user = userEvent.setup(); + const publishedWoodenFishWork: WoodenFishGalleryCardResponse = { + publicWorkCode: 'WF-3A9EC89B', + workId: 'wooden-fish-work-like-1', + profileId: 'wooden-fish-profile-like-1', + ownerUserId: 'wooden-fish-user-1', + authorDisplayName: '木鱼作者', + workTitle: '莲台木鱼', + workDescription: '推荐页里的敲木鱼作品。', + coverImageSrc: null, + themeTags: ['敲木鱼'], + publicationStatus: 'published', + playCount: 0, + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + generationStatus: 'ready', + }; + + vi.mocked(woodenFishClient.listGallery).mockResolvedValue({ + items: [publishedWoodenFishWork], + hasMore: false, + nextCursor: null, + }); + vi.mocked(woodenFishClient.startRun).mockResolvedValue({ + run: { + runId: 'wooden-fish-run-like-1', + profileId: publishedWoodenFishWork.profileId, + ownerUserId: publishedWoodenFishWork.ownerUserId, + status: 'playing', + totalTapCount: 0, + wordCounters: [], + startedAtMs: 1, + updatedAtMs: 1, + finishedAtMs: null, + }, + }); + vi.mocked(likeRpgEntryWorldGallery).mockResolvedValue( + buildMockRpgGalleryDetail({ + ownerUserId: 'custom-world-user-1', + profileId: 'custom-world-profile-1', + publicWorkCode: 'CW-00000001', + authorPublicUserCode: 'SY-00000001', + visibility: 'published', + publishedAt: '2026-04-25T09:00:00.000Z', + updatedAt: '2026-04-25T09:00:00.000Z', + authorDisplayName: 'RPG 作者', + worldName: '不应被点赞的 RPG', + subtitle: '错误分流', + summaryText: 'WF 点赞不应进入这里。', + coverImageSrc: null, + themeMode: 'mythic', + playableNpcCount: 0, + landmarkCount: 0, + likeCount: 1, + }), + ); + + render(); + + const meta = await screen.findByLabelText('莲台木鱼 作品信息'); + await user.click(within(meta).getByRole('button', { name: '点赞 0' })); + + expect(likeRpgEntryWorldGallery).not.toHaveBeenCalled(); + expect( + await screen.findByText('作品类型 wooden-fish 暂不支持点赞。'), + ).toBeTruthy(); +}); + test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => { const publishedPuzzleWork = { workId: 'puzzle-work-public-2', @@ -7467,12 +7541,6 @@ test('logged out home recommendation next starts the next puzzle work', async () />, ); - const recommendNavButton = document.querySelector( - '.platform-bottom-nav [aria-label="推荐"]', - ); - expect(recommendNavButton).toBeTruthy(); - await user.click(recommendNavButton!); - await waitFor(() => { expect(startPuzzleRun).toHaveBeenCalledWith( { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 525437ff..a6a3f594 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -144,6 +144,7 @@ import { formatPlatformWorldTime, isBarkBattleGalleryEntry, isBigFishGalleryEntry, + isCustomWorldGalleryEntry, isEdutainmentGalleryEntry, isJumpHopGalleryEntry, isMatch3DGalleryEntry, @@ -373,11 +374,15 @@ type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like'; type PlatformCategoryKindFilter = | 'all' | 'puzzle' + | 'puzzle-clear' + | 'jump-hop' + | 'wooden-fish' | 'match3d' | 'square-hole' | 'visual-novel' | 'bark-battle' | 'big-fish' + | 'edutainment' | 'custom-world'; type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like'; @@ -413,11 +418,15 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{ }> = [ { id: 'all', label: '全部' }, { id: 'puzzle', label: '拼图' }, + { id: 'puzzle-clear', label: '拼消' }, + { id: 'jump-hop', label: '跳一跳' }, + { id: 'wooden-fish', label: '木鱼' }, { id: 'match3d', label: '抓鹅' }, { id: 'square-hole', label: '方洞' }, { id: 'visual-novel', label: '视觉' }, { id: 'bark-battle', label: '汪汪' }, { id: 'big-fish', label: '大鱼' }, + { id: 'edutainment', label: EDUTAINMENT_WORK_TAG }, { id: 'custom-world', label: 'RPG' }, ]; const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{ @@ -2192,6 +2201,18 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) { return 'puzzle'; } + if (isPuzzleClearGalleryEntry(entry)) { + return 'puzzle-clear'; + } + + if (isJumpHopGalleryEntry(entry)) { + return 'jump-hop'; + } + + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + if (isMatch3DGalleryEntry(entry)) { return 'match3d'; } @@ -2212,7 +2233,15 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) { return 'big-fish'; } - return 'custom-world'; + if (isEdutainmentGalleryEntry(entry)) { + return 'edutainment'; + } + + if (isCustomWorldGalleryEntry(entry)) { + return 'custom-world'; + } + + throw new Error('未知公开作品类型。'); } function matchesPlatformCategoryKindFilter( diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 62c9cb30..47c5eff5 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -23,6 +23,7 @@ import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; +import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes'; import type { SquareHoleHoleOption, SquareHoleShapeOption, @@ -55,8 +56,12 @@ export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4; export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match'; export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物'; +export type PlatformCustomWorldGalleryCard = CustomWorldGalleryCard & { + sourceType?: 'custom-world'; +}; + export type PlatformWorldCardLike = - | CustomWorldGalleryCard + | PlatformCustomWorldGalleryCard | CustomWorldLibraryEntry | PlatformBigFishGalleryCard | PlatformMatch3DGalleryCard @@ -319,7 +324,7 @@ export type PlatformBarkBattleGalleryCard = { }; export type PlatformPublicGalleryCard = - | CustomWorldGalleryCard + | PlatformCustomWorldGalleryCard | PlatformBigFishGalleryCard | PlatformMatch3DGalleryCard | PlatformSquareHoleGalleryCard @@ -337,6 +342,14 @@ export function isLibraryWorldEntry( return 'profile' in entry; } +export function isCustomWorldGalleryEntry( + entry: PlatformWorldCardLike, +): entry is PlatformCustomWorldGalleryCard { + return !isLibraryWorldEntry(entry) && !('sourceType' in entry) + ? true + : 'sourceType' in entry && entry.sourceType === 'custom-world'; +} + export function isPuzzleGalleryEntry( entry: PlatformWorldCardLike, ): entry is PlatformPuzzleGalleryCard { @@ -397,28 +410,62 @@ export function isBarkBattleGalleryEntry( return 'sourceType' in entry && entry.sourceType === 'bark-battle'; } +export function resolvePlatformPublicWorkSourceType( + entry: PlatformPublicGalleryCard, +): PublicWorkSourceType { + if (isCustomWorldGalleryEntry(entry)) { + return 'custom-world'; + } + + if (isBigFishGalleryEntry(entry)) { + return 'big-fish'; + } + + if (isPuzzleGalleryEntry(entry)) { + return 'puzzle'; + } + + if (isPuzzleClearGalleryEntry(entry)) { + return 'puzzle-clear'; + } + + if (isJumpHopGalleryEntry(entry)) { + return 'jump-hop'; + } + + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + + if (isMatch3DGalleryEntry(entry)) { + return 'match3d'; + } + + if (isSquareHoleGalleryEntry(entry)) { + return 'square-hole'; + } + + if (isVisualNovelGalleryEntry(entry)) { + return 'visual-novel'; + } + + if (isBarkBattleGalleryEntry(entry)) { + return 'bark-battle'; + } + + if (isEdutainmentGalleryEntry(entry)) { + return 'edutainment'; + } + + throw new Error('未知公开作品类型。'); +} + export function buildPlatformPublicGalleryCardKey( entry: PlatformPublicGalleryCard, ) { - const kind = isBigFishGalleryEntry(entry) - ? 'big-fish' - : isPuzzleGalleryEntry(entry) - ? 'puzzle' - : isJumpHopGalleryEntry(entry) - ? 'jump-hop' - : isWoodenFishGalleryEntry(entry) - ? 'wooden-fish' - : isMatch3DGalleryEntry(entry) - ? 'match3d' - : isSquareHoleGalleryEntry(entry) - ? 'square-hole' - : isVisualNovelGalleryEntry(entry) - ? 'visual-novel' - : isBarkBattleGalleryEntry(entry) - ? 'bark-battle' - : isEdutainmentGalleryEntry(entry) - ? `edutainment:${entry.templateId}` - : 'rpg'; + const kind = isEdutainmentGalleryEntry(entry) + ? `edutainment:${entry.templateId}` + : resolvePlatformPublicWorkSourceType(entry); return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } @@ -868,7 +915,11 @@ export function resolvePlatformWorldFallbackCoverImage( return '/creation-type-references/bark-battle.webp'; } - return '/creation-type-references/rpg.webp'; + if (isCustomWorldGalleryEntry(entry) || isLibraryWorldEntry(entry)) { + return '/creation-type-references/rpg.webp'; + } + + throw new Error('未知公开作品类型。'); } export function resolvePlatformWorldCoverSlides( diff --git a/src/components/unified-creation/unifiedCreationSpecs.test.ts b/src/components/unified-creation/unifiedCreationSpecs.test.ts index 611e294d..9754d249 100644 --- a/src/components/unified-creation/unifiedCreationSpecs.test.ts +++ b/src/components/unified-creation/unifiedCreationSpecs.test.ts @@ -16,6 +16,7 @@ describe('unified creation specs', () => { 'jump-hop', 'match3d', 'puzzle', + 'puzzle-clear', 'rpg', 'square-hole', 'visual-novel', @@ -47,6 +48,11 @@ describe('unified creation specs', () => { generationStage: 'puzzle-generating', resultStage: 'puzzle-result', }); + expect(getUnifiedCreationSpec('puzzle-clear')).toMatchObject({ + workspaceStage: 'puzzle-clear-workspace', + generationStage: 'puzzle-clear-generating', + resultStage: 'puzzle-clear-result', + }); expect(getUnifiedCreationSpec('match3d')).toMatchObject({ title: '抓大鹅', workspaceStage: 'match3d-agent-workspace', diff --git a/src/components/unified-creation/unifiedCreationSpecs.ts b/src/components/unified-creation/unifiedCreationSpecs.ts index 3b750212..1c5f30e2 100644 --- a/src/components/unified-creation/unifiedCreationSpecs.ts +++ b/src/components/unified-creation/unifiedCreationSpecs.ts @@ -2,11 +2,13 @@ import type { CreationEntryTypeConfig, UnifiedCreationSpec, } from '../../services/creationEntryConfigService'; +import type { PlatformCreationTypeId } from '../../../packages/shared/src/contracts/playTypes'; export const UNIFIED_CREATION_PLAY_IDS = [ 'rpg', 'big-fish', 'puzzle', + 'puzzle-clear', 'match3d', 'jump-hop', 'wooden-fish', @@ -15,7 +17,7 @@ export const UNIFIED_CREATION_PLAY_IDS = [ 'visual-novel', 'baby-object-match', 'creative-agent', -] as const; +] as const satisfies readonly PlatformCreationTypeId[]; export type UnifiedCreationPlayId = (typeof UNIFIED_CREATION_PLAY_IDS)[number]; @@ -82,6 +84,33 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, ], }, + 'puzzle-clear': { + playId: 'puzzle-clear', + title: '想做个什么玩法?', + workspaceStage: 'puzzle-clear-workspace', + generationStage: 'puzzle-clear-generating', + resultStage: 'puzzle-clear-result', + fields: [ + { + id: 'title', + kind: 'text', + label: '作品标题', + required: true, + }, + { + id: 'themePrompt', + kind: 'text', + label: '主题', + required: true, + }, + { + id: 'backgroundReferenceImage', + kind: 'image', + label: '参考图', + required: false, + }, + ], + }, match3d: { playId: 'match3d', title: '抓大鹅', diff --git a/src/services/creationEntryConfigService.ts b/src/services/creationEntryConfigService.ts index 7ca6adef..b3bfab43 100644 --- a/src/services/creationEntryConfigService.ts +++ b/src/services/creationEntryConfigService.ts @@ -1,8 +1,9 @@ +import type { PlatformCreationTypeId } from '../../packages/shared/src/contracts/playTypes'; import { requestJson } from './apiClient'; /** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */ export type CreationEntryTypeConfig = { - id: string; + id: PlatformCreationTypeId; title: string; subtitle: string; badge: string; @@ -27,7 +28,7 @@ export type UnifiedCreationField = { /** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */ export type UnifiedCreationSpec = { - playId: string; + playId: PlatformCreationTypeId; title: string; workspaceStage: string; generationStage: string;