fix: tighten public work type routing
This commit is contained in:
@@ -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 改成深色文字
|
||||
|
||||
- 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。
|
||||
|
||||
@@ -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/<play>/gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。
|
||||
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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` 为准,新增玩法必须先补类型再补推荐页、详情页、分类页和公开互动分支。
|
||||
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
|
||||
|
||||
## 跳一跳
|
||||
|
||||
@@ -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';
|
||||
|
||||
72
packages/shared/src/contracts/playTypes.ts
Normal file
72
packages/shared/src/contracts/playTypes.ts
Normal file
@@ -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<string> = 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<string> = 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}`);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PublicWorkSourceType } from './playTypes';
|
||||
|
||||
export interface PublicWorkGalleryEntryResponse {
|
||||
sourceType: string;
|
||||
sourceType: PublicWorkSourceType;
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<CustomWorldGalleryEntrySnapshot, String> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<Timestamp>,
|
||||
deleted_at: Option<Timestamp>,
|
||||
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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(<TestWrapper withAuth />);
|
||||
|
||||
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<HTMLButtonElement>(
|
||||
'.platform-bottom-nav [aria-label="推荐"]',
|
||||
);
|
||||
expect(recommendNavButton).toBeTruthy();
|
||||
await user.click(recommendNavButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<CustomWorldProfile>
|
||||
| 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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '抓大鹅',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user