refactor: 收口玩过作品打开意图
This commit is contained in:
@@ -1363,6 +1363,14 @@
|
|||||||
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
||||||
- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-04 Platform Played Work Open Model 收口
|
||||||
|
|
||||||
|
- 背景:`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调内联判断 `worldType`、`worldKey` 前缀、玩法别名、目标 ID、RPG fallback 详情和大鱼吃小鱼 fallback work,壳层同时承担打开意图与异步副作用。
|
||||||
|
- 决策:新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,以 `resolvePlatformPlayedWorkOpenIntent(work)` 返回 `noop`、各玩法公开详情打开意图、`open-big-fish` 或 `open-rpg`。Module 负责玩法别名、`worldKey` 前缀兜底、big-fish gallery miss `fallbackWork` 和 RPG `CustomWorldGalleryCard` payload;壳层继续负责关闭面板、刷新 gallery、命中真实作品、打开详情和错误提示。
|
||||||
|
- 影响范围:个人“玩过作品”面板点击打开、拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 大鱼吃小鱼 / RPG 公开详情入口。
|
||||||
|
- 验证方式:`npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、相关 profile 面板交互片段、`npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-03 Public Work Presentation 收口
|
## 2026-06-03 Public Work Presentation 收口
|
||||||
|
|
||||||
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。
|
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
|
|||||||
|
|
||||||
平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。
|
平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。
|
||||||
|
|
||||||
|
个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。
|
||||||
|
|
||||||
平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||||
|
|
||||||
平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。
|
平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 【前端架构】Platform Played Work Open Model 收口计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调曾在壳层内直接判断 `worldType`、`worldKey` 前缀、玩法别名、目标 ID 兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback。壳层因此同时承载纯打开意图与异步副作用,后续新增玩法或修正玩过作品身份时缺少稳定测试面。
|
||||||
|
|
||||||
|
个人“玩过作品”的点击规则属于打开意图。壳层应只关闭面板、读取 gallery、打开详情和写错误;玩法别名、目标 ID、fallback payload 应收口到纯 **Module**。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts` 作为 Platform Played Work Open **Module**。其公开 **Interface** 为:
|
||||||
|
|
||||||
|
- `resolvePlatformPlayedWorkOpenIntent(work)`:输入 `ProfilePlayedWorkSummary`,输出 `noop`、各玩法公开详情打开意图、`open-big-fish` 或 `open-rpg`。
|
||||||
|
- `PlatformPlayedWorkOpenIntent`:描述壳层可执行的打开动作;大鱼吃小鱼意图包含 `sessionId` 和 gallery miss 时使用的 `fallbackWork`,RPG 意图包含 `CustomWorldGalleryCard` 详情 payload。
|
||||||
|
|
||||||
|
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `setIsProfilePlayStatsOpen(false)`、各玩法 `open*PublicWorkDetail`、`refreshBigFishGallery()`、大鱼 gallery 命中优先逻辑、`mapBigFishWorkToPublicWorkDetail(...)` 与错误 setter。
|
||||||
|
|
||||||
|
## Interface 约束
|
||||||
|
|
||||||
|
- `worldType` 只做小写归一,不 trim;`worldKey` 前缀匹配保持大小写敏感,延续旧行为。
|
||||||
|
- `profileId` 使用 nullish 优先级:只在 `profileId` 为 `null` / `undefined` 时从 `worldKey` 前缀兜底;空字符串仍视为缺目标并返回 `noop`。
|
||||||
|
- `puzzle` 打开时固定携带 `{ tab: 'profile' }`。
|
||||||
|
- `match3d` / `match_3d`、`square-hole` / `square_hole`、`jump-hop` / `jump_hop`、`wooden-fish` / `wooden_fish`、`big-fish` / `big_fish` 均保持既有别名。
|
||||||
|
- `big-fish` 缺 gallery 命中时使用 Module 生成的 `fallbackWork`,默认 `ownerUserId` 为空串、`authorDisplayName` 为 `worldSubtitle || '玩家'`、关卡和素材 ready 计数为 `0` / `false`。
|
||||||
|
- 未识别的 `worldType` 仍按 RPG 公开详情打开;缺 `ownerUserId` 或缺 profile 目标时返回 `noop`。
|
||||||
|
|
||||||
|
## Depth / Leverage / Locality
|
||||||
|
|
||||||
|
- **Depth**:调用方只消费一个打开 intent;玩法别名、目标 ID 兜底和 fallback payload 藏入 Module Implementation。
|
||||||
|
- **Leverage**:新增“玩过作品”玩法时,先在 intent union、resolver 与单测中定义,再让壳层 Adapter 绑定对应打开副作用。
|
||||||
|
- **Locality**:RPG fallback payload 与大鱼 fallback work 不再散落在大型壳层里,维护者可在纯测试中锁定字段契约。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- `npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`
|
||||||
|
- `npx eslint src/components/platform-entry/platformPlayedWorkOpenModel.ts src/components/platform-entry/platformPlayedWorkOpenModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel"`
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run check:encoding`
|
||||||
@@ -59,6 +59,8 @@
|
|||||||
|
|
||||||
平台公开搜索的分流顺序统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链。
|
平台公开搜索的分流顺序统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链。
|
||||||
|
|
||||||
|
个人“玩过作品”面板点击作品时,玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼缺 gallery 命中时的 fallback work 统一由 `platformPlayedWorkOpenModel.ts` 判定。平台壳只负责关闭面板、调用对应公开详情打开函数、刷新大鱼 gallery、优先使用真实 gallery 命中项和写入错误提示;不要在壳层重新维护 `worldType` / `worldKey` 分支链。
|
||||||
|
|
||||||
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
|
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
|
||||||
|
|
||||||
## RPG / 自定义世界
|
## RPG / 自定义世界
|
||||||
|
|||||||
@@ -512,6 +512,7 @@ import {
|
|||||||
resolveMatch3DRuntimeGeneratedBackgroundAsset,
|
resolveMatch3DRuntimeGeneratedBackgroundAsset,
|
||||||
resolveMatch3DRuntimeGeneratedItemAssets,
|
resolveMatch3DRuntimeGeneratedItemAssets,
|
||||||
} from './platformMatch3DRuntimeProfile';
|
} from './platformMatch3DRuntimeProfile';
|
||||||
|
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||||
import {
|
import {
|
||||||
type PlatformPublicCodeSearchStep,
|
type PlatformPublicCodeSearchStep,
|
||||||
resolvePlatformPublicCodeSearchPlan,
|
resolvePlatformPublicCodeSearchPlan,
|
||||||
@@ -13815,145 +13816,59 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const openPlayedWork = useCallback(
|
const openPlayedWork = useCallback(
|
||||||
(work: ProfilePlayedWorkSummary) => {
|
(work: ProfilePlayedWorkSummary) => {
|
||||||
const worldType = (work.worldType ?? '').toLowerCase();
|
const intent = resolvePlatformPlayedWorkOpenIntent(work);
|
||||||
setIsProfilePlayStatsOpen(false);
|
setIsProfilePlayStatsOpen(false);
|
||||||
|
|
||||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
switch (intent.type) {
|
||||||
const profileId =
|
case 'noop':
|
||||||
work.profileId ?? work.worldKey.replace(/^puzzle:/u, '');
|
|
||||||
if (profileId) {
|
|
||||||
void openPuzzlePublicWorkDetail(profileId, { tab: 'profile' });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
worldType === 'match3d' ||
|
|
||||||
worldType === 'match_3d' ||
|
|
||||||
work.worldKey.startsWith('match3d:')
|
|
||||||
) {
|
|
||||||
const profileId =
|
|
||||||
work.profileId ?? work.worldKey.replace(/^match3d:/u, '');
|
|
||||||
if (profileId) {
|
|
||||||
void openMatch3DPublicWorkDetail(profileId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
worldType === 'square-hole' ||
|
|
||||||
worldType === 'square_hole' ||
|
|
||||||
work.worldKey.startsWith('square-hole:')
|
|
||||||
) {
|
|
||||||
const profileId =
|
|
||||||
work.profileId ?? work.worldKey.replace(/^square-hole:/u, '');
|
|
||||||
if (profileId) {
|
|
||||||
void openSquareHolePublicWorkDetail(profileId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
worldType === 'jump-hop' ||
|
|
||||||
worldType === 'jump_hop' ||
|
|
||||||
work.worldKey.startsWith('jump-hop:')
|
|
||||||
) {
|
|
||||||
const profileId =
|
|
||||||
work.profileId ?? work.worldKey.replace(/^jump-hop:/u, '');
|
|
||||||
if (profileId) {
|
|
||||||
void openJumpHopPublicWorkDetail(profileId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
worldType === 'wooden-fish' ||
|
|
||||||
worldType === 'wooden_fish' ||
|
|
||||||
work.worldKey.startsWith('wooden-fish:')
|
|
||||||
) {
|
|
||||||
const profileId =
|
|
||||||
work.profileId ?? work.worldKey.replace(/^wooden-fish:/u, '');
|
|
||||||
if (profileId) {
|
|
||||||
void openWoodenFishPublicWorkDetail(profileId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
worldType === 'big_fish' ||
|
|
||||||
worldType === 'big-fish' ||
|
|
||||||
work.worldKey.startsWith('big-fish:')
|
|
||||||
) {
|
|
||||||
const sessionId =
|
|
||||||
work.profileId ?? work.worldKey.replace(/^big-fish:/u, '');
|
|
||||||
if (!sessionId) {
|
|
||||||
return;
|
return;
|
||||||
}
|
case 'open-puzzle':
|
||||||
void refreshBigFishGallery()
|
void openPuzzlePublicWorkDetail(intent.profileId, {
|
||||||
.then((entries) => {
|
tab: intent.tab,
|
||||||
const matchedEntry = entries.find(
|
|
||||||
(entry) => entry.sourceSessionId === sessionId,
|
|
||||||
);
|
|
||||||
if (matchedEntry) {
|
|
||||||
openPublicWorkDetail(
|
|
||||||
mapBigFishWorkToPublicWorkDetail(matchedEntry),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openPublicWorkDetail(
|
|
||||||
mapBigFishWorkToPublicWorkDetail({
|
|
||||||
workId: `big-fish:${sessionId}`,
|
|
||||||
sourceSessionId: sessionId,
|
|
||||||
ownerUserId: work.ownerUserId ?? '',
|
|
||||||
authorDisplayName: work.worldSubtitle || '玩家',
|
|
||||||
title: work.worldTitle,
|
|
||||||
subtitle: work.worldSubtitle,
|
|
||||||
summary: work.worldSubtitle,
|
|
||||||
coverImageSrc: null,
|
|
||||||
status: 'published',
|
|
||||||
updatedAt: work.lastPlayedAt,
|
|
||||||
publishReady: true,
|
|
||||||
levelCount: 0,
|
|
||||||
levelMainImageReadyCount: 0,
|
|
||||||
levelMotionReadyCount: 0,
|
|
||||||
backgroundReady: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setBigFishError(
|
|
||||||
resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
case 'open-match3d':
|
||||||
|
void openMatch3DPublicWorkDetail(intent.profileId);
|
||||||
|
return;
|
||||||
|
case 'open-square-hole':
|
||||||
|
void openSquareHolePublicWorkDetail(intent.profileId);
|
||||||
|
return;
|
||||||
|
case 'open-jump-hop':
|
||||||
|
void openJumpHopPublicWorkDetail(intent.profileId);
|
||||||
|
return;
|
||||||
|
case 'open-wooden-fish':
|
||||||
|
void openWoodenFishPublicWorkDetail(intent.profileId);
|
||||||
|
return;
|
||||||
|
case 'open-big-fish':
|
||||||
|
void refreshBigFishGallery()
|
||||||
|
.then((entries) => {
|
||||||
|
const matchedEntry = entries.find(
|
||||||
|
(entry) => entry.sourceSessionId === intent.sessionId,
|
||||||
|
);
|
||||||
|
if (matchedEntry) {
|
||||||
|
openPublicWorkDetail(
|
||||||
|
mapBigFishWorkToPublicWorkDetail(matchedEntry),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openPublicWorkDetail(
|
||||||
|
mapBigFishWorkToPublicWorkDetail(intent.fallbackWork),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setBigFishError(
|
||||||
|
resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'open-rpg':
|
||||||
|
void openRpgPublicWorkDetail(intent.detail);
|
||||||
|
return;
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = intent;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileId = work.profileId ?? work.worldKey;
|
|
||||||
const ownerUserId = work.ownerUserId;
|
|
||||||
if (!ownerUserId || !profileId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void openRpgPublicWorkDetail({
|
|
||||||
ownerUserId,
|
|
||||||
profileId,
|
|
||||||
publicWorkCode: null,
|
|
||||||
authorPublicUserCode: null,
|
|
||||||
visibility: 'published',
|
|
||||||
publishedAt: work.firstPlayedAt,
|
|
||||||
updatedAt: work.lastPlayedAt,
|
|
||||||
authorDisplayName: work.worldSubtitle,
|
|
||||||
worldName: work.worldTitle,
|
|
||||||
subtitle: work.worldSubtitle,
|
|
||||||
summaryText: '',
|
|
||||||
coverImageSrc: null,
|
|
||||||
themeMode: 'martial',
|
|
||||||
playableNpcCount: 0,
|
|
||||||
landmarkCount: 0,
|
|
||||||
playCount: 0,
|
|
||||||
remixCount: 0,
|
|
||||||
likeCount: 0,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
openMatch3DPublicWorkDetail,
|
openMatch3DPublicWorkDetail,
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||||
|
|
||||||
|
function buildPlayedWork(
|
||||||
|
overrides: Partial<ProfilePlayedWorkSummary> = {},
|
||||||
|
): ProfilePlayedWorkSummary {
|
||||||
|
return {
|
||||||
|
worldKey: 'custom:world-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
profileId: 'world-1',
|
||||||
|
worldType: 'CUSTOM',
|
||||||
|
worldTitle: '潮雾列岛',
|
||||||
|
worldSubtitle: '旧灯塔与失控航路',
|
||||||
|
firstPlayedAt: '2026-04-18T12:00:00.000Z',
|
||||||
|
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||||
|
lastObservedPlayTimeMs: 12_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('platformPlayedWorkOpenModel', () => {
|
||||||
|
test('opens puzzle played works with profile tab context', () => {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldType: 'PUZZLE',
|
||||||
|
profileId: 'puzzle-profile-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'open-puzzle',
|
||||||
|
profileId: 'puzzle-profile-1',
|
||||||
|
tab: 'profile',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to worldKey prefixes when profile id is absent', () => {
|
||||||
|
const cases = [
|
||||||
|
['puzzle:profile-1', 'open-puzzle', 'profile-1'],
|
||||||
|
['match3d:profile-2', 'open-match3d', 'profile-2'],
|
||||||
|
['square-hole:profile-3', 'open-square-hole', 'profile-3'],
|
||||||
|
['jump-hop:profile-4', 'open-jump-hop', 'profile-4'],
|
||||||
|
['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [worldKey, type, profileId] of cases) {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldKey,
|
||||||
|
profileId: null,
|
||||||
|
worldType: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toMatchObject({ type, profileId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps explicit profile id ahead of worldKey fallback', () => {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldKey: 'jump-hop:key-profile',
|
||||||
|
profileId: 'explicit-profile',
|
||||||
|
worldType: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'open-jump-hop',
|
||||||
|
profileId: 'explicit-profile',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports played work type aliases for mini-games', () => {
|
||||||
|
const cases = [
|
||||||
|
['match_3d', 'open-match3d'],
|
||||||
|
['square_hole', 'open-square-hole'],
|
||||||
|
['jump_hop', 'open-jump-hop'],
|
||||||
|
['wooden_fish', 'open-wooden-fish'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [worldType, type] of cases) {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldType,
|
||||||
|
profileId: `${worldType}-profile`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
type,
|
||||||
|
profileId: `${worldType}-profile`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns noop when a mini-game target is empty', () => {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldKey: 'puzzle:key-profile',
|
||||||
|
profileId: '',
|
||||||
|
worldType: 'puzzle',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'noop',
|
||||||
|
reason: 'missing-target',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds big fish intent and fallback work for gallery misses', () => {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldKey: 'big-fish:big-fish-session-1',
|
||||||
|
ownerUserId: null,
|
||||||
|
profileId: null,
|
||||||
|
worldType: 'big_fish',
|
||||||
|
worldTitle: '机械深海',
|
||||||
|
worldSubtitle: '',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'open-big-fish',
|
||||||
|
sessionId: 'big-fish-session-1',
|
||||||
|
fallbackWork: {
|
||||||
|
workId: 'big-fish:big-fish-session-1',
|
||||||
|
sourceSessionId: 'big-fish-session-1',
|
||||||
|
ownerUserId: '',
|
||||||
|
authorDisplayName: '玩家',
|
||||||
|
title: '机械深海',
|
||||||
|
subtitle: '',
|
||||||
|
summary: '',
|
||||||
|
coverImageSrc: null,
|
||||||
|
status: 'published',
|
||||||
|
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||||
|
publishReady: true,
|
||||||
|
levelCount: 0,
|
||||||
|
levelMainImageReadyCount: 0,
|
||||||
|
levelMotionReadyCount: 0,
|
||||||
|
backgroundReady: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens unknown played work types as RPG detail when identity is complete', () => {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldType: 'CUSTOM',
|
||||||
|
profileId: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'open-rpg',
|
||||||
|
detail: {
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
profileId: 'custom:world-1',
|
||||||
|
publicWorkCode: null,
|
||||||
|
authorPublicUserCode: null,
|
||||||
|
visibility: 'published',
|
||||||
|
publishedAt: '2026-04-18T12:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||||
|
authorDisplayName: '旧灯塔与失控航路',
|
||||||
|
worldName: '潮雾列岛',
|
||||||
|
subtitle: '旧灯塔与失控航路',
|
||||||
|
summaryText: '',
|
||||||
|
coverImageSrc: null,
|
||||||
|
themeMode: 'martial',
|
||||||
|
playableNpcCount: 0,
|
||||||
|
landmarkCount: 0,
|
||||||
|
playCount: 0,
|
||||||
|
remixCount: 0,
|
||||||
|
likeCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns noop for RPG fallback when owner or profile is missing', () => {
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
ownerUserId: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'noop',
|
||||||
|
reason: 'missing-target',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
buildPlayedWork({
|
||||||
|
worldKey: '',
|
||||||
|
profileId: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'noop',
|
||||||
|
reason: 'missing-target',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
|
import type {
|
||||||
|
CustomWorldGalleryCard,
|
||||||
|
ProfilePlayedWorkSummary,
|
||||||
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
|
||||||
|
export type PlatformPlayedWorkOpenIntent =
|
||||||
|
| {
|
||||||
|
type: 'noop';
|
||||||
|
reason: 'missing-target';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'open-puzzle';
|
||||||
|
profileId: string;
|
||||||
|
tab: 'profile';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'open-match3d';
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'open-square-hole';
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'open-jump-hop';
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'open-wooden-fish';
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'open-big-fish';
|
||||||
|
sessionId: string;
|
||||||
|
fallbackWork: BigFishWorkSummary;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'open-rpg';
|
||||||
|
detail: CustomWorldGalleryCard;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePlayedWorkWorldType(worldType: string | null) {
|
||||||
|
return (worldType ?? '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePlayedWorkTargetId(
|
||||||
|
work: ProfilePlayedWorkSummary,
|
||||||
|
worldKeyPrefix: string,
|
||||||
|
) {
|
||||||
|
const prefixedWorldKey = `${worldKeyPrefix}:`;
|
||||||
|
return (
|
||||||
|
work.profileId ??
|
||||||
|
(work.worldKey.startsWith(prefixedWorldKey)
|
||||||
|
? work.worldKey.slice(prefixedWorldKey.length)
|
||||||
|
: work.worldKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePlayedWorkProfileIntent<TIntent extends PlatformPlayedWorkOpenIntent>(
|
||||||
|
profileId: string,
|
||||||
|
intent: (profileId: string) => TIntent,
|
||||||
|
) {
|
||||||
|
return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent {
|
||||||
|
return {
|
||||||
|
type: 'noop',
|
||||||
|
reason: 'missing-target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlayedBigFishFallbackWork(
|
||||||
|
work: ProfilePlayedWorkSummary,
|
||||||
|
sessionId: string,
|
||||||
|
): BigFishWorkSummary {
|
||||||
|
return {
|
||||||
|
workId: `big-fish:${sessionId}`,
|
||||||
|
sourceSessionId: sessionId,
|
||||||
|
ownerUserId: work.ownerUserId ?? '',
|
||||||
|
authorDisplayName: work.worldSubtitle || '玩家',
|
||||||
|
title: work.worldTitle,
|
||||||
|
subtitle: work.worldSubtitle,
|
||||||
|
summary: work.worldSubtitle,
|
||||||
|
coverImageSrc: null,
|
||||||
|
status: 'published',
|
||||||
|
updatedAt: work.lastPlayedAt,
|
||||||
|
publishReady: true,
|
||||||
|
levelCount: 0,
|
||||||
|
levelMainImageReadyCount: 0,
|
||||||
|
levelMotionReadyCount: 0,
|
||||||
|
backgroundReady: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlayedRpgDetail(
|
||||||
|
work: ProfilePlayedWorkSummary,
|
||||||
|
profileId: string,
|
||||||
|
ownerUserId: string,
|
||||||
|
): CustomWorldGalleryCard {
|
||||||
|
return {
|
||||||
|
ownerUserId,
|
||||||
|
profileId,
|
||||||
|
publicWorkCode: null,
|
||||||
|
authorPublicUserCode: null,
|
||||||
|
visibility: 'published',
|
||||||
|
publishedAt: work.firstPlayedAt,
|
||||||
|
updatedAt: work.lastPlayedAt,
|
||||||
|
authorDisplayName: work.worldSubtitle,
|
||||||
|
worldName: work.worldTitle,
|
||||||
|
subtitle: work.worldSubtitle,
|
||||||
|
summaryText: '',
|
||||||
|
coverImageSrc: null,
|
||||||
|
themeMode: 'martial',
|
||||||
|
playableNpcCount: 0,
|
||||||
|
landmarkCount: 0,
|
||||||
|
playCount: 0,
|
||||||
|
remixCount: 0,
|
||||||
|
likeCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */
|
||||||
|
export function resolvePlatformPlayedWorkOpenIntent(
|
||||||
|
work: ProfilePlayedWorkSummary,
|
||||||
|
): PlatformPlayedWorkOpenIntent {
|
||||||
|
const worldType = normalizePlayedWorkWorldType(work.worldType);
|
||||||
|
|
||||||
|
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||||
|
const profileId = resolvePlayedWorkTargetId(work, 'puzzle');
|
||||||
|
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||||
|
type: 'open-puzzle',
|
||||||
|
profileId: resolvedProfileId,
|
||||||
|
tab: 'profile',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
worldType === 'match3d' ||
|
||||||
|
worldType === 'match_3d' ||
|
||||||
|
work.worldKey.startsWith('match3d:')
|
||||||
|
) {
|
||||||
|
const profileId = resolvePlayedWorkTargetId(work, 'match3d');
|
||||||
|
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||||
|
type: 'open-match3d',
|
||||||
|
profileId: resolvedProfileId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
worldType === 'square-hole' ||
|
||||||
|
worldType === 'square_hole' ||
|
||||||
|
work.worldKey.startsWith('square-hole:')
|
||||||
|
) {
|
||||||
|
const profileId = resolvePlayedWorkTargetId(work, 'square-hole');
|
||||||
|
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||||
|
type: 'open-square-hole',
|
||||||
|
profileId: resolvedProfileId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
worldType === 'jump-hop' ||
|
||||||
|
worldType === 'jump_hop' ||
|
||||||
|
work.worldKey.startsWith('jump-hop:')
|
||||||
|
) {
|
||||||
|
const profileId = resolvePlayedWorkTargetId(work, 'jump-hop');
|
||||||
|
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||||
|
type: 'open-jump-hop',
|
||||||
|
profileId: resolvedProfileId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
worldType === 'wooden-fish' ||
|
||||||
|
worldType === 'wooden_fish' ||
|
||||||
|
work.worldKey.startsWith('wooden-fish:')
|
||||||
|
) {
|
||||||
|
const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish');
|
||||||
|
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||||
|
type: 'open-wooden-fish',
|
||||||
|
profileId: resolvedProfileId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
worldType === 'big_fish' ||
|
||||||
|
worldType === 'big-fish' ||
|
||||||
|
work.worldKey.startsWith('big-fish:')
|
||||||
|
) {
|
||||||
|
const sessionId = resolvePlayedWorkTargetId(work, 'big-fish');
|
||||||
|
return sessionId
|
||||||
|
? {
|
||||||
|
type: 'open-big-fish',
|
||||||
|
sessionId,
|
||||||
|
fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId),
|
||||||
|
}
|
||||||
|
: buildMissingPlayedWorkTargetIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileId = work.profileId ?? work.worldKey;
|
||||||
|
const ownerUserId = work.ownerUserId;
|
||||||
|
if (!ownerUserId || !profileId) {
|
||||||
|
return buildMissingPlayedWorkTargetIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'open-rpg',
|
||||||
|
detail: buildPlayedRpgDetail(work, profileId, ownerUserId),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user