fix: tighten public work type routing

This commit is contained in:
2026-06-07 14:49:36 +08:00
parent 8f460feb41
commit 8131894bb5
21 changed files with 611 additions and 228 deletions

View File

@@ -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 改成深色文字
- 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。

View File

@@ -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 仍走玩法专用路径。

View File

@@ -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`

View File

@@ -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` 为准,新增玩法必须先补类型再补推荐页、详情页、分类页和公开互动分支。
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
## 跳一跳

View File

@@ -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';

View 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}`);
}

View File

@@ -1,5 +1,7 @@
import type { PublicWorkSourceType } from './playTypes';
export interface PublicWorkGalleryEntryResponse {
sourceType: string;
sourceType: PublicWorkSourceType;
workId: string;
profileId: string;
sourceSessionId?: string | null;

View File

@@ -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';

View File

@@ -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(&current_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(

View File

@@ -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() {

View File

@@ -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,10 +813,13 @@ function isRecommendRuntimeReadyForEntry(
if (expectedKind === 'edutainment') {
return Boolean(state.babyObjectMatchDraft);
}
if (expectedKind === 'rpg') {
return true;
}
throw new Error('未知推荐玩法类型。');
}
function isSamePlatformPublicGalleryEntry(
left: PlatformPublicGalleryCard,
right: PlatformPublicGalleryCard,
@@ -834,7 +850,10 @@ function mergePlatformPublicGalleryEntries(
function mapRpgGalleryCardToPublicWorkDetail(
entry: CustomWorldGalleryCard,
): PlatformPublicGalleryCard {
return entry;
return {
...entry,
sourceType: 'custom-world',
};
}
function mapPuzzleWorkToPublicWorkDetail(
@@ -13521,30 +13540,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isEdutainmentGalleryEntry(entry)) {
setPublicWorkDetailError('宝贝识物点赞将在后续版本开放。');
setIsPublicWorkDetailBusy(false);
return;
}
if (isBarkBattleGalleryEntry(entry)) {
setPublicWorkDetailError('汪汪声浪点赞将在后续版本开放。');
setIsPublicWorkDetailBusy(false);
return;
}
if (isSquareHoleGalleryEntry(entry)) {
setPublicWorkDetailError('方洞挑战点赞将在后续版本开放。');
setIsPublicWorkDetailBusy(false);
return;
}
if (isVisualNovelGalleryEntry(entry)) {
setPublicWorkDetailError('视觉小说点赞将在后续版本开放。');
setIsPublicWorkDetailBusy(false);
return;
}
if (isCustomWorldGalleryEntry(entry)) {
void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
.then((updatedEntry) => {
setSelectedDetailEntry((current) =>
@@ -13554,7 +13550,9 @@ export function PlatformEntryFlowShellImpl({
);
platformBootstrap.setPublishedGalleryEntries((current) =>
current.map((item) =>
item.profileId === updatedEntry.profileId ? updatedEntry : item,
item.profileId === updatedEntry.profileId
? updatedEntry
: item,
),
);
syncUpdatedPublicWorkDetail(
@@ -13569,6 +13567,28 @@ export function PlatformEntryFlowShellImpl({
.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;
}
setPublicWorkDetailError('未知公开作品类型,无法点赞。');
setIsPublicWorkDetailBusy(false);
});
},
[
@@ -14189,7 +14209,12 @@ export function PlatformEntryFlowShellImpl({
return;
}
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,54 +16427,24 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isPuzzleClearGalleryEntry(entry)) {
setPublicWorkDetailError('拼消消作品改造将在后续版本开放。');
setIsPublicWorkDetailBusy(false);
return;
}
if (isMatch3DGalleryEntry(entry)) {
setPublicWorkDetailError('抓大鹅作品改造将在后续版本开放。');
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('汪汪声浪作品改造将在后续版本开放。');
if (
isPuzzleClearGalleryEntry(entry) ||
isMatch3DGalleryEntry(entry) ||
isSquareHoleGalleryEntry(entry) ||
isJumpHopGalleryEntry(entry) ||
isWoodenFishGalleryEntry(entry) ||
isVisualNovelGalleryEntry(entry) ||
isEdutainmentGalleryEntry(entry) ||
isBarkBattleGalleryEntry(entry)
) {
setPublicWorkDetailError(
resolveUnsupportedPublicWorkActionMessage(entry, '改造'),
);
setIsPublicWorkDetailBusy(false);
return;
}
if (isCustomWorldGalleryEntry(entry)) {
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
.then((response) => {
const nextEntry = response.entry;
@@ -16471,6 +16465,11 @@ export function PlatformEntryFlowShellImpl({
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
return;
}
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);
}

View File

@@ -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,9 +88,13 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName;
}
if (isCustomWorldGalleryEntry(entry)) {
return 'RPG';
}
throw new Error('未知公开作品类型。');
}
function getAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '作')[0] ?? '作';
}

View File

@@ -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',
);
});

View File

@@ -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,8 +121,11 @@ export function derivePlatformCreationTypes(
): PlatformCreationTypeCard[] {
const orderedCards = [...creationTypes]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((item) => ({
id: item.id,
.map((item) => {
const id = assertPlatformCreationTypeId(item.id);
return {
id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
@@ -130,8 +137,9 @@ export function derivePlatformCreationTypes(
sortOrder: item.sortOrder,
hidden:
!item.visible ||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
}));
(id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
};
});
return [
...orderedCards.filter((item) => !item.hidden && !item.locked),

View File

@@ -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
) {
if (isEdutainmentGalleryEntry(entry)) {
return `edutainment:${entry.templateId}`;
}
return entry.sourceType;
}
return 'rpg';
return resolvePlatformPublicWorkSourceType(entry);
}
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {

View File

@@ -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(
{

View File

@@ -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,9 +2233,17 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
return 'big-fish';
}
if (isEdutainmentGalleryEntry(entry)) {
return 'edutainment';
}
if (isCustomWorldGalleryEntry(entry)) {
return 'custom-world';
}
throw new Error('未知公开作品类型。');
}
function matchesPlatformCategoryKindFilter(
entry: PlatformPublicGalleryCard,
kindFilter: PlatformCategoryKindFilter,

View File

@@ -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)
const kind = isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
: resolvePlatformPublicWorkSourceType(entry);
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -868,9 +915,13 @@ export function resolvePlatformWorldFallbackCoverImage(
return '/creation-type-references/bark-battle.webp';
}
if (isCustomWorldGalleryEntry(entry) || isLibraryWorldEntry(entry)) {
return '/creation-type-references/rpg.webp';
}
throw new Error('未知公开作品类型。');
}
export function resolvePlatformWorldCoverSlides(
entry: PlatformWorldCardLike,
): PlatformPuzzleCoverSlide[] {

View File

@@ -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',

View File

@@ -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: '抓大鹅',

View File

@@ -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;