fix: tighten public work type routing
This commit is contained in:
@@ -31,6 +31,22 @@
|
|||||||
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。
|
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`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 改成深色文字
|
## 暗色创作进度卡不要被 platform-remap-surface 改成深色文字
|
||||||
|
|
||||||
- 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。
|
- 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
- 旧 view 退到底层 source / 兼容职责。
|
- 旧 view 退到底层 source / 兼容职责。
|
||||||
- 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。
|
- 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。
|
||||||
- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。
|
- 各玩法 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。
|
- 临时运行约束: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。
|
- 旧 `/api/runtime/<play>/gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。
|
||||||
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。
|
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`CustomWorldProfile`
|
- Rust 结构体:`CustomWorldProfile`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.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`
|
### `custom_world_session`
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
- 推荐页嵌入拼图运行态时,“下一关”必须走推荐页统一相邻作品切换流程,不得由拼图 runtime 自己传递 `preferSimilarWork` 或私自把当前 run handoff 到其它拼图作品。点击后应与推荐页底部“下一个”使用同一套 `activeRecommendEntryKey` / 推荐队列切换和新作品启动语义,推荐卡标题、分享 / 点赞 / 改造基准都由统一推荐切换结果决定。切换发起前仍必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;后续局部同步状态由推荐页启动新作品的统一 busy 表现承接。
|
- 推荐页嵌入拼图运行态时,“下一关”必须走推荐页统一相邻作品切换流程,不得由拼图 runtime 自己传递 `preferSimilarWork` 或私自把当前 run handoff 到其它拼图作品。点击后应与推荐页底部“下一个”使用同一套 `activeRecommendEntryKey` / 推荐队列切换和新作品启动语义,推荐卡标题、分享 / 点赞 / 改造基准都由统一推荐切换结果决定。切换发起前仍必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;后续局部同步状态由推荐页启动新作品的统一 busy 表现承接。
|
||||||
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。
|
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。
|
||||||
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。
|
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `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 './jumpHop';
|
||||||
export type * from './puzzleCreativeTemplate';
|
export type * from './puzzleCreativeTemplate';
|
||||||
export type * from './puzzleClear';
|
export type * from './puzzleClear';
|
||||||
|
export * from './playTypes';
|
||||||
export type * from './publicWork';
|
export type * from './publicWork';
|
||||||
export type * from './visualNovel';
|
export type * from './visualNovel';
|
||||||
export type * from './barkBattle';
|
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 {
|
export interface PublicWorkGalleryEntryResponse {
|
||||||
sourceType: string;
|
sourceType: PublicWorkSourceType;
|
||||||
workId: string;
|
workId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
sourceSessionId?: string | null;
|
sourceSessionId?: string | null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type * from './contracts/hyper3d';
|
|||||||
export * from './contracts/match3dAgent';
|
export * from './contracts/match3dAgent';
|
||||||
export * from './contracts/match3dRuntime';
|
export * from './contracts/match3dRuntime';
|
||||||
export * from './contracts/match3dWorks';
|
export * from './contracts/match3dWorks';
|
||||||
|
export * from './contracts/playTypes';
|
||||||
export * from './contracts/puzzleAgentActions';
|
export * from './contracts/puzzleAgentActions';
|
||||||
export * from './contracts/puzzleAgentDraft';
|
export * from './contracts/puzzleAgentDraft';
|
||||||
export * from './contracts/puzzleAgentSession';
|
export * from './contracts/puzzleAgentSession';
|
||||||
|
|||||||
@@ -1662,9 +1662,7 @@ fn get_custom_world_gallery_detail_record(
|
|||||||
.find(&input.profile_id)
|
.find(&input.profile_id)
|
||||||
.filter(|row| {
|
.filter(|row| {
|
||||||
row.owner_user_id == input.owner_user_id
|
row.owner_user_id == input.owner_user_id
|
||||||
&& row.publication_status == CustomWorldPublicationStatus::Published
|
&& is_custom_world_profile_publicly_interactive(row)
|
||||||
&& row.deleted_at.is_none()
|
|
||||||
&& row.visible
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let gallery_entry = ctx
|
let gallery_entry = ctx
|
||||||
@@ -1712,8 +1710,7 @@ fn get_custom_world_gallery_detail_record_by_code(
|
|||||||
.find(&row.profile_id)
|
.find(&row.profile_id)
|
||||||
.filter(|profile_row| {
|
.filter(|profile_row| {
|
||||||
profile_row.owner_user_id == row.owner_user_id
|
profile_row.owner_user_id == row.owner_user_id
|
||||||
&& profile_row.publication_status == CustomWorldPublicationStatus::Published
|
&& is_custom_world_profile_publicly_interactive(profile_row)
|
||||||
&& profile_row.deleted_at.is_none()
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1756,12 +1753,7 @@ fn remix_custom_world_profile_record(
|
|||||||
.profile_id()
|
.profile_id()
|
||||||
.find(&source_profile_id.to_string())
|
.find(&source_profile_id.to_string())
|
||||||
.filter(|row| row.owner_user_id == source_owner_user_id)
|
.filter(|row| row.owner_user_id == source_owner_user_id)
|
||||||
.filter(|row| {
|
.filter(is_custom_world_profile_publicly_interactive)
|
||||||
row.publication_status == CustomWorldPublicationStatus::Published
|
|
||||||
&& row.deleted_at.is_none()
|
|
||||||
&& row.visible
|
|
||||||
&& row.published_at.is_some()
|
|
||||||
})
|
|
||||||
.ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?;
|
.ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?;
|
||||||
let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros);
|
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()
|
.profile_id()
|
||||||
.find(&profile_id.to_string())
|
.find(&profile_id.to_string())
|
||||||
.filter(|row| row.owner_user_id == owner_user_id)
|
.filter(|row| row.owner_user_id == owner_user_id)
|
||||||
.filter(|row| {
|
.filter(is_custom_world_profile_publicly_interactive)
|
||||||
row.publication_status == CustomWorldPublicationStatus::Published
|
|
||||||
&& row.deleted_at.is_none()
|
|
||||||
&& row.visible
|
|
||||||
&& row.published_at.is_some()
|
|
||||||
})
|
|
||||||
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
|
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
|
||||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
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()
|
.profile_id()
|
||||||
.find(&profile_id.to_string())
|
.find(&profile_id.to_string())
|
||||||
.filter(|row| row.owner_user_id == owner_user_id)
|
.filter(|row| row.owner_user_id == owner_user_id)
|
||||||
.filter(|row| {
|
.filter(is_custom_world_profile_publicly_interactive)
|
||||||
row.publication_status == CustomWorldPublicationStatus::Published
|
|
||||||
&& row.deleted_at.is_none()
|
|
||||||
&& row.visible
|
|
||||||
&& row.published_at.is_some()
|
|
||||||
})
|
|
||||||
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
|
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
|
||||||
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
|
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(
|
fn list_custom_world_work_snapshots(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: CustomWorldWorksListInput,
|
input: CustomWorldWorksListInput,
|
||||||
@@ -4832,9 +4826,10 @@ fn sync_custom_world_gallery_entry_from_profile(
|
|||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
profile: &CustomWorldProfile,
|
profile: &CustomWorldProfile,
|
||||||
) -> Result<CustomWorldGalleryEntrySnapshot, String> {
|
) -> Result<CustomWorldGalleryEntrySnapshot, String> {
|
||||||
let published_at = profile
|
if profile.publication_status != CustomWorldPublicationStatus::Published {
|
||||||
.published_at
|
return Err("custom_world profile 未发布,无法同步 gallery".to_string());
|
||||||
.ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?;
|
}
|
||||||
|
let published_at = resolve_custom_world_published_at(profile);
|
||||||
|
|
||||||
ctx.db
|
ctx.db
|
||||||
.custom_world_gallery_entry()
|
.custom_world_gallery_entry()
|
||||||
@@ -4881,10 +4876,6 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(),
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for profile in published_profiles {
|
for profile in published_profiles {
|
||||||
if profile.published_at.is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let existing_gallery_entry = ctx
|
let existing_gallery_entry = ctx
|
||||||
.db
|
.db
|
||||||
.custom_world_gallery_entry()
|
.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]
|
#[test]
|
||||||
fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() {
|
fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() {
|
||||||
fn build_test_custom_world_profile(
|
fn build_test_custom_world_profile(
|
||||||
|
|||||||
@@ -1573,11 +1573,6 @@ mod tests {
|
|||||||
5, 1_000, &existing
|
5, 1_000, &existing
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jump_hop_delete_input_carries_owner_and_profile() {
|
fn jump_hop_delete_input_carries_owner_and_profile() {
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ import {
|
|||||||
buildPlatformPublicGalleryCardKey,
|
buildPlatformPublicGalleryCardKey,
|
||||||
isBarkBattleGalleryEntry,
|
isBarkBattleGalleryEntry,
|
||||||
isBigFishGalleryEntry,
|
isBigFishGalleryEntry,
|
||||||
|
isCustomWorldGalleryEntry,
|
||||||
isEdutainmentGalleryEntry,
|
isEdutainmentGalleryEntry,
|
||||||
isJumpHopGalleryEntry,
|
isJumpHopGalleryEntry,
|
||||||
isMatch3DGalleryEntry,
|
isMatch3DGalleryEntry,
|
||||||
@@ -701,6 +702,10 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
|||||||
function getPlatformRecommendRuntimeKind(
|
function getPlatformRecommendRuntimeKind(
|
||||||
entry: PlatformPublicGalleryCard,
|
entry: PlatformPublicGalleryCard,
|
||||||
): RecommendRuntimeKind {
|
): RecommendRuntimeKind {
|
||||||
|
if (isCustomWorldGalleryEntry(entry)) {
|
||||||
|
return 'rpg';
|
||||||
|
}
|
||||||
|
|
||||||
if (isBigFishGalleryEntry(entry)) {
|
if (isBigFishGalleryEntry(entry)) {
|
||||||
return 'big-fish';
|
return 'big-fish';
|
||||||
}
|
}
|
||||||
@@ -741,7 +746,7 @@ function getPlatformRecommendRuntimeKind(
|
|||||||
return 'edutainment';
|
return 'edutainment';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'rpg';
|
throw new Error('未知公开作品类型,无法启动推荐玩法。');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRecommendEntryShareStage(
|
function resolveRecommendEntryShareStage(
|
||||||
@@ -758,6 +763,14 @@ function resolveRecommendEntryShareStage(
|
|||||||
return 'work-detail';
|
return 'work-detail';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveUnsupportedPublicWorkActionMessage(
|
||||||
|
entry: PlatformPublicGalleryCard,
|
||||||
|
actionLabel: string,
|
||||||
|
) {
|
||||||
|
const sourceType = 'sourceType' in entry ? entry.sourceType : 'custom-world';
|
||||||
|
return `作品类型 ${sourceType} 暂不支持${actionLabel}。`;
|
||||||
|
}
|
||||||
|
|
||||||
function isRecommendRuntimeReadyForEntry(
|
function isRecommendRuntimeReadyForEntry(
|
||||||
entry: PlatformPublicGalleryCard,
|
entry: PlatformPublicGalleryCard,
|
||||||
state: RecommendRuntimeState,
|
state: RecommendRuntimeState,
|
||||||
@@ -800,8 +813,11 @@ function isRecommendRuntimeReadyForEntry(
|
|||||||
if (expectedKind === 'edutainment') {
|
if (expectedKind === 'edutainment') {
|
||||||
return Boolean(state.babyObjectMatchDraft);
|
return Boolean(state.babyObjectMatchDraft);
|
||||||
}
|
}
|
||||||
|
if (expectedKind === 'rpg') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
throw new Error('未知推荐玩法类型。');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSamePlatformPublicGalleryEntry(
|
function isSamePlatformPublicGalleryEntry(
|
||||||
@@ -834,7 +850,10 @@ function mergePlatformPublicGalleryEntries(
|
|||||||
function mapRpgGalleryCardToPublicWorkDetail(
|
function mapRpgGalleryCardToPublicWorkDetail(
|
||||||
entry: CustomWorldGalleryCard,
|
entry: CustomWorldGalleryCard,
|
||||||
): PlatformPublicGalleryCard {
|
): PlatformPublicGalleryCard {
|
||||||
return entry;
|
return {
|
||||||
|
...entry,
|
||||||
|
sourceType: 'custom-world',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapPuzzleWorkToPublicWorkDetail(
|
function mapPuzzleWorkToPublicWorkDetail(
|
||||||
@@ -13521,54 +13540,55 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdutainmentGalleryEntry(entry)) {
|
if (isCustomWorldGalleryEntry(entry)) {
|
||||||
setPublicWorkDetailError('宝贝识物点赞将在后续版本开放。');
|
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);
|
setIsPublicWorkDetailBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBarkBattleGalleryEntry(entry)) {
|
setPublicWorkDetailError('未知公开作品类型,无法点赞。');
|
||||||
setPublicWorkDetailError('汪汪声浪点赞将在后续版本开放。');
|
setIsPublicWorkDetailBusy(false);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -14189,7 +14209,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void openRpgPublicWorkDetail(entry);
|
if (isCustomWorldGalleryEntry(entry)) {
|
||||||
|
void openRpgPublicWorkDetail(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicWorkDetailError('未知公开作品类型,无法打开作品详情。');
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
openPuzzlePublicWorkDetail,
|
openPuzzlePublicWorkDetail,
|
||||||
@@ -15506,14 +15531,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) {
|
|
||||||
setPublicWorkDetailError(null);
|
|
||||||
void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, {
|
|
||||||
returnStage: 'work-detail',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) {
|
if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) {
|
||||||
setPublicWorkDetailError(null);
|
setPublicWorkDetailError(null);
|
||||||
void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, {
|
void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, {
|
||||||
@@ -15588,6 +15605,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCustomWorldGalleryEntry(selectedPublicWorkDetail)) {
|
||||||
|
setPublicWorkDetailError('未知公开作品类型,无法进入玩法。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const launchEntry =
|
const launchEntry =
|
||||||
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
|
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
|
||||||
? selectedDetailEntry
|
? selectedDetailEntry
|
||||||
@@ -15759,8 +15781,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
embedded: true,
|
embedded: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else if (isCustomWorldGalleryEntry(entry)) {
|
||||||
started = true;
|
started = true;
|
||||||
|
} else {
|
||||||
|
throw new Error('未知公开作品类型,无法启动推荐玩法。');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCurrentStartRequest()) {
|
if (!isCurrentStartRequest()) {
|
||||||
@@ -16403,74 +16427,49 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPuzzleClearGalleryEntry(entry)) {
|
if (
|
||||||
setPublicWorkDetailError('拼消消作品改造将在后续版本开放。');
|
isPuzzleClearGalleryEntry(entry) ||
|
||||||
|
isMatch3DGalleryEntry(entry) ||
|
||||||
|
isSquareHoleGalleryEntry(entry) ||
|
||||||
|
isJumpHopGalleryEntry(entry) ||
|
||||||
|
isWoodenFishGalleryEntry(entry) ||
|
||||||
|
isVisualNovelGalleryEntry(entry) ||
|
||||||
|
isEdutainmentGalleryEntry(entry) ||
|
||||||
|
isBarkBattleGalleryEntry(entry)
|
||||||
|
) {
|
||||||
|
setPublicWorkDetailError(
|
||||||
|
resolveUnsupportedPublicWorkActionMessage(entry, '改造'),
|
||||||
|
);
|
||||||
setIsPublicWorkDetailBusy(false);
|
setIsPublicWorkDetailBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMatch3DGalleryEntry(entry)) {
|
if (isCustomWorldGalleryEntry(entry)) {
|
||||||
setPublicWorkDetailError('抓大鹅作品改造将在后续版本开放。');
|
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
|
||||||
setIsPublicWorkDetailBusy(false);
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSquareHoleGalleryEntry(entry)) {
|
setPublicWorkDetailError('未知公开作品类型,无法改造。');
|
||||||
setPublicWorkDetailError('方洞挑战作品改造将在后续版本开放。');
|
setIsPublicWorkDetailBusy(false);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -16630,6 +16629,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCustomWorldGalleryEntry(entry)) {
|
||||||
|
setPublicWorkDetailError('未知公开作品类型,无法编辑。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const editEntry =
|
const editEntry =
|
||||||
selectedDetailEntry?.profileId === entry.profileId
|
selectedDetailEntry?.profileId === entry.profileId
|
||||||
? selectedDetailEntry
|
? selectedDetailEntry
|
||||||
@@ -16737,6 +16741,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const entry =
|
const entry =
|
||||||
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
|
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
|
||||||
const card = {
|
const card = {
|
||||||
|
sourceType: 'custom-world',
|
||||||
ownerUserId: entry.ownerUserId,
|
ownerUserId: entry.ownerUserId,
|
||||||
profileId: entry.profileId,
|
profileId: entry.profileId,
|
||||||
publicWorkCode: entry.publicWorkCode,
|
publicWorkCode: entry.publicWorkCode,
|
||||||
@@ -16755,7 +16760,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
playCount: entry.playCount ?? 0,
|
playCount: entry.playCount ?? 0,
|
||||||
remixCount: entry.remixCount ?? 0,
|
remixCount: entry.remixCount ?? 0,
|
||||||
likeCount: entry.likeCount ?? 0,
|
likeCount: entry.likeCount ?? 0,
|
||||||
} satisfies CustomWorldGalleryCard;
|
} satisfies PlatformPublicGalleryCard;
|
||||||
if (!canExposePublicWork(card)) {
|
if (!canExposePublicWork(card)) {
|
||||||
throw new Error(EDUTAINMENT_HIDDEN_MESSAGE);
|
throw new Error(EDUTAINMENT_HIDDEN_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import {
|
|||||||
formatPlatformWorkDisplayTags,
|
formatPlatformWorkDisplayTags,
|
||||||
formatPlatformWorldTime,
|
formatPlatformWorldTime,
|
||||||
isBarkBattleGalleryEntry,
|
isBarkBattleGalleryEntry,
|
||||||
|
isCustomWorldGalleryEntry,
|
||||||
isEdutainmentGalleryEntry,
|
isEdutainmentGalleryEntry,
|
||||||
|
isJumpHopGalleryEntry,
|
||||||
|
isPuzzleClearGalleryEntry,
|
||||||
|
isWoodenFishGalleryEntry,
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
resolvePlatformWorkAuthorDisplayName,
|
resolvePlatformWorkAuthorDisplayName,
|
||||||
resolvePlatformPublicWorkCode,
|
resolvePlatformPublicWorkCode,
|
||||||
@@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
|||||||
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
|
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
|
||||||
return '拼图';
|
return '拼图';
|
||||||
}
|
}
|
||||||
|
if (isPuzzleClearGalleryEntry(entry)) {
|
||||||
|
return '拼消消';
|
||||||
|
}
|
||||||
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
||||||
return '大鱼吃小鱼';
|
return '大鱼吃小鱼';
|
||||||
}
|
}
|
||||||
|
if (isJumpHopGalleryEntry(entry)) {
|
||||||
|
return '跳一跳';
|
||||||
|
}
|
||||||
|
if (isWoodenFishGalleryEntry(entry)) {
|
||||||
|
return '敲木鱼';
|
||||||
|
}
|
||||||
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
||||||
return '抓大鹅';
|
return '抓大鹅';
|
||||||
}
|
}
|
||||||
@@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
|||||||
if (isEdutainmentGalleryEntry(entry)) {
|
if (isEdutainmentGalleryEntry(entry)) {
|
||||||
return entry.templateName;
|
return entry.templateName;
|
||||||
}
|
}
|
||||||
return 'RPG';
|
if (isCustomWorldGalleryEntry(entry)) {
|
||||||
|
return 'RPG';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('未知公开作品类型。');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthorAvatarLabel(authorDisplayName: string) {
|
function getAuthorAvatarLabel(authorDisplayName: string) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||||
import {
|
import {
|
||||||
derivePlatformCreationTypes,
|
derivePlatformCreationTypes,
|
||||||
groupVisiblePlatformCreationTypes,
|
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', () => {
|
test('visible platform creation types hide invisible cards and put locked cards last', () => {
|
||||||
const cards = derivePlatformCreationTypes([
|
const cards = derivePlatformCreationTypes([
|
||||||
{
|
{
|
||||||
id: 'hidden',
|
id: 'airp',
|
||||||
title: '隐藏',
|
title: '隐藏',
|
||||||
subtitle: '隐藏',
|
subtitle: '隐藏',
|
||||||
badge: '隐藏',
|
badge: '隐藏',
|
||||||
@@ -95,7 +96,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
|||||||
updatedAtMicros: 1,
|
updatedAtMicros: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'locked',
|
id: 'visual-novel',
|
||||||
title: '锁定',
|
title: '锁定',
|
||||||
subtitle: '锁定',
|
subtitle: '锁定',
|
||||||
badge: '即将开放',
|
badge: '即将开放',
|
||||||
@@ -109,7 +110,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
|||||||
updatedAtMicros: 1,
|
updatedAtMicros: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'open',
|
id: 'rpg',
|
||||||
title: '开放',
|
title: '开放',
|
||||||
subtitle: '开放',
|
subtitle: '开放',
|
||||||
badge: '可创建',
|
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(
|
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
|
||||||
['open', 'locked'],
|
['rpg', 'visual-novel'],
|
||||||
);
|
);
|
||||||
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
|
expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false);
|
||||||
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
|
expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true);
|
||||||
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
|
expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false);
|
||||||
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
|
expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false);
|
||||||
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
|
expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
cards.every((item) =>
|
cards.every((item) =>
|
||||||
item.imageSrc.startsWith('/creation-type-references/'),
|
item.imageSrc.startsWith('/creation-type-references/'),
|
||||||
@@ -288,7 +289,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
|||||||
updatedAtMicros: 1,
|
updatedAtMicros: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hidden',
|
id: 'airp',
|
||||||
title: '隐藏入口',
|
title: '隐藏入口',
|
||||||
subtitle: '隐藏',
|
subtitle: '隐藏',
|
||||||
badge: '隐藏',
|
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', () => {
|
test('falls back when backend creation type category metadata is missing', () => {
|
||||||
const cards = derivePlatformCreationTypes([
|
const cards = derivePlatformCreationTypes([
|
||||||
{
|
{
|
||||||
id: 'legacy-entry',
|
id: 'creative-agent',
|
||||||
title: '历史入口',
|
title: '历史入口',
|
||||||
subtitle: '旧数据缺少分类字段',
|
subtitle: '旧数据缺少分类字段',
|
||||||
badge: '可创建',
|
badge: '可创建',
|
||||||
@@ -336,7 +337,7 @@ test('falls back when backend creation type category metadata is missing', () =>
|
|||||||
|
|
||||||
expect(cards[0]).toEqual(
|
expect(cards[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'legacy-entry',
|
id: 'creative-agent',
|
||||||
categoryId: 'recommended',
|
categoryId: 'recommended',
|
||||||
categoryLabel: '热门推荐',
|
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 type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||||
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
|
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
|
||||||
|
|
||||||
export type PlatformCreationTypeId = string;
|
export type { PlatformCreationTypeId };
|
||||||
|
|
||||||
export type PlatformCreationTypeCard = {
|
export type PlatformCreationTypeCard = {
|
||||||
id: PlatformCreationTypeId;
|
id: PlatformCreationTypeId;
|
||||||
@@ -117,21 +121,25 @@ export function derivePlatformCreationTypes(
|
|||||||
): PlatformCreationTypeCard[] {
|
): PlatformCreationTypeCard[] {
|
||||||
const orderedCards = [...creationTypes]
|
const orderedCards = [...creationTypes]
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||||
.map((item) => ({
|
.map((item) => {
|
||||||
id: item.id,
|
const id = assertPlatformCreationTypeId(item.id);
|
||||||
title: item.title,
|
|
||||||
subtitle: item.subtitle,
|
return {
|
||||||
badge: item.badge,
|
id,
|
||||||
imageSrc: item.imageSrc,
|
title: item.title,
|
||||||
locked: !item.open,
|
subtitle: item.subtitle,
|
||||||
categoryId: normalizeCategoryId(item.categoryId),
|
badge: item.badge,
|
||||||
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
imageSrc: item.imageSrc,
|
||||||
categorySortOrder: item.categorySortOrder,
|
locked: !item.open,
|
||||||
sortOrder: item.sortOrder,
|
categoryId: normalizeCategoryId(item.categoryId),
|
||||||
hidden:
|
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
||||||
!item.visible ||
|
categorySortOrder: item.categorySortOrder,
|
||||||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
sortOrder: item.sortOrder,
|
||||||
}));
|
hidden:
|
||||||
|
!item.visible ||
|
||||||
|
(id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...orderedCards.filter((item) => !item.hidden && !item.locked),
|
...orderedCards.filter((item) => !item.hidden && !item.locked),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
buildPlatformPublicGalleryCardKey,
|
buildPlatformPublicGalleryCardKey,
|
||||||
|
isEdutainmentGalleryEntry,
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
|
resolvePlatformPublicWorkSourceType,
|
||||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
const MS_PER_DAY = 86_400_000;
|
||||||
@@ -70,19 +72,11 @@ function getRecommendationMetric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
|
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
|
||||||
if ('sourceType' in entry) {
|
if (isEdutainmentGalleryEntry(entry)) {
|
||||||
if (
|
return `edutainment:${entry.templateId}`;
|
||||||
entry.sourceType === 'edutainment' &&
|
|
||||||
'templateId' in entry &&
|
|
||||||
entry.templateId
|
|
||||||
) {
|
|
||||||
return `edutainment:${entry.templateId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.sourceType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'rpg';
|
return resolvePlatformPublicWorkSourceType(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
|
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ import type {
|
|||||||
CustomWorldGalleryCard,
|
CustomWorldGalleryCard,
|
||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} 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 { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import {
|
import {
|
||||||
@@ -155,6 +158,7 @@ import {
|
|||||||
deleteRpgEntryWorldProfile,
|
deleteRpgEntryWorldProfile,
|
||||||
getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient,
|
getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient,
|
||||||
getRpgEntryWorldGalleryDetailByCode,
|
getRpgEntryWorldGalleryDetailByCode,
|
||||||
|
likeRpgEntryWorldGallery,
|
||||||
recordRpgEntryWorldGalleryPlay,
|
recordRpgEntryWorldGalleryPlay,
|
||||||
remixRpgEntryWorldGallery,
|
remixRpgEntryWorldGallery,
|
||||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||||
@@ -538,6 +542,7 @@ const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({
|
|||||||
getRpgEntryWorldGalleryDetail: vi.fn(),
|
getRpgEntryWorldGalleryDetail: vi.fn(),
|
||||||
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||||||
getRpgEntryWorldLibraryDetail: vi.fn(),
|
getRpgEntryWorldLibraryDetail: vi.fn(),
|
||||||
|
likeRpgEntryWorldGallery: vi.fn(),
|
||||||
listRpgEntryWorldGallery: vi.fn(),
|
listRpgEntryWorldGallery: vi.fn(),
|
||||||
listRpgEntryWorldLibrary: vi.fn(),
|
listRpgEntryWorldLibrary: vi.fn(),
|
||||||
publishRpgEntryWorldProfile: vi.fn(),
|
publishRpgEntryWorldProfile: vi.fn(),
|
||||||
@@ -7365,6 +7370,75 @@ test('home recommendation share opens publish share modal', async () => {
|
|||||||
.toBeTruthy();
|
.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 () => {
|
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
|
||||||
const publishedPuzzleWork = {
|
const publishedPuzzleWork = {
|
||||||
workId: 'puzzle-work-public-2',
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ import {
|
|||||||
formatPlatformWorldTime,
|
formatPlatformWorldTime,
|
||||||
isBarkBattleGalleryEntry,
|
isBarkBattleGalleryEntry,
|
||||||
isBigFishGalleryEntry,
|
isBigFishGalleryEntry,
|
||||||
|
isCustomWorldGalleryEntry,
|
||||||
isEdutainmentGalleryEntry,
|
isEdutainmentGalleryEntry,
|
||||||
isJumpHopGalleryEntry,
|
isJumpHopGalleryEntry,
|
||||||
isMatch3DGalleryEntry,
|
isMatch3DGalleryEntry,
|
||||||
@@ -373,11 +374,15 @@ type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
|
|||||||
type PlatformCategoryKindFilter =
|
type PlatformCategoryKindFilter =
|
||||||
| 'all'
|
| 'all'
|
||||||
| 'puzzle'
|
| 'puzzle'
|
||||||
|
| 'puzzle-clear'
|
||||||
|
| 'jump-hop'
|
||||||
|
| 'wooden-fish'
|
||||||
| 'match3d'
|
| 'match3d'
|
||||||
| 'square-hole'
|
| 'square-hole'
|
||||||
| 'visual-novel'
|
| 'visual-novel'
|
||||||
| 'bark-battle'
|
| 'bark-battle'
|
||||||
| 'big-fish'
|
| 'big-fish'
|
||||||
|
| 'edutainment'
|
||||||
| 'custom-world';
|
| 'custom-world';
|
||||||
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
|
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
|
||||||
|
|
||||||
@@ -413,11 +418,15 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{
|
|||||||
}> = [
|
}> = [
|
||||||
{ id: 'all', label: '全部' },
|
{ id: 'all', label: '全部' },
|
||||||
{ id: 'puzzle', label: '拼图' },
|
{ id: 'puzzle', label: '拼图' },
|
||||||
|
{ id: 'puzzle-clear', label: '拼消' },
|
||||||
|
{ id: 'jump-hop', label: '跳一跳' },
|
||||||
|
{ id: 'wooden-fish', label: '木鱼' },
|
||||||
{ id: 'match3d', label: '抓鹅' },
|
{ id: 'match3d', label: '抓鹅' },
|
||||||
{ id: 'square-hole', label: '方洞' },
|
{ id: 'square-hole', label: '方洞' },
|
||||||
{ id: 'visual-novel', label: '视觉' },
|
{ id: 'visual-novel', label: '视觉' },
|
||||||
{ id: 'bark-battle', label: '汪汪' },
|
{ id: 'bark-battle', label: '汪汪' },
|
||||||
{ id: 'big-fish', label: '大鱼' },
|
{ id: 'big-fish', label: '大鱼' },
|
||||||
|
{ id: 'edutainment', label: EDUTAINMENT_WORK_TAG },
|
||||||
{ id: 'custom-world', label: 'RPG' },
|
{ id: 'custom-world', label: 'RPG' },
|
||||||
];
|
];
|
||||||
const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{
|
const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{
|
||||||
@@ -2192,6 +2201,18 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
|
|||||||
return 'puzzle';
|
return 'puzzle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPuzzleClearGalleryEntry(entry)) {
|
||||||
|
return 'puzzle-clear';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJumpHopGalleryEntry(entry)) {
|
||||||
|
return 'jump-hop';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWoodenFishGalleryEntry(entry)) {
|
||||||
|
return 'wooden-fish';
|
||||||
|
}
|
||||||
|
|
||||||
if (isMatch3DGalleryEntry(entry)) {
|
if (isMatch3DGalleryEntry(entry)) {
|
||||||
return 'match3d';
|
return 'match3d';
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2233,15 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
|
|||||||
return 'big-fish';
|
return 'big-fish';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'custom-world';
|
if (isEdutainmentGalleryEntry(entry)) {
|
||||||
|
return 'edutainment';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCustomWorldGalleryEntry(entry)) {
|
||||||
|
return 'custom-world';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('未知公开作品类型。');
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesPlatformCategoryKindFilter(
|
function matchesPlatformCategoryKindFilter(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
CustomWorldGalleryCard,
|
CustomWorldGalleryCard,
|
||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
|
||||||
import type {
|
import type {
|
||||||
SquareHoleHoleOption,
|
SquareHoleHoleOption,
|
||||||
SquareHoleShapeOption,
|
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_ID = 'baby-object-match';
|
||||||
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
||||||
|
|
||||||
|
export type PlatformCustomWorldGalleryCard = CustomWorldGalleryCard & {
|
||||||
|
sourceType?: 'custom-world';
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformWorldCardLike =
|
export type PlatformWorldCardLike =
|
||||||
| CustomWorldGalleryCard
|
| PlatformCustomWorldGalleryCard
|
||||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||||
| PlatformBigFishGalleryCard
|
| PlatformBigFishGalleryCard
|
||||||
| PlatformMatch3DGalleryCard
|
| PlatformMatch3DGalleryCard
|
||||||
@@ -319,7 +324,7 @@ export type PlatformBarkBattleGalleryCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformPublicGalleryCard =
|
export type PlatformPublicGalleryCard =
|
||||||
| CustomWorldGalleryCard
|
| PlatformCustomWorldGalleryCard
|
||||||
| PlatformBigFishGalleryCard
|
| PlatformBigFishGalleryCard
|
||||||
| PlatformMatch3DGalleryCard
|
| PlatformMatch3DGalleryCard
|
||||||
| PlatformSquareHoleGalleryCard
|
| PlatformSquareHoleGalleryCard
|
||||||
@@ -337,6 +342,14 @@ export function isLibraryWorldEntry(
|
|||||||
return 'profile' in entry;
|
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(
|
export function isPuzzleGalleryEntry(
|
||||||
entry: PlatformWorldCardLike,
|
entry: PlatformWorldCardLike,
|
||||||
): entry is PlatformPuzzleGalleryCard {
|
): entry is PlatformPuzzleGalleryCard {
|
||||||
@@ -397,28 +410,62 @@ export function isBarkBattleGalleryEntry(
|
|||||||
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
|
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(
|
export function buildPlatformPublicGalleryCardKey(
|
||||||
entry: PlatformPublicGalleryCard,
|
entry: PlatformPublicGalleryCard,
|
||||||
) {
|
) {
|
||||||
const kind = isBigFishGalleryEntry(entry)
|
const kind = isEdutainmentGalleryEntry(entry)
|
||||||
? 'big-fish'
|
? `edutainment:${entry.templateId}`
|
||||||
: isPuzzleGalleryEntry(entry)
|
: resolvePlatformPublicWorkSourceType(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';
|
|
||||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +915,11 @@ export function resolvePlatformWorldFallbackCoverImage(
|
|||||||
return '/creation-type-references/bark-battle.webp';
|
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(
|
export function resolvePlatformWorldCoverSlides(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('unified creation specs', () => {
|
|||||||
'jump-hop',
|
'jump-hop',
|
||||||
'match3d',
|
'match3d',
|
||||||
'puzzle',
|
'puzzle',
|
||||||
|
'puzzle-clear',
|
||||||
'rpg',
|
'rpg',
|
||||||
'square-hole',
|
'square-hole',
|
||||||
'visual-novel',
|
'visual-novel',
|
||||||
@@ -47,6 +48,11 @@ describe('unified creation specs', () => {
|
|||||||
generationStage: 'puzzle-generating',
|
generationStage: 'puzzle-generating',
|
||||||
resultStage: 'puzzle-result',
|
resultStage: 'puzzle-result',
|
||||||
});
|
});
|
||||||
|
expect(getUnifiedCreationSpec('puzzle-clear')).toMatchObject({
|
||||||
|
workspaceStage: 'puzzle-clear-workspace',
|
||||||
|
generationStage: 'puzzle-clear-generating',
|
||||||
|
resultStage: 'puzzle-clear-result',
|
||||||
|
});
|
||||||
expect(getUnifiedCreationSpec('match3d')).toMatchObject({
|
expect(getUnifiedCreationSpec('match3d')).toMatchObject({
|
||||||
title: '抓大鹅',
|
title: '抓大鹅',
|
||||||
workspaceStage: 'match3d-agent-workspace',
|
workspaceStage: 'match3d-agent-workspace',
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type {
|
|||||||
CreationEntryTypeConfig,
|
CreationEntryTypeConfig,
|
||||||
UnifiedCreationSpec,
|
UnifiedCreationSpec,
|
||||||
} from '../../services/creationEntryConfigService';
|
} from '../../services/creationEntryConfigService';
|
||||||
|
import type { PlatformCreationTypeId } from '../../../packages/shared/src/contracts/playTypes';
|
||||||
|
|
||||||
export const UNIFIED_CREATION_PLAY_IDS = [
|
export const UNIFIED_CREATION_PLAY_IDS = [
|
||||||
'rpg',
|
'rpg',
|
||||||
'big-fish',
|
'big-fish',
|
||||||
'puzzle',
|
'puzzle',
|
||||||
|
'puzzle-clear',
|
||||||
'match3d',
|
'match3d',
|
||||||
'jump-hop',
|
'jump-hop',
|
||||||
'wooden-fish',
|
'wooden-fish',
|
||||||
@@ -15,7 +17,7 @@ export const UNIFIED_CREATION_PLAY_IDS = [
|
|||||||
'visual-novel',
|
'visual-novel',
|
||||||
'baby-object-match',
|
'baby-object-match',
|
||||||
'creative-agent',
|
'creative-agent',
|
||||||
] as const;
|
] as const satisfies readonly PlatformCreationTypeId[];
|
||||||
|
|
||||||
export type UnifiedCreationPlayId =
|
export type UnifiedCreationPlayId =
|
||||||
(typeof UNIFIED_CREATION_PLAY_IDS)[number];
|
(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: {
|
match3d: {
|
||||||
playId: 'match3d',
|
playId: 'match3d',
|
||||||
title: '抓大鹅',
|
title: '抓大鹅',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { PlatformCreationTypeId } from '../../packages/shared/src/contracts/playTypes';
|
||||||
import { requestJson } from './apiClient';
|
import { requestJson } from './apiClient';
|
||||||
|
|
||||||
/** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */
|
/** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */
|
||||||
export type CreationEntryTypeConfig = {
|
export type CreationEntryTypeConfig = {
|
||||||
id: string;
|
id: PlatformCreationTypeId;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
badge: string;
|
badge: string;
|
||||||
@@ -27,7 +28,7 @@ export type UnifiedCreationField = {
|
|||||||
|
|
||||||
/** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */
|
/** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */
|
||||||
export type UnifiedCreationSpec = {
|
export type UnifiedCreationSpec = {
|
||||||
playId: string;
|
playId: PlatformCreationTypeId;
|
||||||
title: string;
|
title: string;
|
||||||
workspaceStage: string;
|
workspaceStage: string;
|
||||||
generationStage: string;
|
generationStage: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user