diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index d40d9f9b..9aae7e77 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -31,6 +31,22 @@ - 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 自定义世界旧公开作品不要用 published_at 判断是否存在 + +- 现象:RPG / 自定义世界作品详情能打开,但点赞时报 `custom_world 已发布作品不存在,无法点赞`,错误来源是 `作品详情 CW-*` 或其它自定义世界历史公开号。 +- 原因:部分历史 `custom_world_profile` 已是 `publication_status=Published`,但 `published_at` 为空;统一公开详情会用 `updated_at` 兜底展示,旧点赞 / 游玩 / Remix 判断却额外要求 `published_at.is_some()`。 +- 处理:公开互动存在性统一按 `Published + deleted_at=None + visible=true` 判断;`custom_world_gallery_entry` 同步和公开展示时间在 `published_at` 缺失时回退 `updated_at`。 +- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 + +## 推荐页 WF 点赞不要落到 RPG / custom-world + +- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`。 +- 原因:推荐页点赞统一走 `likePublicWork`,但敲木鱼尚未接入点赞后端;缺少 `wooden-fish` 分支时会落入默认 RPG / custom-world 点赞路径,把敲木鱼的 owner/profile 传给 custom-world reducer。 +- 处理:所有公开作品互动必须先按 `packages/shared/src/contracts/playTypes.ts` 中的全局 `sourceType` 分流;暂未接入点赞的玩法直接报“该作品类型暂不支持点赞”,禁止显示开放兜底文案,也禁止用默认 RPG / custom-world 分支兜底。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation wooden fish like does not call RPG gallery like"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 + ## 暗色创作进度卡不要被 platform-remap-surface 改成深色文字 - 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。 @@ -1238,8 +1254,8 @@ - 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为服务器初始化专用口径,不允许公网 Git fallback,Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源。 - 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15`、`curl 56 GnuTLS recv error (-9)`、`early EOF`、`invalid index-pack output` 失败;发布类流水线还可能先遇到 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达。 - 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。即使只使用域名 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`,Jenkins Git 插件也会拉取所有分支。 -- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build` 的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。 -- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `userRemoteConfigs` 带 `+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}`,`CloneOption` 带 `honorRefspec: true`;运行 `bash -n scripts/jenkins-checkout-source.sh`。 +- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build` 的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,指定 commit 时也先保持 `depth=1` 校验,浅历史无法证明归属时才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS` 逐步加深,最后才展开完整历史。发布流水线不得为了缩短 checkout 时间清空上游构建传入的 `COMMIT_HASH`。 +- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `userRemoteConfigs` 带 `+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}`,`CloneOption` 带 `honorRefspec: true`;扫描发布流水线确认传给 `scripts/jenkins-checkout-source.sh` 的 `COMMIT_HASH` 未被硬编码为空;运行 `bash -n scripts/jenkins-checkout-source.sh`。 - 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## Jenkins 可选参数在 set -u 下不能裸读 @@ -1532,7 +1548,7 @@ ## 推荐页嵌入拼图通关结算不要放在运行态内部 absolute 层 -- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜、下一关按钮或相似作品卡被截断。 +- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜或下一关按钮被截断。 - 原因:推荐页把运行态放在滑动作品卡的视觉区内,`platform-recommend-swipe-page`、`platform-recommend-swipe-card__visual` 和 `platform-recommend-runtime-viewport` 都是 `overflow: hidden`;拼图通关结算如果仍是运行态内部 `absolute inset-0` 弹层,就只能在半屏卡片区域里显示。 - 处理:`PuzzleRuntimeShell` 在 `embedded` 模式下把通关结算层通过 portal 挂到 `document.body`,使用 `puzzle-runtime-modal-overlay--fixed` 页面级 fixed 浮层;非嵌入态继续使用运行态内部覆盖层。 - 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪"`,确认弹层不再位于 `.platform-recommend-runtime-viewport` 内。 @@ -2051,6 +2067,14 @@ - 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 拼图文字直创的 compile 回包不等于生成完成 + +- 现象:只输入文字点击生成拼图时,页面刚进入生成页就弹出“生成任务已完成,可以继续查看草稿。”,随后又提示“请先选择一张正式拼图图片。”,结果页关卡里也没有图。 +- 原因:统一创作表单路径把 `compile_puzzle_draft` 的同步回包无条件当成 ready;但后端在 AI 重绘路径会先返回 `stage=image_refining`、`progressPercent=88` 的会话,只表示首关草稿已编译且后台首图 / UI 资产任务已启动,还没有正式封面或候选图。 +- 处理:前端必须继续用 `isPuzzleCompileActionReady(...)` 判断回包 session;没有 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时保持生成中,不弹完成、不把作品架 pending 标 ready、不自动试玩。生成页轮询合并 session 进度时,未进入编译态或进度无变化就返回原 state,避免轮询制造重复 render。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle text-only form stays generating|puzzle draft generation auto starts trial|running puzzle draft opens generation progress"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## CreativeImageInputPanel 主图点击默认预览 - 现象:复用 `CreativeImageInputPanel` 的结果页 / 编辑页已有主图时,用户点击图片却触发上传,无法直接查看大图;不同玩法若各自手写上传按钮会让主图、历史图、AI 重绘和参考图行为再次分叉。 @@ -2058,3 +2082,11 @@ - 处理:通用面板已有主图时默认点击主图打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方用 `canUploadMainImage` 和 `canUseImageHistory` 分别控制上传与历史按钮,不要复制面板或用样式遮挡按钮。 - 验证:`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。 - 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 统一创作页短表单软键盘打开不要露出黑底 + +- 现象:小程序 / H5 移动端点击拼图或敲木鱼创作输入框后,输入框和键盘之间出现一大片黑色区域;H5 还会明显弹一下。跳一跳因为按钮区用 `mt-auto` 撑开页面,看起来没有同样问题。 +- 原因:旧移动键盘处理会用 `--platform-keyboard-focus-offset` 把 `.platform-viewport-shell` 整体上移;但 H5 浏览器和小程序 `web-view` 已会自行处理输入框可见性,二次整体上移会造成页面弹跳并露出 `body` 或原生 `page` 的黑色宿主底色。统一创作短表单若内容区按短内容收缩,也会放大这个黑底暴露。 +- 处理:`UnifiedCreationPage` 根容器必须保留 `bg-[image:var(--platform-body-fill)]` 和 `overscroll-contain`,内容区必须用 `flex-1 min-h-0` 占满统一页剩余高度;移动端键盘打开时只记录 `data-mobile-keyboard-open`、隐藏底部 dock、设置键盘 inset 和浅色 `--platform-keyboard-exposed-fill`,不要再对 `.platform-viewport-shell` 做全局 `transform`;小程序 `pages/web-view` 的 `page` 和 web-view class 也要用浅色背景。不要只给某个玩法工作台单独加高度补丁。 +- 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js`;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。 +- 关联:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/mobileViewportKeyboardFocus.ts`、`src/index.css`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/.hermes/shared-memory/team-conventions.md b/.hermes/shared-memory/team-conventions.md index 8677fadc..c4e19275 100644 --- a/.hermes/shared-memory/team-conventions.md +++ b/.hermes/shared-memory/team-conventions.md @@ -56,7 +56,7 @@ 3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。 4. 若产生长期有效知识,更新 `.hermes/shared-memory/`。 5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。 -6. 在提交信息中区分代码变更与文档/记忆变更。 +6. 提交代码时,提交标题使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。 ## 文档阅读顺序 diff --git a/AGENTS.md b/AGENTS.md index 01cbf619..49289da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ Single-context layout: read root `CONTEXT.md` when present. Current architecture - UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。 - UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。 - 不要在gitignore中添加.env.local文件。 +- 提交代码时,提交标题必须使用中文;标题后必须逐行写明本次提交修改了什么,每条变更单独一行。 - 严格遵循简洁的代码风格 - 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。 - 禁止将功能说明描述类的文本默认写入UI界面中。 diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx index da9362d7..adad5145 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx @@ -27,7 +27,7 @@ vi.mock('../api/adminApiClient', () => ({ const puzzleSpec: UnifiedCreationSpecPayload = { playId: 'puzzle', - title: '想做个什么玩法?', + title: '拼图', workspaceStage: 'puzzle-agent-workspace', generationStage: 'puzzle-generating', resultStage: 'puzzle-result', @@ -88,6 +88,9 @@ test('创作入口后台展示并保存统一创作契约', async () => { await screen.findByText('pictureDescription'); expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull(); + expect( + container.querySelector('.admin-subsection .admin-info-list')?.textContent, + ).toContain('拼图'); expect(container.querySelector('.admin-panel .admin-panel')).toBeNull(); expect(container.querySelector('.admin-muted')).toBeNull(); diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index 9de00709..9390f0f1 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -707,6 +707,10 @@ function UnifiedCreationSpecSummary({specJson}: {specJson: string}) {
玩法
{parsed.spec.playId}
+
+
表头
+
{parsed.spec.title}
+
阶段
diff --git a/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx index 4e02a845..aea06873 100644 --- a/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx +++ b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx @@ -16,6 +16,7 @@ interface AdminWorkVisibilityPageProps { const sourceLabels: Record = { puzzle: '拼图', + 'puzzle-clear': '拼消消', 'custom-world': '自定义世界', 'jump-hop': '跳一跳', 'wooden-fish': '敲木鱼', diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 6bbc45af..07751c9e 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -135,11 +135,13 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 展示字段: 1. rank; -2. playerId; +2. displayName; 3. successfulJumpCount; 4. durationMs; 5. updatedAt。 +排行榜 UI 禁止展示 `user_id` / `playerId` 这类内部身份键。后端可以继续用 `playerId` 做作品维度最佳成绩去重和 `viewerBest` 匹配,但 HTTP 响应必须补齐 `displayName`;已登录用户读取账号 `displayName`,匿名游客展示为“游客玩家”,账号失效或无法解析时展示为“失效玩家”。 + 草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。 ## 8. 结果页 diff --git a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md index 76c22a5f..ac1c723d 100644 --- a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -50,7 +50,7 @@ - `GET /admin/api/works/visibility` - `POST /admin/api/works/visibility` -后台操作 key 使用统一的 `sourceType + profileId` 组合。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id`,`bark-battle` 对应 `work_id`。`custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。 +后台操作 key 使用统一的 `sourceType + profileId` 组合。当前后端统一可见性管理覆盖 `puzzle`、`puzzle-clear`、`custom-world`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`big-fish` 和 `bark-battle`;`edutainment` 当前没有后端统一作品源表,暂不接入该后台能力。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id`,`bark-battle` 对应 `work_id`。`custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。 该后台能力只修改源表 / source view 过滤事实,不把 `visible` 暴露到公开列表或公开详情契约。隐藏作品后,统一 `public_work_gallery_entry` 与 `public_work_detail_entry` 不再返回该作品;恢复显示后重新进入公开 read model。 @@ -77,6 +77,7 @@ - 旧 view 退到底层 source / 兼容职责。 - 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。 - 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。 +- RPG / 自定义世界旧数据可能缺少 `published_at`。统一公开详情可以用 `updated_at` 作为展示和排序兜底;点赞、游玩、Remix 等写入路径也必须按 `publication_status=Published + visible=true + 未删除` 判断作品存在,不能额外要求 `published_at` 非空。 - 临时运行约束:SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view` 的 `publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic;为避免后台隐藏作品打爆 module instance,统一 `public_work_*` view 暂不级联抓大鹅 source view,抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。 - 旧 `/api/runtime//gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。 - 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 516898f8..90550feb 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -66,6 +66,7 @@ npm run check:server-rs-ddd - 方洞挑战:`/api/creation/square-hole/*`、`/api/runtime/square-hole/*`。 - 视觉小说:`/api/creation/visual-novel/*`、`/api/runtime/visual-novel/*`。 - 大鱼吃小鱼:`/api/runtime/big-fish/*`。 +- 跳一跳:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`。 - 汪汪声浪:`/api/runtime/bark-battle/*`。 - 儿童向创作:`/api/creation/edutainment/*`。 - AI task:`/api/ai/tasks*`。 @@ -74,7 +75,7 @@ npm run check:server-rs-ddd ### 认证态用户与会话摘要下发口径 -- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 +- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。 - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 @@ -343,7 +344,7 @@ npm run check:server-rs-ddd - Rust 结构体:`CreationEntryTypeConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` - 字段:`id`、`title`、`subtitle`、`badge`、`image_src`、`visible`、`open`、`sort_order`、`updated_at`、`category_id`、`category_label`、`category_sort_order`、`unified_creation_spec_json`。 -- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时只回退首批 `puzzle`、`match3d`、`wooden-fish` 默认 spec。 +- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时按 `shared-contracts` 中当前支持的统一创作默认 spec 回退。`unifiedCreationSpec.title` 是统一创作页表头契约内容,读取和保存时不按入口 `title` 自动覆盖。 ### `custom_world_agent_message` @@ -378,6 +379,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` @@ -414,6 +416,7 @@ npm run check:server-rs-ddd - Rust 结构体:`JumpHopLeaderboardEntryRow` - 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` - 说明:跳一跳作品维度排行榜 read model,每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。 +- 展示契约:`player_id` 只作为后端去重和 `viewerBest` 匹配身份键,不得直接进入 HTTP/UI 展示字段;`/api/runtime/jump-hop/works/{profile_id}/leaderboard` 必须补齐 `displayName`,已登录玩家读取账号显示名,匿名游客展示“游客玩家”,失效账号展示“失效玩家”。 ### `jump_hop_runtime_run` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 16e7939c..c0ab1715 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -250,6 +250,8 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。其它构建 / 发布流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。 +`scripts/jenkins-checkout-source.sh` 是生产 Jenkinsfile 内部二次确认源码的统一入口。构建和发布流水线传入 `COMMIT_HASH` 时,脚本必须先保持 `depth=1` 浅拉,若上游 commit 已在浅历史内则直接校验并 checkout;只有浅历史无法证明 commit 属于目标分支时,才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS`(默认 `50 200 1000 5000`)逐步加深,最后才尝试展开完整历史。`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy` 和 `Genarrative-Stdb-Module-Publish` 都必须保留上游构建传入的 `COMMIT_HASH`,不得为了缩短 checkout 时间改为空值或改用目标分支最新提交。 + `Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 33c98a46..88304754 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -45,11 +45,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。 3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 -5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一先通过原生 `input type="nickname"` 获取微信昵称并作为 `displayName`,再通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。 +5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一先通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login` 完成快捷登录。若该接口返回 `created=true`,或返回用户昵称仍是手机号、公开陶泥号、“微信旅人”等默认展示值,才展示原生 `input type="nickname"` 补充微信昵称并再次调用 `/api/auth/wechat/miniprogram-login` 写入 `displayName`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。 6. 小程序外壳注入到 H5 URL 的 `clientType`、`clientRuntime`、`miniProgramEnv` 是宿主上下文,H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。 7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 -9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 +9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 +10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。 ## 账户与充值 @@ -95,7 +96,7 @@ server-rs + Axum + SpacetimeDB 3. 点击按钮弹出独立面板时,必须弹出 dialog / drawer / modal,不要在当前面板下方展开内容。 4. 优先复用现有系统、页面、组件和弹层,不因一次需求新建平行系统。 5. 游戏式页面要防止文字、按钮、HUD、底部 dock、输入法和画布互相遮挡。 -6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,用偏移聚焦输入框,业务组件不要重复注册全局键盘适配。 +6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,只记录键盘状态、隐藏底部 dock 并补齐浅色暴露背景,不再全局上移平台壳;业务组件不要重复注册全局键盘适配。 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index 8d17e08e..dcb824b6 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -89,17 +89,12 @@ pipeline { env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL } } - script { - if (params.COMMIT_HASH?.trim()) { - echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。" - } - } sh ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ - COMMIT_HASH="" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 51724d71..db8f0233 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -111,6 +111,58 @@ function normalizeNicknameInput(value) { return String(value || '').trim(); } +function normalizeNicknameForMatch(value) { + return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase(); +} + +function isPhoneLikeDisplayName(value) { + const normalized = normalizeNicknameForMatch(value); + if (!normalized) { + return false; + } + + const digits = normalized.replace(/\D/gu, ''); + return ( + /^(\+?86)?1\d{10}$/u.test(normalized) || + /^1\d{2}\*{4}\d{4}$/u.test(normalized) || + (/[*x]/iu.test(normalized) && digits.length >= 7) || + digits.length >= 11 + ); +} + +function isDefaultDisplayName(value, publicUserCode) { + const normalized = normalizeNicknameForMatch(value); + const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode); + if (!normalized) { + return true; + } + + return ( + normalized === '微信旅人' || + normalized === '玩家' || + normalized === normalizedPublicUserCode || + /^sy-\d{8}$/iu.test(normalized) || + /^user[_-]/iu.test(normalized) || + isPhoneLikeDisplayName(normalized) + ); +} + +function shouldRequestNicknameAfterLogin(authResult) { + const user = authResult && authResult.user ? authResult.user : {}; + const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName); + if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) { + return false; + } + + return ( + authResult && + (authResult.created || + isDefaultDisplayName(user.displayName, user.publicUserCode) || + (wechatDisplayName && + isDefaultDisplayName(wechatDisplayName, user.publicUserCode))) + ); +} + function normalizeMiniProgramEnv(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'release') { @@ -372,6 +424,8 @@ async function resolveAuthResult(displayName) { return { token: response.token, bindingStatus: response.bindingStatus || 'pending_bind_phone', + user: response.user || null, + created: response.created === true, }; } @@ -431,13 +485,14 @@ Page({ authResult: null, bindingPhone: false, errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: true, + loggingIn: true, + loading: true, + nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, webViewUrl: '', }); + await this.startAuthFlow(returnToPreviousPage, ''); }, handleNicknameInput(event) { @@ -465,6 +520,20 @@ Page({ async startAuthFlow(returnToPreviousPage, displayName) { try { const authResult = await resolveAuthResult(displayName); + if (!displayName && shouldRequestNicknameAfterLogin(authResult)) { + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: true, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + return; + } + if (authResult.bindingStatus === 'pending_bind_phone') { this.setData({ authResult, @@ -512,7 +581,7 @@ Page({ error && error.message ? error.message : '微信登录失败,请稍后重试。', loggingIn: false, loading: false, - nicknameRequired: true, + nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, webViewUrl: '', diff --git a/miniprogram/pages/web-view/index.style.test.js b/miniprogram/pages/web-view/index.style.test.js new file mode 100644 index 00000000..120e5d48 --- /dev/null +++ b/miniprogram/pages/web-view/index.style.test.js @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +const PAGE_DIR = path.resolve( + process.cwd(), + 'miniprogram/pages/web-view', +); + +function readPageFile(fileName) { + return fs.readFileSync(path.join(PAGE_DIR, fileName), 'utf8'); +} + +describe('mini program web-view page background', () => { + test('keeps the native web-view host light when the mobile keyboard exposes it', () => { + const wxml = readPageFile('index.wxml'); + const wxss = readPageFile('index.wxss'); + + expect(wxml).toContain('class="web-view-host"'); + expect(wxml).not.toContain('class="web-view-page"'); + expect(wxss).toContain('page'); + expect(wxss).toContain('.web-view-host'); + expect(wxss).toContain('background: #fffdf9;'); + + const webViewHostBlock = wxss.slice( + wxss.indexOf('.web-view-host'), + wxss.indexOf('.setup-screen'), + ); + expect(webViewHostBlock).not.toContain('#0b0f14'); + }); +}); diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index a00efe76..a54f33f0 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -1,6 +1,7 @@ - 登录 + 完善昵称 {{errorMessage}} @@ -29,7 +30,7 @@ disabled="{{loggingIn}}" bindtap="handleStartLogin" > - {{loggingIn ? '正在登录' : '微信快捷登录'}} + {{loggingIn ? '正在提交' : '确认昵称'}} diff --git a/miniprogram/pages/web-view/index.wxss b/miniprogram/pages/web-view/index.wxss index 568bbca5..f5002852 100644 --- a/miniprogram/pages/web-view/index.wxss +++ b/miniprogram/pages/web-view/index.wxss @@ -1,3 +1,14 @@ +page { + background: #fffdf9; +} + +.web-view-host { + display: block; + width: 100%; + min-height: 100vh; + background: #fffdf9; +} + .setup-screen { min-height: 100vh; display: flex; diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index cc9eff13..ab3202d9 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -143,6 +143,7 @@ export type AuthWechatMiniProgramLoginResponse = { token: string; bindingStatus: AuthBindingStatus; user: AuthUser; + created: boolean; }; export type AuthPhoneChangeRequest = { diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 5ba8e295..4d42e963 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -4,6 +4,7 @@ export type * from './hyper3d'; export type * from './jumpHop'; export type * from './puzzleCreativeTemplate'; export type * from './puzzleClear'; +export * from './playTypes'; export type * from './publicWork'; export type * from './visualNovel'; export type * from './barkBattle'; diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index a5b6d9e9..8b2621e1 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -294,6 +294,7 @@ export interface JumpHopJumpResponse { export interface JumpHopLeaderboardEntry { rank: number; playerId: string; + displayName: string; successfulJumpCount: number; durationMs: number; updatedAt: string; diff --git a/packages/shared/src/contracts/playTypes.ts b/packages/shared/src/contracts/playTypes.ts new file mode 100644 index 00000000..e81383ab --- /dev/null +++ b/packages/shared/src/contracts/playTypes.ts @@ -0,0 +1,72 @@ +export const PLATFORM_CREATION_TYPE_IDS = [ + 'rpg', + 'big-fish', + 'puzzle', + 'puzzle-clear', + 'match3d', + 'jump-hop', + 'wooden-fish', + 'square-hole', + 'bark-battle', + 'visual-novel', + 'baby-object-match', + 'creative-agent', + 'airp', +] as const; + +export type PlatformCreationTypeId = + (typeof PLATFORM_CREATION_TYPE_IDS)[number]; + +const PLATFORM_CREATION_TYPE_ID_SET: ReadonlySet = new Set( + PLATFORM_CREATION_TYPE_IDS, +); + +export function isPlatformCreationTypeId( + value: string, +): value is PlatformCreationTypeId { + return PLATFORM_CREATION_TYPE_ID_SET.has(value); +} + +export function assertPlatformCreationTypeId( + value: string, +): PlatformCreationTypeId { + if (isPlatformCreationTypeId(value)) { + return value; + } + + throw new Error(`未知创作类型:${value}`); +} + +export const PUBLIC_WORK_SOURCE_TYPES = [ + 'custom-world', + 'big-fish', + 'puzzle', + 'puzzle-clear', + 'jump-hop', + 'wooden-fish', + 'match3d', + 'square-hole', + 'visual-novel', + 'bark-battle', + 'edutainment', +] as const; + +export type PublicWorkSourceType = (typeof PUBLIC_WORK_SOURCE_TYPES)[number]; + +const PUBLIC_WORK_SOURCE_TYPE_SET: ReadonlySet = new Set( + PUBLIC_WORK_SOURCE_TYPES, +); + +export function isPublicWorkSourceType( + value: string, +): value is PublicWorkSourceType { + return PUBLIC_WORK_SOURCE_TYPE_SET.has(value); +} + +export function assertPublicWorkSourceType(value: string): PublicWorkSourceType { + if (isPublicWorkSourceType(value)) { + return value; + } + + throw new Error(`未知公开作品类型:${value}`); +} diff --git a/packages/shared/src/contracts/publicWork.ts b/packages/shared/src/contracts/publicWork.ts index 71aae5d9..c7a3065a 100644 --- a/packages/shared/src/contracts/publicWork.ts +++ b/packages/shared/src/contracts/publicWork.ts @@ -1,5 +1,7 @@ +import type { PublicWorkSourceType } from './playTypes'; + export interface PublicWorkGalleryEntryResponse { - sourceType: string; + sourceType: PublicWorkSourceType; workId: string; profileId: string; sourceSessionId?: string | null; diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 11d91c27..5e705782 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -136,7 +136,6 @@ export interface DragPuzzlePieceRequest { export interface AdvancePuzzleNextLevelRequest { targetProfileId?: string | null; - preferSimilarWork?: boolean; } export interface UsePuzzleRuntimePropRequest { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3c8b36f3..cfffc0a4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,6 +12,7 @@ export type * from './contracts/hyper3d'; export * from './contracts/match3dAgent'; export * from './contracts/match3dRuntime'; export * from './contracts/match3dWorks'; +export * from './contracts/playTypes'; export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentSession'; diff --git a/public/creation-type-references/jump-hop.webp b/public/creation-type-references/jump-hop.webp index b4e6c7b2..20b885e8 100644 Binary files a/public/creation-type-references/jump-hop.webp and b/public/creation-type-references/jump-hop.webp differ diff --git a/scripts/jenkins-checkout-source.sh b/scripts/jenkins-checkout-source.sh index 5ae89465..131bcd8e 100644 --- a/scripts/jenkins-checkout-source.sh +++ b/scripts/jenkins-checkout-source.sh @@ -51,11 +51,71 @@ fetch_source_branch() { fi echo "[jenkins-checkout-source] 尝试 Git 远端: ${remote_url:-origin}" - if [[ -z "${COMMIT_HASH}" ]]; then - git fetch --no-tags --prune --depth=1 origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + git fetch --no-tags --prune --depth=1 origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" +} + +is_shallow_repository() { + [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]] +} + +resolve_requested_commit_if_on_branch() { + local requested_commit="$1" + local resolved_commit + + if ! git cat-file -e "${requested_commit}^{commit}" 2>/dev/null; then + return 1 + fi + + resolved_commit="$(git rev-parse "${requested_commit}^{commit}")" + if ! git merge-base --is-ancestor "${resolved_commit}" "refs/remotes/origin/${SOURCE_BRANCH}" 2>/dev/null; then + return 1 + fi + + printf "%s\n" "${resolved_commit}" +} + +resolve_requested_commit_with_deepen() { + local requested_commit="$1" + local deepen_steps_raw="${GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS:-50 200 1000 5000}" + local deepen_steps=() + local deepen_depth + local resolved_commit + + # 中文注释:上游构建 commit 通常就是分支 HEAD,先吃浅克隆;确实不是浅历史内提交时再逐步加深。 + if resolved_commit="$(resolve_requested_commit_if_on_branch "${requested_commit}")"; then + printf "%s\n" "${resolved_commit}" + return 0 + fi + + read -r -a deepen_steps <<<"${deepen_steps_raw}" + for deepen_depth in "${deepen_steps[@]}"; do + if [[ ! "${deepen_depth}" =~ ^[0-9]+$ || "${deepen_depth}" -le 1 ]]; then + echo "[jenkins-checkout-source] 忽略无效加深深度: ${deepen_depth}" >&2 + continue + fi + + echo "[jenkins-checkout-source] 浅历史未命中 commit=${requested_commit},加深到 depth=${deepen_depth}" >&2 + if is_shallow_repository; then + git fetch --no-tags --prune --depth="${deepen_depth}" origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + else + git fetch --no-tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + fi + + if resolved_commit="$(resolve_requested_commit_if_on_branch "${requested_commit}")"; then + printf "%s\n" "${resolved_commit}" + return 0 + fi + done + + if is_shallow_repository; then + echo "[jenkins-checkout-source] 逐步加深仍未命中 commit=${requested_commit},最后尝试展开完整历史" >&2 + git fetch --unshallow --no-tags origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" || \ + git fetch --no-tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" else git fetch --no-tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" fi + + resolve_requested_commit_if_on_branch "${requested_commit}" } add_git_remote_candidate "${GIT_REMOTE_URL}" @@ -80,17 +140,11 @@ else fi fi -if [[ -n "${COMMIT_HASH}" && "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then - git fetch --unshallow --no-tags origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" || true -fi - git cat-file -e "refs/remotes/origin/${SOURCE_BRANCH}^{commit}" if [[ -n "${COMMIT_HASH}" ]]; then - git cat-file -e "${COMMIT_HASH}^{commit}" - RESOLVED_COMMIT="$(git rev-parse "${COMMIT_HASH}^{commit}")" - if ! git merge-base --is-ancestor "${RESOLVED_COMMIT}" "refs/remotes/origin/${SOURCE_BRANCH}"; then - echo "[jenkins-checkout-source] 指定 commit 不属于 origin/${SOURCE_BRANCH}: ${RESOLVED_COMMIT}" >&2 + if ! RESOLVED_COMMIT="$(resolve_requested_commit_with_deepen "${COMMIT_HASH}")"; then + echo "[jenkins-checkout-source] 指定 commit 不属于 origin/${SOURCE_BRANCH}: ${COMMIT_HASH}" >&2 exit 1 fi else diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 8b6cb151..339abb1a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2640,6 +2640,7 @@ mod tests { login_payload["bindingStatus"], Value::String("pending_bind_phone".to_string()) ); + assert_eq!(login_payload["created"], Value::Bool(true)); assert_eq!( login_payload["user"]["loginMethod"], Value::String("wechat".to_string()) @@ -2746,6 +2747,7 @@ mod tests { login_payload["bindingStatus"], Value::String("pending_bind_phone".to_string()) ); + assert_eq!(login_payload["created"], Value::Bool(true)); let bind_response = app .oneshot( @@ -4423,4 +4425,4 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); } } -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 5d905083..55914d7e 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -13,7 +13,8 @@ use serde_json::{Value, json}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, - JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse, + JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest, + JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, @@ -222,6 +223,34 @@ pub async fn list_jump_hop_works( )) } +pub async fn get_jump_hop_work_detail( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &profile_id, "profileId")?; + let work = state + .spacetime_client() + .get_jump_hop_work_profile( + profile_id, + authenticated.claims().user_id().to_string(), + ) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopWorkDetailResponse { item: work }, + )) +} + pub async fn delete_jump_hop_work( State(state): State, Path(profile_id): Path, @@ -231,7 +260,10 @@ pub async fn delete_jump_hop_work( ensure_non_empty(&request_context, &profile_id, "profileId")?; let works = state .spacetime_client() - .delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string()) + .delete_jump_hop_work( + profile_id, + authenticated.claims().user_id().to_string(), + ) .await .map_err(|error| { jump_hop_error_response( @@ -296,8 +328,14 @@ pub async fn get_jump_hop_leaderboard( Some(&request_context), JumpHopLeaderboardResponse { profile_id: leaderboard.profile_id, - items: leaderboard.items, - viewer_best: leaderboard.viewer_best, + items: leaderboard + .items + .into_iter() + .map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry)) + .collect(), + viewer_best: leaderboard + .viewer_best + .map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry)), }, )) } @@ -1270,6 +1308,51 @@ fn build_jump_hop_work_play_tracking_draft( WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route) } +fn resolve_jump_hop_leaderboard_entry_display_name( + state: &AppState, + mut entry: JumpHopLeaderboardEntry, +) -> JumpHopLeaderboardEntry { + entry.display_name = resolve_jump_hop_leaderboard_display_name(state, &entry.player_id); + entry +} + +fn resolve_jump_hop_leaderboard_display_name(state: &AppState, player_id: &str) -> String { + resolve_jump_hop_leaderboard_display_name_with_lookup(player_id, |user_id| { + state + .auth_user_service() + .get_user_by_id(user_id) + .ok() + .flatten() + .and_then(|user| normalize_non_empty_text(user.display_name.as_str())) + }) +} + +fn resolve_jump_hop_leaderboard_display_name_with_lookup( + player_id: &str, + lookup_display_name: impl FnOnce(&str) -> Option, +) -> String { + let player_id = player_id.trim(); + if player_id.is_empty() { + return "玩家".to_string(); + } + if player_id.starts_with("guest-runtime-") { + return "游客玩家".to_string(); + } + + lookup_display_name(player_id) + .and_then(|display_name| normalize_non_empty_text(display_name.as_str())) + .unwrap_or_else(|| "失效玩家".to_string()) +} + +fn normalize_non_empty_text(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool { runtime_mode.trim().eq_ignore_ascii_case("draft") } @@ -1464,6 +1547,33 @@ mod tests { assert!(!is_jump_hop_draft_runtime_mode("")); } + #[test] + fn jump_hop_leaderboard_display_name_never_falls_back_to_player_id() { + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup(" user-secret-1 ", |user_id| { + assert_eq!(user_id, "user-secret-1"); + Some(" 陶泥儿玩家 ".to_string()) + }), + "陶泥儿玩家" + ); + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup("guest-runtime-1", |_| { + panic!("guest player should not query account display name") + }), + "游客玩家" + ); + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup("user-missing", |_| None), + "失效玩家" + ); + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup("", |_| { + panic!("empty player id should not query account display name") + }), + "玩家" + ); + } + #[test] fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 1d69d4c3..2ed65a3b 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -1,6 +1,6 @@ use axum::{ middleware, - routing::{delete, get, post}, + routing::{get, post}, Router, }; @@ -9,8 +9,9 @@ use crate::{ jump_hop::{ create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action, get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work, - get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, - publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, + get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, + list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, + start_jump_hop_run, }, state::AppState, }; @@ -47,10 +48,12 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/creation/jump-hop/works/{profile_id}", - delete(delete_jump_hop_work).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + get(get_jump_hop_work_detail) + .delete(delete_jump_hop_work) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/creation/jump-hop/works/{profile_id}/publish", diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index a8c930b5..165dc5e7 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -349,6 +349,7 @@ pub async fn login_wechat_mini_program( token: signed_session.access_token, binding_status: result.user.binding_status.as_str().to_string(), user: map_auth_user_payload(result.user), + created: result.created, }, ), )) diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 9e09c4d4..63e897de 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -242,8 +242,8 @@ pub fn resolve_creation_entry_event_banner_responses( banners } .into_iter() - .map(build_creation_entry_event_banner_response) - .collect() + .map(build_creation_entry_event_banner_response) + .collect() } /// 把领域公告快照转换为 HTTP 响应字段。 diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 3e16b6fe..3243177f 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -339,12 +339,20 @@ mod tests { assert_eq!(banners.len(), 1); assert_eq!(banners[0].render_mode, "html"); assert_eq!(banners[0].title, "创作公告"); - assert!(banners[0].html_code.as_deref().unwrap_or("").contains("创作公告")); - assert!(banners[0] - .html_code - .as_deref() - .unwrap_or("") - .contains("/creation-type-references/puzzle.webp")); + assert!( + banners[0] + .html_code + .as_deref() + .unwrap_or("") + .contains("创作公告") + ); + assert!( + banners[0] + .html_code + .as_deref() + .unwrap_or("") + .contains("/creation-type-references/puzzle.webp") + ); assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src); } @@ -485,6 +493,60 @@ mod tests { assert_eq!(jump_hop.category_sort_order, 20); } + #[test] + fn creation_entry_response_uses_unified_creation_contract_title() { + let response = build_creation_entry_config_response(CreationEntryConfigSnapshot { + config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(), + start_card: CreationEntryStartCardSnapshot { + title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(), + description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(), + idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(), + busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(), + }, + type_modal: CreationEntryTypeModalSnapshot { + title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), + description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), + }, + event_banner: default_creation_entry_event_banner_snapshots() + .into_iter() + .next() + .expect("default banner"), + event_banners_json: Some(default_creation_entry_event_banners_json()), + creation_types: vec![CreationEntryTypeSnapshot { + id: "puzzle".to_string(), + title: "定制拼图".to_string(), + subtitle: "拼图关卡创作".to_string(), + badge: "可创建".to_string(), + image_src: "/creation-type-references/puzzle.webp".to_string(), + visible: true, + open: true, + sort_order: 30, + category_id: "recommended".to_string(), + category_label: "热门推荐".to_string(), + category_sort_order: 20, + updated_at_micros: 1, + unified_creation_spec_json: Some( + r#"{"playId":"puzzle","title":"想做个什么玩法?","workspaceStage":"puzzle-agent-workspace","generationStage":"puzzle-generating","resultStage":"puzzle-result","fields":[{"id":"pictureDescription","kind":"text","label":"画面描述","required":true}]}"# + .to_string(), + ), + }], + updated_at_micros: 1, + }); + let puzzle = response + .creation_types + .iter() + .find(|item| item.id == "puzzle") + .expect("puzzle entry"); + + assert_eq!( + puzzle + .unified_creation_spec + .as_ref() + .map(|spec| spec.title.as_str()), + Some("想做个什么玩法?") + ); + } + #[test] fn normalized_clamps_music_volume_into_valid_range() { let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index a9024f9b..038e260c 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -253,6 +253,7 @@ pub struct WechatMiniProgramLoginResponse { pub token: String, pub binding_status: String, pub user: AuthUserPayload, + pub created: bool, } pub fn build_available_login_methods( @@ -389,4 +390,29 @@ mod tests { }) ); } + + #[test] + fn wechat_mini_program_login_response_marks_created_user() { + let payload = serde_json::to_value(WechatMiniProgramLoginResponse { + token: "token-001".to_string(), + binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(), + user: AuthUserPayload { + id: "user_001".to_string(), + public_user_code: "SY-00000001".to_string(), + display_name: "微信旅人".to_string(), + avatar_url: None, + phone_number: None, + phone_number_masked: None, + login_method: AUTH_LOGIN_METHOD_WECHAT.to_string(), + binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(), + wechat_bound: true, + wechat_display_name: None, + wechat_account: Some("wx-openid-001".to_string()), + }, + created: true, + }) + .expect("payload should serialize"); + + assert_eq!(payload["created"], serde_json::Value::Bool(true)); + } } diff --git a/server-rs/crates/shared-contracts/src/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs index 616b2582..7a9ded46 100644 --- a/server-rs/crates/shared-contracts/src/creation_entry_config.rs +++ b/server-rs/crates/shared-contracts/src/creation_entry_config.rs @@ -137,16 +137,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option ( "wooden-fish-workspace", @@ -210,7 +201,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option Option Option<&'static str> { + match play_id { + "rpg" => Some("文字冒险"), + "big-fish" => Some("摸鱼"), + "puzzle" => Some("拼图"), + "match3d" => Some("抓大鹅"), + "jump-hop" => Some("跳一跳"), + "wooden-fish" => Some("敲木鱼"), + "square-hole" => Some("方洞"), + "bark-battle" => Some("汪汪声浪"), + "visual-novel" => Some("视觉小说"), + "baby-object-match" => Some("宝贝识物"), + "creative-agent" => Some("智能体创作"), + _ => None, + } +} + pub fn validate_unified_creation_spec_response( spec: &UnifiedCreationSpecResponse, ) -> Result<(), String> { @@ -328,10 +336,12 @@ mod tests { #[test] fn phase1_unified_creation_specs_cover_existing_templates() { let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec"); + assert_eq!(puzzle.title, "拼图"); assert_eq!(puzzle.fields[0].id, "pictureDescription"); assert_eq!(puzzle.fields[1].kind, "image"); let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec"); + assert_eq!(match3d.title, "抓大鹅"); assert_eq!( match3d .fields @@ -342,18 +352,9 @@ mod tests { ); let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec"); - assert!( - jump_hop - .fields - .iter() - .any(|field| field.id == "stylePreset") - ); - assert!( - jump_hop - .fields - .iter() - .any(|field| field.id == "endMoodPrompt") - ); + assert_eq!(jump_hop.title, "跳一跳"); + assert_eq!(jump_hop.fields.len(), 1); + assert_eq!(jump_hop.fields[0].id, "themeText"); let wooden_fish = build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec"); @@ -379,6 +380,30 @@ mod tests { ); } + #[test] + fn unified_creation_spec_title_uses_contract_content() { + let raw = r#"{ + "playId": "puzzle", + "title": "想做个什么玩法?", + "workspaceStage": "puzzle-agent-workspace", + "generationStage": "puzzle-generating", + "resultStage": "puzzle-result", + "fields": [ + { + "id": "pictureDescription", + "kind": "text", + "label": "画面描述", + "required": true + } + ] + }"#; + + let spec = + resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec"); + + assert_eq!(spec.title, "想做个什么玩法?"); + } + #[test] fn creation_entry_event_banner_defaults_to_structured_render_mode() { let banner = serde_json::from_str::( diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index cbad6f68..826130f4 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -443,6 +443,7 @@ pub struct JumpHopJumpResponse { pub struct JumpHopLeaderboardEntry { pub rank: u32, pub player_id: String, + pub display_name: String, pub successful_jump_count: u32, pub duration_ms: u64, pub updated_at: String, diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index eec6ba97..5a5a8a5e 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -342,6 +342,7 @@ fn map_jump_hop_leaderboard_entry_snapshot( JumpHopLeaderboardEntry { rank: snapshot.rank, player_id: snapshot.player_id, + display_name: String::new(), successful_jump_count: snapshot.successful_jump_count, duration_ms: snapshot.duration_ms, updated_at: format_timestamp_micros(snapshot.updated_at_micros), diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 6e88121e..646c12ac 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -1662,9 +1662,7 @@ fn get_custom_world_gallery_detail_record( .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id - && row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible + && is_custom_world_profile_publicly_interactive(row) }); let gallery_entry = ctx @@ -1712,8 +1710,7 @@ fn get_custom_world_gallery_detail_record_by_code( .find(&row.profile_id) .filter(|profile_row| { profile_row.owner_user_id == row.owner_user_id - && profile_row.publication_status == CustomWorldPublicationStatus::Published - && profile_row.deleted_at.is_none() + && is_custom_world_profile_publicly_interactive(profile_row) }) }); @@ -1756,12 +1753,7 @@ fn remix_custom_world_profile_record( .profile_id() .find(&source_profile_id.to_string()) .filter(|row| row.owner_user_id == source_owner_user_id) - .filter(|row| { - row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible - && row.published_at.is_some() - }) + .filter(is_custom_world_profile_publicly_interactive) .ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?; let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); @@ -1859,12 +1851,7 @@ fn record_custom_world_profile_play_record( .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) - .filter(|row| { - row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible - && row.published_at.is_some() - }) + .filter(is_custom_world_profile_publicly_interactive) .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); @@ -1932,12 +1919,7 @@ fn record_custom_world_profile_like_record( .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) - .filter(|row| { - row.publication_status == CustomWorldPublicationStatus::Published - && row.deleted_at.is_none() - && row.visible - && row.published_at.is_some() - }) + .filter(is_custom_world_profile_publicly_interactive) .ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?; let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); @@ -1998,6 +1980,18 @@ fn record_custom_world_profile_like_record( )) } +fn is_custom_world_profile_publicly_interactive(row: &CustomWorldProfile) -> bool { + // 历史公开作品可能缺少 published_at;公开互动只按发布、未删除、可见判断。 + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.visible +} + +fn resolve_custom_world_published_at(row: &CustomWorldProfile) -> Timestamp { + // gallery 展示与同步兼容旧数据,用 updated_at 兜底公开时间。 + row.published_at.unwrap_or(row.updated_at) +} + fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, @@ -4832,9 +4826,10 @@ fn sync_custom_world_gallery_entry_from_profile( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> Result { - let published_at = profile - .published_at - .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; + if profile.publication_status != CustomWorldPublicationStatus::Published { + return Err("custom_world profile 未发布,无法同步 gallery".to_string()); + } + let published_at = resolve_custom_world_published_at(profile); ctx.db .custom_world_gallery_entry() @@ -4881,10 +4876,6 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), .collect::>(); for profile in published_profiles { - if profile.published_at.is_none() { - continue; - } - let existing_gallery_entry = ctx .db .custom_world_gallery_entry() @@ -5483,6 +5474,78 @@ mod tests { )); } + #[test] + fn custom_world_public_interactions_accept_legacy_missing_published_at() { + fn build_profile( + publication_status: CustomWorldPublicationStatus, + published_at: Option, + deleted_at: Option, + visible: bool, + ) -> CustomWorldProfile { + CustomWorldProfile { + profile_id: "profile-legacy".to_string(), + owner_user_id: "user-legacy".to_string(), + public_work_code: Some("CW-3A9EC89B".to_string()), + author_public_user_code: Some("SY-00000001".to_string()), + source_agent_session_id: Some("session-legacy".to_string()), + publication_status, + world_name: "旧公开世界".to_string(), + subtitle: String::new(), + summary_text: String::new(), + theme_mode: CustomWorldThemeMode::Mythic, + cover_image_src: None, + profile_payload_json: "{}".to_string(), + playable_npc_count: 0, + landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, + author_display_name: "玩家".to_string(), + published_at, + deleted_at, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(20), + visible, + } + } + + let legacy_published = + build_profile(CustomWorldPublicationStatus::Published, None, None, true); + assert!(is_custom_world_profile_publicly_interactive( + &legacy_published + )); + assert_eq!( + resolve_custom_world_published_at(&legacy_published).to_micros_since_unix_epoch(), + 20 + ); + + let current_published = build_profile( + CustomWorldPublicationStatus::Published, + Some(Timestamp::from_micros_since_unix_epoch(10)), + None, + true, + ); + assert_eq!( + resolve_custom_world_published_at(¤t_published).to_micros_since_unix_epoch(), + 10 + ); + + assert!(!is_custom_world_profile_publicly_interactive( + &build_profile(CustomWorldPublicationStatus::Draft, None, None, true,) + )); + assert!(!is_custom_world_profile_publicly_interactive( + &build_profile( + CustomWorldPublicationStatus::Published, + None, + Some(Timestamp::from_micros_since_unix_epoch(30)), + true, + ) + )); + assert!(!is_custom_world_profile_publicly_interactive( + &build_profile(CustomWorldPublicationStatus::Published, None, None, false,) + )); + } + #[test] fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() { fn build_test_custom_world_profile( diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index cc600047..743d62f9 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -1573,11 +1573,6 @@ mod tests { 5, 1_000, &existing )); } -} - -#[cfg(test)] -mod tests { - use super::*; #[test] fn jump_hop_delete_input_carries_owner_and_profile() { diff --git a/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs b/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs index 13ec22a5..9dd603a6 100644 --- a/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs +++ b/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs @@ -4,6 +4,7 @@ use module_custom_world::CustomWorldPublicationStatus; use module_puzzle::PuzzlePublicationStatus; const SOURCE_TYPE_PUZZLE: &str = "puzzle"; +const SOURCE_TYPE_PUZZLE_CLEAR: &str = "puzzle-clear"; const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world"; const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop"; const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish"; @@ -63,6 +64,7 @@ fn list_work_visibility_tx( let mut entries = Vec::new(); entries.extend(list_puzzle_work_visibility(ctx)); + entries.extend(list_puzzle_clear_work_visibility(ctx)); entries.extend(list_custom_world_work_visibility(ctx)); entries.extend(list_jump_hop_work_visibility(ctx)); entries.extend(list_wooden_fish_work_visibility(ctx)); @@ -85,6 +87,9 @@ fn update_work_visibility_tx( match source_type.as_str() { SOURCE_TYPE_PUZZLE => update_puzzle_work_visibility(ctx, &profile_id, input.visible), + SOURCE_TYPE_PUZZLE_CLEAR => { + update_puzzle_clear_work_visibility(ctx, &profile_id, input.visible) + } SOURCE_TYPE_CUSTOM_WORLD => { update_custom_world_work_visibility(ctx, &profile_id, input.visible) } @@ -167,6 +172,63 @@ fn puzzle_work_visibility_snapshot(row: &PuzzleWorkProfileRow) -> AdminWorkVisib } } +fn list_puzzle_clear_work_visibility(ctx: &ReducerContext) -> Vec { + ctx.db + .puzzle_clear_work_profile() + .by_puzzle_clear_work_publication_status() + .filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED) + .map(|row| puzzle_clear_work_visibility_snapshot(&row)) + .collect() +} + +fn update_puzzle_clear_work_visibility( + ctx: &ReducerContext, + profile_id: &str, + visible: bool, +) -> Result { + let row = ctx + .db + .puzzle_clear_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼消消作品不存在".to_string())?; + if row.publication_status != PUZZLE_CLEAR_PUBLICATION_PUBLISHED { + return Err("只能修改已发布拼消消作品可见性".to_string()); + } + let next = PuzzleClearWorkProfileRow { visible, ..row }; + let snapshot = puzzle_clear_work_visibility_snapshot(&next); + let profile_id = next.profile_id.clone(); + ctx.db + .puzzle_clear_work_profile() + .profile_id() + .delete(&profile_id); + ctx.db.puzzle_clear_work_profile().insert(next); + Ok(snapshot) +} + +fn puzzle_clear_work_visibility_snapshot( + row: &PuzzleClearWorkProfileRow, +) -> AdminWorkVisibilitySnapshot { + let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); + AdminWorkVisibilitySnapshot { + source_type: SOURCE_TYPE_PUZZLE_CLEAR.to_string(), + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()), + public_work_code: build_prefixed_public_work_code("PC", &row.profile_id), + owner_user_id: row.owner_user_id.clone(), + author_display_name: row.author_display_name.clone(), + title: choose_non_empty(&[row.work_title.as_str(), row.theme_prompt.as_str(), "拼消消"]), + subtitle: "拼消消".to_string(), + cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()), + visible: row.visible, + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: sort_time, + } +} + fn list_custom_world_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .custom_world_profile() diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 353153df..28576bb0 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -3,11 +3,11 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { AuthSessionSummary, AuthUser } from '../../services/authService'; import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments'; -import { AuthGate } from './AuthGate'; +import { AuthGate, setAuthGateReloadForTest } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ @@ -107,6 +107,7 @@ beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); window.history.replaceState(null, '', '/'); + setAuthGateReloadForTest(vi.fn()); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getStoredAccessToken.mockReturnValue(''); @@ -158,6 +159,10 @@ beforeEach(() => { authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true); }); +afterEach(() => { + setAuthGateReloadForTest(null); +}); + async function acceptLegalConsent( user: ReturnType, dialog: HTMLElement, @@ -382,6 +387,8 @@ test('auth gate keeps sms and password entries available when login options requ test('auth gate opens a login modal for protected actions and resumes after login', async () => { const user = userEvent.setup(); const onAuthenticated = vi.fn(); + const reload = vi.fn(); + setAuthGateReloadForTest(reload); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], @@ -411,6 +418,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi ); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); expect(onAuthenticated).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledTimes(1); }); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); @@ -636,6 +644,8 @@ test('registration invite modal can skip when invite code is empty', async () => test('auth state refresh keeps mounted platform content and local tab state', async () => { const user = userEvent.setup(); + const reload = vi.fn(); + setAuthGateReloadForTest(reload); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], @@ -674,10 +684,13 @@ test('auth state refresh keeps mounted platform content and local tab state', as expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2); }); expect(screen.getByText('当前Tab:创作')).toBeTruthy(); + expect(reload).not.toHaveBeenCalled(); }); test('logout withdraws user context before backend request finishes', async () => { const user = userEvent.setup(); + const reload = vi.fn(); + setAuthGateReloadForTest(reload); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], @@ -703,11 +716,14 @@ test('logout withdraws user context before backend request finishes', async () = expect(await screen.findByText('当前用户:未登录')).toBeTruthy(); expect(screen.getByText('私有数据:不可读取')).toBeTruthy(); expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); await act(async () => { resolveLogout(); await logoutPromise; }); + + expect(reload).toHaveBeenCalledTimes(1); }); test('auth gate shows sms send feedback in the login modal', async () => { diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 404764b2..b7c7ec1b 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -65,6 +65,18 @@ type AuthStatus = const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password']; +let reloadCurrentPageForAuthStateChange = () => { + window.location.reload(); +}; + +export function setAuthGateReloadForTest(handler: (() => void) | null) { + reloadCurrentPageForAuthStateChange = + handler ?? + (() => { + window.location.reload(); + }); +} + function readInviteCodeFromLocation(): string { const params = new URLSearchParams(window.location.search || ''); return (params.get('inviteCode') || params.get('invite_code') || '') @@ -140,6 +152,8 @@ export function AuthGate({ children }: AuthGateProps) { const autoOpenedInviteCodeRef = useRef(null); const hasRenderedPlatformContentRef = useRef(false); const authHydrateVersionRef = useRef(0); + const lastStableAuthPresenceRef = useRef(null); + const pendingAuthStateReloadRef = useRef(false); const canKeepPlatformContentMounted = hasRenderedPlatformContentRef.current && (status === 'checking' || status === 'recovering'); @@ -152,36 +166,64 @@ export function AuthGate({ children }: AuthGateProps) { hasRenderedPlatformContentRef.current = true; } + const markAuthStateReloadIfChanged = useCallback( + ( + nextUser: AuthUser | null, + options: { reloadOnChange?: boolean } = {}, + ) => { + const nextHasUser = Boolean(nextUser); + const previousHasUser = lastStableAuthPresenceRef.current; + if (previousHasUser === null) { + lastStableAuthPresenceRef.current = nextHasUser; + return; + } + + lastStableAuthPresenceRef.current = nextHasUser; + if ( + previousHasUser !== nextHasUser && + options.reloadOnChange !== false + ) { + pendingAuthStateReloadRef.current = true; + } + }, + [], + ); + const activateReadyUser = useCallback((nextUser: AuthUser) => { // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 authHydrateVersionRef.current += 1; + markAuthStateReloadIfChanged(nextUser); setUser(nextUser); setStatus('ready'); - }, []); + }, [markAuthStateReloadIfChanged]); - const clearLocalAuthenticatedState = useCallback(() => { - // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 - // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 - authHydrateVersionRef.current += 1; - pendingProtectedActionRef.current = null; - setUser(null); - setStatus('unauthenticated'); - setShowLoginModal(false); - setShowRegistrationInviteModal(false); - setShowSettingsModal(false); - setSettingsEntryMode('settings'); - setInitialSettingsSection(null); - setSessions([]); - setRevokingSessionIds([]); - setAuditLogs([]); - setRiskBlocks([]); - setLoginCaptchaChallenge(null); - setBindCaptchaChallenge(null); - setChangePhoneCaptchaChallenge(null); - setPendingInviteCode(''); - setRegistrationInviteError(''); - setError(''); - }, []); + const clearLocalAuthenticatedState = useCallback( + (options: { reloadOnChange?: boolean } = {}) => { + // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 + // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 + authHydrateVersionRef.current += 1; + markAuthStateReloadIfChanged(null, options); + pendingProtectedActionRef.current = null; + setUser(null); + setStatus('unauthenticated'); + setShowLoginModal(false); + setShowRegistrationInviteModal(false); + setShowSettingsModal(false); + setSettingsEntryMode('settings'); + setInitialSettingsSection(null); + setSessions([]); + setRevokingSessionIds([]); + setAuditLogs([]); + setRiskBlocks([]); + setLoginCaptchaChallenge(null); + setBindCaptchaChallenge(null); + setChangePhoneCaptchaChallenge(null); + setPendingInviteCode(''); + setRegistrationInviteError(''); + setError(''); + }, + [markAuthStateReloadIfChanged], + ); const restoreAuthSession = useCallback(async () => { const hadLocalAccessToken = Boolean(getStoredAccessToken()); @@ -234,7 +276,7 @@ export function AuthGate({ children }: AuthGateProps) { }, []); const logoutCurrentSession = useCallback(async () => { - clearLocalAuthenticatedState(); + clearLocalAuthenticatedState({ reloadOnChange: false }); try { await logoutAuthUser(); } catch (logoutError) { @@ -243,11 +285,13 @@ export function AuthGate({ children }: AuthGateProps) { ? logoutError.message : '退出登录失败,请刷新页面确认状态。', ); + } finally { + reloadCurrentPageForAuthStateChange(); } }, [clearLocalAuthenticatedState]); const logoutAllSessions = useCallback(async () => { - clearLocalAuthenticatedState(); + clearLocalAuthenticatedState({ reloadOnChange: false }); try { await logoutAllAuthSessions(); } catch (logoutError) { @@ -256,6 +300,8 @@ export function AuthGate({ children }: AuthGateProps) { ? logoutError.message : '退出全部设备失败,请刷新页面确认状态。', ); + } finally { + reloadCurrentPageForAuthStateChange(); } }, [clearLocalAuthenticatedState]); @@ -386,6 +432,7 @@ export function AuthGate({ children }: AuthGateProps) { return; } + markAuthStateReloadIfChanged(null); setUser(null); setStatus('unauthenticated'); } catch (optionsError) { @@ -394,6 +441,7 @@ export function AuthGate({ children }: AuthGateProps) { } setAvailableLoginMethods(REQUIRED_LOGIN_METHODS); + markAuthStateReloadIfChanged(null); setUser(null); // 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口; // 这里不展示接口读取错误,避免用户误以为登录本身不可用。 @@ -413,6 +461,7 @@ export function AuthGate({ children }: AuthGateProps) { return; } if (restoredSession.kind === 'guest') { + markAuthStateReloadIfChanged(null); setAvailableLoginMethods( normalizeAvailableLoginMethods( restoredSession.session?.availableLoginMethods, @@ -423,6 +472,7 @@ export function AuthGate({ children }: AuthGateProps) { } const nextSession = restoredSession.session; + markAuthStateReloadIfChanged(nextSession.user); setUser(nextSession.user); setAvailableLoginMethods( normalizeAvailableLoginMethods(nextSession.availableLoginMethods), @@ -470,19 +520,23 @@ export function AuthGate({ children }: AuthGateProps) { window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); window.removeEventListener('hashchange', handleAuthHashChange); }; - }, [restoreAuthSession]); + }, [markAuthStateReloadIfChanged, restoreAuthSession]); useEffect(() => { if (!readyUser) { setShowSettingsModal(false); - return; + } else { + setShowLoginModal(false); + + const pendingAction = pendingProtectedActionRef.current; + pendingProtectedActionRef.current = null; + pendingAction?.(); } - setShowLoginModal(false); - - const pendingAction = pendingProtectedActionRef.current; - pendingProtectedActionRef.current = null; - pendingAction?.(); + if (pendingAuthStateReloadRef.current) { + pendingAuthStateReloadRef.current = false; + reloadCurrentPageForAuthStateChange(); + } }, [readyUser]); useEffect(() => { diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 3f971fe2..eba1fd1b 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -335,6 +335,16 @@ export function buildCreationWorkShelfItems(params: { }), ), }, + { + kind: 'puzzle-clear', + buildItems: () => + puzzleClearItems.map((item) => + mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, { + onOpen: onOpenPuzzleClearDetail, + onDelete: onDeletePuzzleClear, + }), + ), + }, { kind: 'puzzle', buildItems: () => diff --git a/src/components/jump-hop-result/JumpHopResultView.test.tsx b/src/components/jump-hop-result/JumpHopResultView.test.tsx index f19ec837..3727d5e5 100644 --- a/src/components/jump-hop-result/JumpHopResultView.test.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.test.tsx @@ -28,14 +28,16 @@ test('跳一跳结果页展示排行榜列表', () => { items: [ { rank: 1, - playerId: 'player-1', + playerId: 'user-secret-1', + displayName: '陶泥儿玩家', successfulJumpCount: 12, durationMs: 40123, updatedAt: '2026-05-27T00:00:00Z', }, { rank: 2, - playerId: 'player-2', + playerId: 'user-secret-2', + displayName: '森林玩家', successfulJumpCount: 10, durationMs: 38210, updatedAt: '2026-05-26T00:00:00Z', @@ -60,10 +62,12 @@ test('跳一跳结果页展示排行榜列表', () => { ); expect(screen.getByText('排行榜')).toBeTruthy(); - expect(screen.getByText('player-1')).toBeTruthy(); + expect(screen.getByText('陶泥儿玩家')).toBeTruthy(); + expect(screen.queryByText('user-secret-1')).toBeNull(); expect(screen.getByText('12 跳')).toBeTruthy(); expect(screen.getByText('00:40')).toBeTruthy(); - expect(screen.getByText('player-2')).toBeTruthy(); + expect(screen.getByText('森林玩家')).toBeTruthy(); + expect(screen.queryByText('user-secret-2')).toBeNull(); }); test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => { @@ -83,6 +87,24 @@ test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => { ); }); +test('跳一跳结果页根容器允许移动端向下滚动到操作按钮', () => { + const { container } = render( + {}} + onEdit={() => {}} + onStartTestRun={() => {}} + onPublish={() => {}} + onRegenerateTiles={() => {}} + />, + ); + + const root = container.firstElementChild as HTMLElement; + expect(root.className).toContain('overflow-y-auto'); + expect(root.className).toContain('overscroll-contain'); + expect(root.className).toContain('safe-area-inset-bottom'); +}); + test('跳一跳草稿结果页不请求公开排行榜', () => { render( {entry.rank} - {entry.playerId} + + {entry.displayName?.trim() || '玩家'} + {entry.successfulJumpCount} 跳 {formatJumpHopDurationLabel(entry.durationMs)}
@@ -300,7 +302,7 @@ export function JumpHopResultView({ }; return ( -
+
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 52038020..4cbbeebe 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -115,6 +115,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets' import { buildPublicWorkStagePath, pushAppHistoryPath, + resolvePathForSelectionStage, } from '../../routing/appPageRoutes'; import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery'; import { @@ -371,7 +372,6 @@ import { isPersistedBarkBattleDraftGenerating, } from '../custom-world-home/creationWorkShelf'; import { - buildPlatformRecommendFeedEntries, selectAdjacentPlatformRecommendEntry, } from '../rpg-entry/rpgEntryPublicGalleryViewModel'; import { @@ -493,7 +493,9 @@ import { import { canExposePublicWork, EDUTAINMENT_HIDDEN_MESSAGE, + filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; +import { buildPlatformRecommendedEntries } from './platformRecommendation'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { @@ -2872,10 +2874,10 @@ export function PlatformEntryFlowShellImpl({ publicGalleryFeeds; const recommendRuntimeEntries = useMemo( () => - buildPlatformRecommendFeedEntries( - featuredGalleryEntries, - latestGalleryEntries, - ), + buildPlatformRecommendedEntries({ + featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries), + latestEntries: filterGeneralPublicWorks(latestGalleryEntries), + }), [featuredGalleryEntries, latestGalleryEntries], ); @@ -4440,6 +4442,18 @@ export function PlatformEntryFlowShellImpl({ activePuzzleBackgroundCompileTask?.session ?? puzzleSession; const puzzleGenerationViewPayload = activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload; + const puzzleGenerationViewStateRef = useRef(puzzleGenerationViewState); + const puzzleGenerationViewPayloadRef = useRef(puzzleGenerationViewPayload); + const setPuzzleSessionRef = useRef(puzzleFlow.setSession); + useEffect(() => { + puzzleGenerationViewStateRef.current = puzzleGenerationViewState; + }, [puzzleGenerationViewState]); + useEffect(() => { + puzzleGenerationViewPayloadRef.current = puzzleGenerationViewPayload; + }, [puzzleGenerationViewPayload]); + useEffect(() => { + setPuzzleSessionRef.current = puzzleFlow.setSession; + }, [puzzleFlow.setSession]); const puzzleGenerationViewError = activePuzzleBackgroundCompileTask?.error ?? puzzleError; const isPuzzleGenerationViewBusy = @@ -4835,21 +4849,21 @@ export function PlatformEntryFlowShellImpl({ if (hasRecoverableGeneratedPuzzleDraft(latestSession)) { const payload = - puzzleGenerationViewPayload ?? + puzzleGenerationViewPayloadRef.current ?? buildPuzzleFormPayloadFromSession(latestSession); const generationState = - puzzleGenerationViewState ?? + puzzleGenerationViewStateRef.current ?? createPuzzleDraftGenerationStateFromPayload(payload, latestSession); await recoverCompletedPuzzleDraftGeneration({ sessionId: latestSession.sessionId, payload, generationState, - setSession: setPuzzleSession, + setSession: setPuzzleSessionRef.current, }); return; } - setPuzzleSession(latestSession); + setPuzzleSessionRef.current(latestSession); setPuzzleBackgroundCompileTasks((current) => { const task = current[activePuzzleGenerationSessionId]; if (!task) { @@ -4892,11 +4906,8 @@ export function PlatformEntryFlowShellImpl({ }; }, [ activePuzzleGenerationSessionId, - puzzleGenerationViewPayload, - puzzleGenerationViewState, recoverCompletedPuzzleDraftGeneration, shouldPollPuzzleGenerationSession, - setPuzzleSession, ]); const match3DGeneratingSessionId = @@ -5258,27 +5269,48 @@ export function PlatformEntryFlowShellImpl({ ); setPuzzleOperation(response.operation); const openResult = isViewingPuzzleGeneration(nextSession.sessionId); - const readyGenerationState = - resolveFinishedMiniGameDraftGenerationState( - generationState, - 'ready', - { - completedAssetCount: 1, - totalAssetCount: 1, - }, - ); + const isCompileReady = isPuzzleCompileActionReady(response.session); + const nextGenerationState = isCompileReady + ? resolveFinishedMiniGameDraftGenerationState( + generationState, + 'ready', + { + completedAssetCount: 1, + totalAssetCount: 1, + }, + ) + : mergePuzzleSessionProgressIntoGenerationState( + generationState, + response.session, + ); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { session: response.session, payload, - generationState: readyGenerationState, + generationState: nextGenerationState, error: null, }, })); if (isViewingPuzzleGeneration(nextSession.sessionId)) { puzzleFlow.setSession(response.session); - setPuzzleGenerationState(readyGenerationState); + setPuzzleGenerationState(nextGenerationState); + } + + if (!isCompileReady) { + markDraftGenerating('puzzle', [ + response.session.sessionId, + buildPuzzleResultWorkId(response.session.sessionId), + response.session.publishedProfileId, + buildPuzzleResultProfileId(response.session.sessionId), + ]); + markPendingDraftGenerating( + 'puzzle', + response.session.sessionId, + buildPendingPuzzleDraftMetadata(payload), + ); + void refreshPuzzleShelf(); + return; } const profileId = @@ -6870,10 +6902,10 @@ export function PlatformEntryFlowShellImpl({ profileId: targetProfileId, mode: 'play' as const, }; - const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( - authUi, - options.embedded, - ); + const runtimeGuestOptions = + options.embedded || workDetail.summary.publishStatus === 'draft' + ? await buildRecommendRuntimeAuthOptions(authUi, true) + : {}; const { run } = options.embedded ? await startVisualNovelRun( targetProfileId, @@ -8818,7 +8850,11 @@ export function PlatformEntryFlowShellImpl({ profile: Match3DWorkProfile | Match3DWorkSummary, returnStage: 'match3d-result' | 'work-detail' = 'match3d-result', mirrorErrorToPublicDetail = false, - options: { embedded?: boolean; itemTypeCountOverride?: number } = {}, + options: { + embedded?: boolean; + authMode?: PuzzleRuntimeAuthMode; + itemTypeCountOverride?: number; + } = {}, ) => { if (isMatch3DBusy) { return false; @@ -8857,10 +8893,10 @@ export function PlatformEntryFlowShellImpl({ runtimeProfile.generatedBackgroundAsset, { expireSeconds: 300 }, ); - const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( - authUi, - options.embedded, - ); + const runtimeGuestOptions = + options.authMode === 'isolated' + ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS + : await buildRecommendRuntimeAuthOptions(authUi, options.embedded); const runtimeOptions = { ...runtimeGuestOptions, ...(typeof options.itemTypeCountOverride === 'number' @@ -9657,10 +9693,6 @@ export function PlatformEntryFlowShellImpl({ ? await buildRecommendRuntimeGuestOptions() : {}; const targetProfileId = _target?.profileId?.trim() ?? ''; - const preferSimilarWork = - activeRecommendRuntimeKind === 'puzzle' && - puzzleRuntimeReturnStage === 'platform' && - puzzleRun.nextLevelMode === 'sameWork'; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = selectedPuzzleDetail?.profileId === targetProfileId @@ -9700,34 +9732,10 @@ export function PlatformEntryFlowShellImpl({ puzzleRuntimeAuthMode === 'isolated' ? await advancePuzzleNextLevel( puzzleRun.runId, - preferSimilarWork ? { preferSimilarWork: true } : {}, + {}, runtimeGuestOptions, ) - : await advancePuzzleNextLevel( - puzzleRun.runId, - preferSimilarWork ? { preferSimilarWork: true } : {}, - ); - const nextProfileId = run.currentLevel?.profileId?.trim() ?? ''; - if ( - nextProfileId && - selectedPuzzleDetail?.profileId !== nextProfileId - ) { - const item = await getPuzzleGalleryDetail(nextProfileId).then( - (response) => response.item, - ); - const nextRecommendEntry = mapPuzzleWorkToPlatformGalleryCard(item); - setPuzzleGalleryEntries((current) => { - const nextEntries = current.filter( - (entry) => entry.profileId !== item.profileId, - ); - nextEntries.push(item); - return nextEntries; - }); - setSelectedPuzzleDetail(item); - setActiveRecommendEntryKey( - getPlatformPublicGalleryEntryKey(nextRecommendEntry), - ); - } + : await advancePuzzleNextLevel(puzzleRun.runId, {}); setPuzzleRun(run); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); @@ -9739,12 +9747,8 @@ export function PlatformEntryFlowShellImpl({ [ isPuzzleBusy, isPuzzleLeaderboardBusy, - activeRecommendRuntimeKind, puzzleRun, - puzzleRuntimeReturnStage, puzzleRuntimeAuthMode, - setActiveRecommendEntryKey, - setPuzzleGalleryEntries, resolvePuzzleErrorMessage, selectedPuzzleDetail, setIsPuzzleBusy, @@ -10956,12 +10960,18 @@ export function PlatformEntryFlowShellImpl({ } try { - const detail = await jumpHopClient.getWorkDetail(item.profileId); + const detail = await jumpHopClient.getWorkDetail(item.profileId, { + audience: 'creation', + }); setJumpHopSession(null); setJumpHopRun(null); setJumpHopWork(detail.item); setJumpHopRuntimeReturnStage('jump-hop-result'); enterCreateTab(); + pushAppHistoryPath(resolvePathForSelectionStage('jump-hop-result')); + writeCreationUrlState( + buildJumpHopCreationUrlState({ work: detail.item }), + ); setSelectionStage('jump-hop-result'); } catch (error) { setJumpHopError( @@ -11572,6 +11582,8 @@ export function PlatformEntryFlowShellImpl({ const started = await startMatch3DRunFromProfile( normalizedProfile, 'match3d-result', + false, + { authMode: 'isolated' }, ); if (!started) { setMatch3DProfile(normalizedProfile); @@ -12147,7 +12159,11 @@ export function PlatformEntryFlowShellImpl({ let work: JumpHopWorkProfileResponse | null = null; try { if (profileId) { - work = (await jumpHopClient.getWorkDetail(profileId)).item; + work = ( + await jumpHopClient.getWorkDetail(profileId, { + audience: 'creation', + }) + ).item; } } catch { work = null; @@ -12525,6 +12541,13 @@ export function PlatformEntryFlowShellImpl({ setActiveRecommendEntryKey(entryKey); setActiveRecommendRuntimeKind(runtimeKind); setActiveRecommendRuntimeError(null); + if ( + runtimeKind === 'puzzle' && + (isPuzzleBusy || puzzleStartInFlightKeyRef.current !== null) + ) { + setIsStartingRecommendEntry(false); + return; + } setIsStartingRecommendEntry(true); try { @@ -12659,6 +12682,7 @@ export function PlatformEntryFlowShellImpl({ [ activeRecommendEntryKey, barkBattleGalleryEntries, + isPuzzleBusy, saveAndExitRecommendPuzzleRuntime, selectedPuzzleDetail, setBarkBattleError, @@ -12700,6 +12724,23 @@ export function PlatformEntryFlowShellImpl({ selectRecommendRuntimeEntry, ], ); + const resolveRecommendRuntimeEntryKeyByProfileId = useCallback( + (profileId: string | null | undefined) => { + const normalizedProfileId = profileId?.trim(); + if (!normalizedProfileId) { + return null; + } + + const matchedEntry = + recommendRuntimeEntries.find( + (entry) => entry.profileId === normalizedProfileId, + ) ?? null; + return matchedEntry + ? getPlatformPublicGalleryEntryKey(matchedEntry) + : null; + }, + [recommendRuntimeEntries], + ); const recommendRuntimeContent = useMemo(() => { if ( @@ -12861,7 +12902,13 @@ export function PlatformEntryFlowShellImpl({ void dragPuzzlePiece(payload); }} onAdvanceNextLevel={(target) => { - void advancePuzzleLevel(target); + const targetEntryKey = resolveRecommendRuntimeEntryKeyByProfileId( + target?.profileId, + ); + void selectAdjacentRecommendRuntimeEntry( + 1, + targetEntryKey ?? activeRecommendEntryKey, + ); }} onRestartLevel={() => { void restartPuzzleCurrentLevel(); @@ -13115,9 +13162,10 @@ export function PlatformEntryFlowShellImpl({ squareHoleRun, submitBigFishInput, submitVisualNovelRuntimeAction, - advancePuzzleLevel, dragPuzzlePiece, + resolveRecommendRuntimeEntryKeyByProfileId, restartPuzzleCurrentLevel, + selectAdjacentRecommendRuntimeEntry, setSquareHoleError, swapPuzzlePiecesInRun, syncPuzzleRuntimeTimeout, @@ -13166,7 +13214,8 @@ export function PlatformEntryFlowShellImpl({ isLoadingPlatform: platformBootstrap.isLoadingPlatform, entries: recommendRuntimeEntries, activeEntryKey: activeRecommendEntryKey, - isStarting: isStartingRecommendEntry, + isStarting: isStartingRecommendEntry || isPuzzleBusy, + hasStartError: Boolean(activeRecommendRuntimeError), readyState: { activeKind: activeRecommendRuntimeKind, hasBabyObjectMatchDraft: Boolean(babyObjectMatchDraft), @@ -13198,10 +13247,12 @@ export function PlatformEntryFlowShellImpl({ }, [ activeRecommendEntryKey, activeRecommendRuntimeKind, + activeRecommendRuntimeError, babyObjectMatchDraft, bigFishRun, jumpHopRun, isStartingRecommendEntry, + isPuzzleBusy, match3dRun, platformBootstrap.isLoadingPlatform, platformBootstrap.platformTab, @@ -13952,8 +14003,11 @@ export function PlatformEntryFlowShellImpl({ canDeleteBigFish: isBigFishCreationVisible, canDeleteMatch3D: true, canDeleteSquareHole: isSquareHoleCreationVisible, + canDeleteJumpHop: isJumpHopCreationVisible, + canDeleteWoodenFish: true, canDeletePuzzle: true, canDeleteBabyObjectMatch: isBabyObjectMatchVisible, + canDeleteBarkBattle: true, canDeleteVisualNovel: true, onOpenRpgDraft: (item) => { runProtectedAction(() => { @@ -13993,12 +14047,16 @@ export function PlatformEntryFlowShellImpl({ }); } : undefined, + onDeleteJumpHop: isJumpHopCreationVisible + ? handleDeleteJumpHopWork + : undefined, onOpenWoodenFishDetail: (item) => { runProtectedAction(() => { markCreationFlowReturnToDraftShelf(); void openWoodenFishDraft(item); }); }, + onDeleteWoodenFish: handleDeleteWoodenFishWork, onOpenMatch3DDetail: (item) => { runProtectedAction(() => { markCreationFlowReturnToDraftShelf(); @@ -14038,6 +14096,7 @@ export function PlatformEntryFlowShellImpl({ openBarkBattleDraft(item); }); }, + onDeleteBarkBattle: handleDeleteBarkBattleWork, onOpenVisualNovelDetail: (item) => { runProtectedAction(() => { markCreationFlowReturnToDraftShelf(); @@ -14057,11 +14116,14 @@ export function PlatformEntryFlowShellImpl({ handleClaimPuzzlePointIncentive, handleDeleteBabyObjectMatchWork, handleDeleteBigFishWork, + handleDeleteBarkBattleWork, + handleDeleteJumpHopWork, handleDeleteMatch3DWork, handleDeletePublishedWork, handleDeletePuzzleWork, handleDeleteSquareHoleWork, handleDeleteVisualNovelWork, + handleDeleteWoodenFishWork, isBabyObjectMatchVisible, isBigFishCreationVisible, isJumpHopCreationVisible, @@ -14884,7 +14946,13 @@ export function PlatformEntryFlowShellImpl({ normalizedProfile, 'match3d-result', false, - options, + { + ...options, + authMode: + normalizedProfile.publicationStatus === 'draft' + ? 'isolated' + : undefined, + }, ); }} /> diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index 5390415a..9f8e3cd4 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -24,7 +24,11 @@ import { formatPlatformWorkDisplayTags, formatPlatformWorldTime, isBarkBattleGalleryEntry, + isCustomWorldGalleryEntry, isEdutainmentGalleryEntry, + isJumpHopGalleryEntry, + isPuzzleClearGalleryEntry, + isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, resolvePlatformWorkAuthorDisplayName, resolvePlatformPublicWorkCode, @@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) { if ('sourceType' in entry && entry.sourceType === 'puzzle') { return '拼图'; } + if (isPuzzleClearGalleryEntry(entry)) { + return '拼消消'; + } if ('sourceType' in entry && entry.sourceType === 'big-fish') { return '大鱼吃小鱼'; } + if (isJumpHopGalleryEntry(entry)) { + return '跳一跳'; + } + if (isWoodenFishGalleryEntry(entry)) { + return '敲木鱼'; + } if ('sourceType' in entry && entry.sourceType === 'match3d') { return '抓大鹅'; } @@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) { if (isEdutainmentGalleryEntry(entry)) { return entry.templateName; } - return 'RPG'; + if (isCustomWorldGalleryEntry(entry)) { + return 'RPG'; + } + + throw new Error('未知公开作品类型。'); } function getAuthorAvatarLabel(authorDisplayName: string) { diff --git a/src/components/platform-entry/platformCreationLaunchModel.test.ts b/src/components/platform-entry/platformCreationLaunchModel.test.ts index ccaa9977..240c715e 100644 --- a/src/components/platform-entry/platformCreationLaunchModel.test.ts +++ b/src/components/platform-entry/platformCreationLaunchModel.test.ts @@ -64,7 +64,7 @@ describe('platformCreationLaunchModel', () => { test('keeps unknown creation type as a prepared noop', () => { expect( resolvePlatformCreationLaunchIntent({ - type: 'unknown-template', + type: 'unknown-template' as never, isBabyObjectMatchVisible: true, }), ).toEqual({ diff --git a/src/components/platform-entry/platformEntryCreationTypes.test.ts b/src/components/platform-entry/platformEntryCreationTypes.test.ts index 4daf6829..c1b065e0 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.test.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test, vi } from 'vitest'; +import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService'; import { derivePlatformCreationTypes, groupVisiblePlatformCreationTypes, @@ -81,7 +82,7 @@ test('database entry config controls visibility open state and display order', ( test('visible platform creation types hide invisible cards and put locked cards last', () => { const cards = derivePlatformCreationTypes([ { - id: 'hidden', + id: 'airp', title: '隐藏', subtitle: '隐藏', badge: '隐藏', @@ -95,7 +96,7 @@ test('visible platform creation types hide invisible cards and put locked cards updatedAtMicros: 1, }, { - id: 'locked', + id: 'visual-novel', title: '锁定', subtitle: '锁定', badge: '即将开放', @@ -109,7 +110,7 @@ test('visible platform creation types hide invisible cards and put locked cards updatedAtMicros: 1, }, { - id: 'open', + id: 'rpg', title: '开放', subtitle: '开放', badge: '可创建', @@ -125,13 +126,13 @@ test('visible platform creation types hide invisible cards and put locked cards ]); expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual( - ['open', 'locked'], + ['rpg', 'visual-novel'], ); - expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false); - expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true); - expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false); - expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false); - expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true); + expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false); + expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true); + expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false); + expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false); + expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true); expect( cards.every((item) => item.imageSrc.startsWith('/creation-type-references/'), @@ -288,7 +289,7 @@ test('groups visible platform creation types by backend category metadata', () = updatedAtMicros: 1, }, { - id: 'hidden', + id: 'airp', title: '隐藏入口', subtitle: '隐藏', badge: '隐藏', @@ -319,7 +320,7 @@ test('groups visible platform creation types by backend category metadata', () = test('falls back when backend creation type category metadata is missing', () => { const cards = derivePlatformCreationTypes([ { - id: 'legacy-entry', + id: 'creative-agent', title: '历史入口', subtitle: '旧数据缺少分类字段', badge: '可创建', @@ -336,7 +337,7 @@ test('falls back when backend creation type category metadata is missing', () => expect(cards[0]).toEqual( expect.objectContaining({ - id: 'legacy-entry', + id: 'creative-agent', categoryId: 'recommended', categoryLabel: '热门推荐', }), @@ -348,3 +349,24 @@ test('falls back when backend creation type category metadata is missing', () => }), ]); }); + +test('throws when backend sends an unknown creation type id', () => { + const unknownEntry = { + id: 'unknown-play', + title: '未知玩法', + subtitle: '未知', + badge: '未知', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 10, + categoryId: 'recommended', + categoryLabel: '热门推荐', + categorySortOrder: 20, + updatedAtMicros: 1, + } as unknown as CreationEntryTypeConfig; + + expect(() => derivePlatformCreationTypes([unknownEntry])).toThrow( + '未知创作类型:unknown-play', + ); +}); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index a87ae967..4c8b7143 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -1,7 +1,11 @@ +import { + assertPlatformCreationTypeId, + type PlatformCreationTypeId, +} from '../../../packages/shared/src/contracts/playTypes'; import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService'; import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility'; -export type PlatformCreationTypeId = string; +export type { PlatformCreationTypeId }; export type PlatformCreationTypeCard = { id: PlatformCreationTypeId; @@ -117,21 +121,25 @@ export function derivePlatformCreationTypes( ): PlatformCreationTypeCard[] { const orderedCards = [...creationTypes] .sort((left, right) => left.sortOrder - right.sortOrder) - .map((item) => ({ - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - imageSrc: item.imageSrc, - locked: !item.open, - categoryId: normalizeCategoryId(item.categoryId), - categoryLabel: normalizeCategoryLabel(item.categoryLabel), - categorySortOrder: item.categorySortOrder, - sortOrder: item.sortOrder, - hidden: - !item.visible || - (item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()), - })); + .map((item) => { + const id = assertPlatformCreationTypeId(item.id); + + return { + id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + imageSrc: item.imageSrc, + locked: !item.open, + categoryId: normalizeCategoryId(item.categoryId), + categoryLabel: normalizeCategoryLabel(item.categoryLabel), + categorySortOrder: item.categorySortOrder, + sortOrder: item.sortOrder, + hidden: + !item.visible || + (id === 'baby-object-match' && !isEdutainmentEntryEnabled()), + }; + }); return [ ...orderedCards.filter((item) => !item.hidden && !item.locked), diff --git a/src/components/platform-entry/platformPublicGalleryFlow.test.ts b/src/components/platform-entry/platformPublicGalleryFlow.test.ts index 5c4020ba..3011776a 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.test.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.test.ts @@ -659,6 +659,13 @@ test('platform public gallery flow resolves recommend runtime auto-start gates', isStarting: true, }), ).toEqual({ type: 'noop' }); + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + activeEntryKey: getPlatformPublicGalleryEntryKey(entry), + hasStartError: true, + }), + ).toEqual({ type: 'noop' }); }); test('platform public gallery flow resolves recommend runtime auto-start target', () => { @@ -695,6 +702,15 @@ test('platform public gallery flow resolves recommend runtime auto-start target' type: 'start', entry: activeEntry, }); + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + readyState: { activeKind: null }, + }), + ).toEqual({ + type: 'start', + entry: activeEntry, + }); expect( resolvePlatformRecommendRuntimeAutoStartDecision({ ...baseInput, diff --git a/src/components/platform-entry/platformPublicGalleryFlow.ts b/src/components/platform-entry/platformPublicGalleryFlow.ts index 5f5da533..670bc3cf 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.ts @@ -165,6 +165,7 @@ export type PlatformRecommendRuntimeAutoStartInput = { entries: readonly PlatformPublicGalleryCard[]; activeEntryKey: string | null; isStarting: boolean; + hasStartError?: boolean; readyState: PlatformRecommendRuntimeReadyState; }; @@ -496,7 +497,11 @@ export function resolvePlatformRecommendRuntimeAutoStartDecision( activeEntry !== null && isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState); - if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) { + if ( + (activeEntry !== null && isActiveRuntimeReady) || + input.isStarting || + (activeEntry !== null && input.hasStartError) + ) { return { type: 'noop' }; } diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index 3b1bf38a..7600ad22 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -599,7 +599,27 @@ test('platform public work detail flow resolves open strategy', () => { test('platform public work detail flow maps work summaries to detail entries', () => { const rpgEntry = buildRpgLibraryEntry(); - expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toBe(rpgEntry); + expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toEqual({ + ownerUserId: rpgEntry.ownerUserId, + profileId: rpgEntry.profileId, + publicWorkCode: rpgEntry.publicWorkCode, + authorPublicUserCode: rpgEntry.authorPublicUserCode, + visibility: rpgEntry.visibility, + publishedAt: rpgEntry.publishedAt, + updatedAt: rpgEntry.updatedAt, + authorDisplayName: rpgEntry.authorDisplayName, + worldName: rpgEntry.worldName, + subtitle: rpgEntry.subtitle, + summaryText: rpgEntry.summaryText, + coverImageSrc: rpgEntry.coverImageSrc, + themeMode: rpgEntry.themeMode, + playableNpcCount: rpgEntry.playableNpcCount, + landmarkCount: rpgEntry.landmarkCount, + playCount: rpgEntry.playCount ?? 0, + remixCount: rpgEntry.remixCount ?? 0, + likeCount: rpgEntry.likeCount ?? 0, + recentPlayCount7d: rpgEntry.recentPlayCount7d ?? 0, + }); expect(mapPuzzleWorkToPublicWorkDetail(buildPuzzleWork())).toMatchObject({ sourceType: 'puzzle', workId: 'puzzle-work', @@ -838,9 +858,20 @@ test('platform public work detail flow resolves like intent', () => { expect( resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d')), ).toEqual({ - type: 'like-rpg-gallery', - ownerUserId: 'user-1', - profileId: 'match3d-profile', + type: 'unsupported', + errorMessage: '作品类型 match3d 暂不支持点赞。', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('jump-hop')), + ).toEqual({ + type: 'unsupported', + errorMessage: '作品类型 jump-hop 暂不支持点赞。', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('wooden-fish')), + ).toEqual({ + type: 'unsupported', + errorMessage: '作品类型 wooden-fish 暂不支持点赞。', }); expect( resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment')), diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index 33598106..909e8038 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -297,8 +297,28 @@ export function isRpgPublicWorkDetailEntry( export function mapRpgGalleryCardToPublicWorkDetail( entry: PlatformRpgPublicWorkDetailEntry, -): PlatformPublicGalleryCard { - return entry; +): CustomWorldGalleryCard { + return { + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + publicWorkCode: entry.publicWorkCode, + authorPublicUserCode: entry.authorPublicUserCode, + visibility: entry.visibility, + publishedAt: entry.publishedAt, + updatedAt: entry.updatedAt, + authorDisplayName: entry.authorDisplayName, + worldName: entry.worldName, + subtitle: entry.subtitle, + summaryText: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + themeMode: entry.themeMode, + playableNpcCount: entry.playableNpcCount, + landmarkCount: entry.landmarkCount, + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + recentPlayCount7d: entry.recentPlayCount7d ?? 0, + }; } function isRpgPublicWorkLibraryEntry( @@ -689,6 +709,27 @@ export function resolvePlatformPublicWorkLikeIntent( }; } + if (isWoodenFishGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '作品类型 wooden-fish 暂不支持点赞。', + }; + } + + if (isJumpHopGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '作品类型 jump-hop 暂不支持点赞。', + }; + } + + if (isMatch3DGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '作品类型 match3d 暂不支持点赞。', + }; + } + if (isBarkBattleGalleryEntry(entry)) { return { type: 'unsupported', diff --git a/src/components/platform-entry/platformRecommendation.test.ts b/src/components/platform-entry/platformRecommendation.test.ts new file mode 100644 index 00000000..a7b51244 --- /dev/null +++ b/src/components/platform-entry/platformRecommendation.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from 'vitest'; + +import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { buildPlatformRecommendedEntries } from './platformRecommendation'; + +const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z'); + +type PublicCardTestParams = { + id: string; + sourceType?: 'puzzle' | 'match3d' | 'jump-hop'; + subtitle?: string; + summaryText?: string; + coverImageSrc?: string | null; + themeTags?: string[]; + playCount?: number; + remixCount?: number; + likeCount?: number; + recentPlayCount7d?: number; + publishedAt?: string | null; + updatedAt?: string; +}; + +function buildPublicCard( + params: PublicCardTestParams, +): PlatformPublicGalleryCard { + const sourceType = params.sourceType ?? 'puzzle'; + + return { + sourceType, + workId: `${sourceType}-work-${params.id}`, + profileId: `${sourceType}-profile-${params.id}`, + publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`, + ownerUserId: `user-${params.id}`, + authorDisplayName: `${params.id} 作者`, + worldName: `${params.id} 作品`, + subtitle: params.subtitle ?? '公开作品', + summaryText: params.summaryText ?? '公开作品摘要。', + coverImageSrc: params.coverImageSrc ?? `${params.id}.png`, + themeTags: params.themeTags ?? ['推荐'], + playCount: params.playCount ?? 0, + remixCount: params.remixCount ?? 0, + likeCount: params.likeCount ?? 0, + recentPlayCount7d: params.recentPlayCount7d ?? 0, + visibility: 'published', + publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z', + updatedAt: + params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z', + } satisfies PlatformPublicGalleryCard; +} + +describe('buildPlatformRecommendedEntries', () => { + test('combines heat, freshness and featured boost after de-duplicating works', () => { + const coldEntry = buildPublicCard({ + id: 'cold', + playCount: 1, + publishedAt: '2026-04-01T12:00:00.000Z', + }); + const hotRecentEntry = buildPublicCard({ + id: 'hot', + playCount: 8, + likeCount: 4, + recentPlayCount7d: 16, + publishedAt: '2026-06-06T12:00:00.000Z', + }); + const curatedEntry = buildPublicCard({ + id: 'curated', + playCount: 0, + likeCount: 0, + publishedAt: '2026-05-10T12:00:00.000Z', + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [curatedEntry], + latestEntries: [coldEntry, hotRecentEntry, curatedEntry], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + hotRecentEntry.profileId, + curatedEntry.profileId, + coldEntry.profileId, + ]); + }); + + test('interleaves close-score works from different play types', () => { + const firstPuzzle = buildPublicCard({ + id: 'puzzle-a', + sourceType: 'puzzle', + likeCount: 2, + }); + const secondPuzzle = buildPublicCard({ + id: 'puzzle-b', + sourceType: 'puzzle', + likeCount: 2, + }); + const match3d = buildPublicCard({ + id: 'match3d-a', + sourceType: 'match3d', + likeCount: 2, + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [], + latestEntries: [firstPuzzle, secondPuzzle, match3d], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + firstPuzzle.profileId, + match3d.profileId, + secondPuzzle.profileId, + ]); + }); + + test('separates same-type candidates while alternatives remain', () => { + const hotPuzzle = buildPublicCard({ + id: 'hot-puzzle', + sourceType: 'puzzle', + recentPlayCount7d: 50, + likeCount: 20, + }); + const warmPuzzle = buildPublicCard({ + id: 'warm-puzzle', + sourceType: 'puzzle', + recentPlayCount7d: 32, + likeCount: 12, + }); + const coldMatch3d = buildPublicCard({ + id: 'cold-match3d', + sourceType: 'match3d', + publishedAt: '2026-04-01T12:00:00.000Z', + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [], + latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + hotPuzzle.profileId, + coldMatch3d.profileId, + warmPuzzle.profileId, + ]); + }); + + test('falls back to same-type adjacency when no other type remains', () => { + const firstPuzzle = buildPublicCard({ + id: 'only-puzzle-a', + sourceType: 'puzzle', + recentPlayCount7d: 8, + }); + const secondPuzzle = buildPublicCard({ + id: 'only-puzzle-b', + sourceType: 'puzzle', + recentPlayCount7d: 4, + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [], + latestEntries: [firstPuzzle, secondPuzzle], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + firstPuzzle.profileId, + secondPuzzle.profileId, + ]); + }); +}); diff --git a/src/components/platform-entry/platformRecommendation.ts b/src/components/platform-entry/platformRecommendation.ts new file mode 100644 index 00000000..ccd6022f --- /dev/null +++ b/src/components/platform-entry/platformRecommendation.ts @@ -0,0 +1,225 @@ +import { + buildPlatformPublicGalleryCardKey, + isEdutainmentGalleryEntry, + type PlatformPublicGalleryCard, + resolvePlatformPublicWorkSourceType, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +const MS_PER_DAY = 86_400_000; +const FEATURED_BONUS = 14; +const MAX_FRESHNESS_SCORE = 12; + +export type PlatformRecommendationOptions = { + nowMs?: number; + limit?: number; +}; + +type RecommendationCandidate = { + entry: PlatformPublicGalleryCard; + key: string; + sourceType: string; + firstSeenIndex: number; + isFeatured: boolean; + timestampMs: number; + score: number; +}; + +type PlatformRecommendationMetricKey = + | 'playCount' + | 'remixCount' + | 'likeCount' + | 'recentPlayCount7d'; + +function parseRecommendationTimestamp(value: string | null | undefined) { + if (!value) { + return 0; + } + + const normalized = value.trim(); + const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u); + if (numericTimestamp?.[1]) { + const rawTimestamp = Number(numericTimestamp[1]); + if (Number.isFinite(rawTimestamp)) { + const absoluteTimestamp = Math.abs(rawTimestamp); + if (absoluteTimestamp >= 1_000_000_000_000_000) { + return rawTimestamp / 1000; + } + if (absoluteTimestamp >= 1_000_000_000_000) { + return rawTimestamp; + } + if (absoluteTimestamp >= 1_000_000_000) { + return rawTimestamp * 1000; + } + } + } + + const timestamp = new Date(normalized).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) { + return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt); +} + +function getRecommendationMetric( + entry: PlatformPublicGalleryCard, + key: PlatformRecommendationMetricKey, +) { + const value = ( + entry as Partial> + )[key]; + return Math.max(0, Math.round(Number(value ?? 0) || 0)); +} + +function getRecommendationSourceType(entry: PlatformPublicGalleryCard) { + if (isEdutainmentGalleryEntry(entry)) { + return `edutainment:${entry.templateId}`; + } + + return resolvePlatformPublicWorkSourceType(entry); +} + +function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) { + return 'themeTags' in entry && Array.isArray(entry.themeTags) + ? entry.themeTags + : []; +} + +function scoreRecommendationCandidate( + candidate: Omit, + nowMs: number, +) { + const entry = candidate.entry; + const ageDays = + candidate.timestampMs > 0 + ? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY) + : Number.POSITIVE_INFINITY; + const freshnessScore = Number.isFinite(ageDays) + ? MAX_FRESHNESS_SCORE / (1 + ageDays / 7) + : 0; + const coverScore = entry.coverImageSrc ? 1.5 : 0; + const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6; + const summaryScore = entry.summaryText.trim() ? 0.8 : 0; + + return ( + (candidate.isFeatured ? FEATURED_BONUS : 0) + + Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 + + Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 + + Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 + + Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 + + freshnessScore + + coverScore + + tagScore + + summaryScore + ); +} + +function compareRecommendationCandidates( + left: RecommendationCandidate, + right: RecommendationCandidate, +) { + const scoreDiff = right.score - left.score; + if (scoreDiff !== 0) { + return scoreDiff; + } + + const timeDiff = right.timestampMs - left.timestampMs; + if (timeDiff !== 0) { + return timeDiff; + } + + if (left.firstSeenIndex !== right.firstSeenIndex) { + return left.firstSeenIndex - right.firstSeenIndex; + } + + return left.key.localeCompare(right.key, 'zh-CN'); +} + +function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) { + const remaining = [...candidates]; + const result: RecommendationCandidate[] = []; + + while (remaining.length > 0) { + const lastSourceType = result[result.length - 1]?.sourceType ?? null; + let nextIndex = 0; + + if (lastSourceType) { + const alternativeIndex = remaining.findIndex( + (candidate) => candidate.sourceType !== lastSourceType, + ); + if (alternativeIndex > 0) { + nextIndex = alternativeIndex; + } + } + + const [nextCandidate] = remaining.splice(nextIndex, 1); + if (nextCandidate) { + result.push(nextCandidate); + } + } + + return result; +} + +export function buildPlatformRecommendedEntries( + params: { + featuredEntries: PlatformPublicGalleryCard[]; + latestEntries: PlatformPublicGalleryCard[]; + }, + options: PlatformRecommendationOptions = {}, +) { + const candidateMap = new Map< + string, + Omit + >(); + let firstSeenIndex = 0; + + const collectEntries = ( + entries: PlatformPublicGalleryCard[], + source: 'featured' | 'latest', + ) => { + entries.forEach((entry) => { + const key = buildPlatformPublicGalleryCardKey(entry); + const timestampMs = getRecommendationTimestamp(entry); + const existing = candidateMap.get(key); + if (existing) { + existing.isFeatured = existing.isFeatured || source === 'featured'; + if (timestampMs >= existing.timestampMs) { + existing.entry = entry; + existing.timestampMs = timestampMs; + } + return; + } + + candidateMap.set(key, { + entry, + key, + sourceType: getRecommendationSourceType(entry), + firstSeenIndex, + isFeatured: source === 'featured', + timestampMs, + }); + firstSeenIndex += 1; + }); + }; + + collectEntries(params.featuredEntries, 'featured'); + collectEntries(params.latestEntries, 'latest'); + + const nowMs = options.nowMs ?? Date.now(); + const rankedCandidates = Array.from(candidateMap.values()) + .map((candidate) => ({ + ...candidate, + score: scoreRecommendationCandidate(candidate, nowMs), + })) + .sort(compareRecommendationCandidates); + const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates); + const limit = + typeof options.limit === 'number' && options.limit > 0 + ? Math.floor(options.limit) + : diversifiedCandidates.length; + + return diversifiedCandidates + .slice(0, limit) + .map((candidate) => candidate.entry); +} diff --git a/src/components/platform-entry/puzzleDraftGenerationState.test.ts b/src/components/platform-entry/puzzleDraftGenerationState.test.ts index dd4a955c..fa7b49b3 100644 --- a/src/components/platform-entry/puzzleDraftGenerationState.test.ts +++ b/src/components/platform-entry/puzzleDraftGenerationState.test.ts @@ -21,7 +21,24 @@ describe('isPuzzleCompileActionReady', () => { expect(isPuzzleCompileActionReady(session)).toBe(false); }); - it('treats compile action as ready after the selected cover exists', () => { + it('keeps compile action generating when only the selected cover exists', () => { + const session = { + sessionId: 'puzzle-session-1', + draft: { + coverImageSrc: '/generated-puzzle-assets/session/cover.png', + levels: [ + { + generationStatus: 'generating', + coverImageSrc: '/generated-puzzle-assets/session/cover.png', + }, + ], + }, + } as PuzzleAgentSessionSnapshot; + + expect(isPuzzleCompileActionReady(session)).toBe(false); + }); + + it('treats compile action as ready after all runtime assets exist', () => { const session = { sessionId: 'puzzle-session-1', draft: { @@ -30,6 +47,12 @@ describe('isPuzzleCompileActionReady', () => { { generationStatus: 'ready', coverImageSrc: '/generated-puzzle-assets/session/cover.png', + levelSceneImageObjectKey: + 'generated-puzzle-assets/session/level-scene.png', + uiSpritesheetImageObjectKey: + 'generated-puzzle-assets/session/ui-spritesheet.png', + levelBackgroundImageObjectKey: + 'generated-puzzle-assets/session/level-background.png', }, ], }, diff --git a/src/components/platform-entry/puzzleDraftGenerationState.ts b/src/components/platform-entry/puzzleDraftGenerationState.ts index f00ee282..c7d2a2ec 100644 --- a/src/components/platform-entry/puzzleDraftGenerationState.ts +++ b/src/components/platform-entry/puzzleDraftGenerationState.ts @@ -4,6 +4,13 @@ function hasText(value: string | null | undefined) { return typeof value === 'string' && value.trim().length > 0; } +function hasAssetReference( + imageSrc: string | null | undefined, + objectKey: string | null | undefined, +) { + return hasText(imageSrc) || hasText(objectKey); +} + export function isPuzzleCompileActionReady( session: PuzzleAgentSessionSnapshot, ) { @@ -11,10 +18,19 @@ export function isPuzzleCompileActionReady( if (!draft) { return false; } - if (hasText(draft.coverImageSrc)) { - return true; - } return ( - draft.levels?.some((level) => hasText(level.coverImageSrc)) === true + draft.levels?.some( + (level) => + (hasText(draft.coverImageSrc) || hasText(level.coverImageSrc)) && + hasAssetReference(level.levelSceneImageSrc, level.levelSceneImageObjectKey) && + hasAssetReference( + level.uiSpritesheetImageSrc, + level.uiSpritesheetImageObjectKey, + ) && + hasAssetReference( + level.levelBackgroundImageSrc, + level.levelBackgroundImageObjectKey, + ), + ) === true ); } diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 1b4d52ca..ed08d448 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -13,16 +13,16 @@ import type { CustomWorldAgentSessionSnapshot, CustomWorldWorkSummary, } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { + BabyObjectMatchDraft, + CreateBabyObjectMatchDraftRequest, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { JumpHopRuntimeRunSnapshotResponse, JumpHopWorkDetailResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, } from '../../../packages/shared/src/contracts/jumpHop'; -import type { - BabyObjectMatchDraft, - CreateBabyObjectMatchDraftRequest, -} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; @@ -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 { @@ -71,7 +74,6 @@ import { submitBigFishInput, } from '../../services/big-fish-runtime'; import { listBigFishWorks } from '../../services/big-fish-works'; -import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; import { type CreationEntryConfig, fetchCreationEntryConfig, @@ -91,6 +93,7 @@ import { regenerateBabyObjectMatchDraftAssets, saveBabyObjectMatchDraft, } from '../../services/edutainment-baby-object'; +import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; import { match3dCreationClient } from '../../services/match3d-creation'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { @@ -155,6 +158,7 @@ import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient, getRpgEntryWorldGalleryDetailByCode, + likeRpgEntryWorldGallery, recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; @@ -334,10 +338,6 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = { notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }; -const RECOMMEND_RUNTIME_AUTH_OPTIONS = { - ...ISOLATED_RUNTIME_AUTH_OPTIONS, - runtimeGuestToken: 'runtime-guest-token', -}; const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS; function getPlatformTabPanel(tab: string) { @@ -542,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(), @@ -2005,6 +2006,18 @@ function buildReadyPuzzleDraft( '/generated-puzzle-assets/puzzle-session-recovered/ui/background.png', uiBackgroundImageObjectKey: 'generated-puzzle-assets/puzzle-session-recovered/ui/background.png', + levelSceneImageSrc: + '/generated-puzzle-assets/puzzle-session-recovered/level-scene.png', + levelSceneImageObjectKey: + 'generated-puzzle-assets/puzzle-session-recovered/level-scene.png', + uiSpritesheetImageSrc: + '/generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png', + uiSpritesheetImageObjectKey: + 'generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png', + levelBackgroundImageSrc: + '/generated-puzzle-assets/puzzle-session-recovered/level-background.png', + levelBackgroundImageObjectKey: + 'generated-puzzle-assets/puzzle-session-recovered/level-background.png', generationStatus: 'ready', }, ], @@ -5216,6 +5229,101 @@ test('running puzzle draft opens generation progress from draft tab', async () = }); }); +test('puzzle text-only form stays generating when compile starts background image without cover', async () => { + const user = userEvent.setup(); + const initialSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-text-only', + stage: 'collecting_anchors', + progressPercent: 0, + draft: null, + }); + const generatingDraft = buildReadyPuzzleDraft({ + workTitle: '文字直创拼图', + workDescription: '只输入文字后后台继续生成图片。', + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating', + levels: [ + { + ...buildReadyPuzzleDraft().levels![0]!, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + levelSceneImageSrc: null, + levelSceneImageObjectKey: null, + uiSpritesheetImageSrc: null, + uiSpritesheetImageObjectKey: null, + levelBackgroundImageSrc: null, + levelBackgroundImageObjectKey: null, + generationStatus: 'generating', + }, + ], + }); + const generatingSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-text-only', + stage: 'image_refining', + progressPercent: 88, + draft: generatingDraft, + lastAssistantReply: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。', + resultPreview: { + draft: generatingDraft, + blockers: [ + { + id: 'missing-cover-image-puzzle-level-1', + code: 'MISSING_COVER_IMAGE', + message: '正式拼图图片尚未确定', + }, + ], + qualityFindings: [], + publishReady: false, + }, + }); + + vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({ + session: initialSession, + }); + vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({ + operation: { + operationId: 'compile-puzzle-text-only', + type: 'compile_puzzle_draft', + status: 'completed', + phaseLabel: '首关拼图草稿', + phaseDetail: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。', + progress: 0.88, + }, + session: generatingSession, + }); + vi.mocked(getPuzzleAgentSession).mockResolvedValue({ + session: generatingSession, + }); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); + + expect( + await screen.findByRole('progressbar', { + name: '拼图图片生成进度', + }), + ).toBeTruthy(); + await waitFor(() => { + expect(executePuzzleAgentAction).toHaveBeenCalledWith( + 'puzzle-session-text-only', + expect.objectContaining({ action: 'compile_puzzle_draft' }), + ); + }); + expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull(); + expect(screen.queryByText('请先选择一张正式拼图图片。')).toBeNull(); + expect(screen.queryByText('拼图结果页')).toBeNull(); + expect(updatePuzzleWork).not.toHaveBeenCalled(); + expect(startLocalPuzzleRun).not.toHaveBeenCalled(); +}); + test('puzzle form checks mud points before creating a draft', async () => { const user = userEvent.setup(); vi.mocked(getProfileDashboard).mockResolvedValue({ @@ -6523,6 +6631,7 @@ test('clicking a public work while logged out opens public detail without starti />, ); + await openDiscoverHub(user); const workCards = await screen.findAllByRole('button', { name: /潮雾列岛/u, }); @@ -6576,20 +6685,29 @@ test('logged out public detail gates puzzle start and remix before real actions' />, ); - await waitFor(() => { - expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); - }); + await openDiscoverHub(user); const workCards = screen.getAllByRole('button', { name: /星桥机关/u }); await user.click(workCards[0]!); expect(await screen.findByText('详情')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '启动' })); + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: 'puzzle-profile-public-1', + levelId: null, + }, + expect.objectContaining({ + runtimeGuestToken: 'runtime-guest-token', + }), + ); + }); expect(requireAuth).toHaveBeenCalledTimes(1); - expect(startPuzzleRun).not.toHaveBeenCalled(); + requireAuth.mockClear(); await user.click(screen.getByRole('button', { name: '作品改造' })); - expect(requireAuth).toHaveBeenCalledTimes(2); + expect(requireAuth).toHaveBeenCalledTimes(1); expect(remixPuzzleGalleryWork).not.toHaveBeenCalled(); }); @@ -7369,6 +7487,75 @@ test('home recommendation share opens publish share modal', async () => { .toBeTruthy(); }); +test('home recommendation wooden fish like does not call RPG gallery like', async () => { + const user = userEvent.setup(); + const publishedWoodenFishWork: WoodenFishGalleryCardResponse = { + publicWorkCode: 'WF-3A9EC89B', + workId: 'wooden-fish-work-like-1', + profileId: 'wooden-fish-profile-like-1', + ownerUserId: 'wooden-fish-user-1', + authorDisplayName: '木鱼作者', + workTitle: '莲台木鱼', + workDescription: '推荐页里的敲木鱼作品。', + coverImageSrc: null, + themeTags: ['敲木鱼'], + publicationStatus: 'published', + playCount: 0, + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + generationStatus: 'ready', + }; + + vi.mocked(woodenFishClient.listGallery).mockResolvedValue({ + items: [publishedWoodenFishWork], + hasMore: false, + nextCursor: null, + }); + vi.mocked(woodenFishClient.startRun).mockResolvedValue({ + run: { + runId: 'wooden-fish-run-like-1', + profileId: publishedWoodenFishWork.profileId, + ownerUserId: publishedWoodenFishWork.ownerUserId, + status: 'playing', + totalTapCount: 0, + wordCounters: [], + startedAtMs: 1, + updatedAtMs: 1, + finishedAtMs: null, + }, + }); + vi.mocked(likeRpgEntryWorldGallery).mockResolvedValue( + buildMockRpgGalleryDetail({ + ownerUserId: 'custom-world-user-1', + profileId: 'custom-world-profile-1', + publicWorkCode: 'CW-00000001', + authorPublicUserCode: 'SY-00000001', + visibility: 'published', + publishedAt: '2026-04-25T09:00:00.000Z', + updatedAt: '2026-04-25T09:00:00.000Z', + authorDisplayName: 'RPG 作者', + worldName: '不应被点赞的 RPG', + subtitle: '错误分流', + summaryText: 'WF 点赞不应进入这里。', + coverImageSrc: null, + themeMode: 'mythic', + playableNpcCount: 0, + landmarkCount: 0, + likeCount: 1, + }), + ); + + render(); + + const meta = await screen.findByLabelText('莲台木鱼 作品信息'); + await user.click(within(meta).getByRole('button', { name: '点赞 0' })); + + expect(likeRpgEntryWorldGallery).not.toHaveBeenCalled(); + expect( + await screen.findByText('作品类型 wooden-fish 暂不支持点赞。'), + ).toBeTruthy(); +}); + test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => { const publishedPuzzleWork = { workId: 'puzzle-work-public-2', @@ -7471,12 +7658,6 @@ test('logged out home recommendation next starts the next puzzle work', async () />, ); - const recommendNavButton = document.querySelector( - '.platform-bottom-nav [aria-label="推荐"]', - ); - expect(recommendNavButton).toBeTruthy(); - await user.click(recommendNavButton!); - await waitFor(() => { expect(startPuzzleRun).toHaveBeenCalledWith( { @@ -7505,7 +7686,114 @@ test('logged out home recommendation next starts the next puzzle work', async () }); }); -test('home recommendation puzzle next level switches to similar work detail', async () => { +test('home recommendation keeps cover while switching during a pending puzzle start', async () => { + const user = userEvent.setup(); + const firstWork = { + workId: 'puzzle-work-pending-next-1', + profileId: 'puzzle-profile-pending-next-1', + ownerUserId: 'user-2', + sourceSessionId: 'puzzle-session-pending-next-1', + authorDisplayName: '拼图作者', + levelName: '雨港电路', + summary: '第一张公开拼图仍在启动。', + themeTags: ['雨港', '拼图'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T10:00:00.000Z', + publishedAt: '2026-04-25T10:00:00.000Z', + playCount: 47, + likeCount: 1, + publishReady: true, + } satisfies PuzzleWorkSummary; + const secondWork = { + ...firstWork, + workId: 'puzzle-work-pending-next-2', + profileId: 'puzzle-profile-pending-next-2', + ownerUserId: 'user-3', + sourceSessionId: 'puzzle-session-pending-next-2', + authorDisplayName: '贝壳作者', + levelName: '贝壳潮汐', + summary: '第二张公开拼图。', + themeTags: ['贝壳', '拼图'], + playCount: 1, + likeCount: 0, + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + } satisfies PuzzleWorkSummary; + let resolveFirstRun!: (value: { run: PuzzleRunSnapshot }) => void; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [firstWork, secondWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({ + item: profileId === secondWork.profileId ? secondWork : firstWork, + })); + vi.mocked(startPuzzleRun).mockImplementationOnce( + (async () => + new Promise((resolve) => { + resolveFirstRun = resolve; + })) as typeof startPuzzleRun, + ); + + render( + {}, + requireAuth: (action) => action(), + })} + />, + ); + + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: firstWork.profileId, + levelId: null, + }, + expect.objectContaining({ + runtimeGuestToken: 'runtime-guest-token', + }), + ); + }); + + await user.click(await screen.findByRole('button', { name: '下一个' })); + + expect( + screen.queryByText('作品暂时无法进入,请稍后再试。'), + ).toBeNull(); + expect( + await screen.findByLabelText('贝壳潮汐 作品信息', undefined, { + timeout: 3000, + }), + ).toBeTruthy(); + expect(startPuzzleRun).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirstRun({ + run: buildMockPuzzleRun(firstWork.profileId, '后端拼图关卡'), + }); + }); + + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: secondWork.profileId, + levelId: null, + }, + expect.objectContaining({ + runtimeGuestToken: 'runtime-guest-token', + }), + ); + }); + expect( + screen.queryByText('作品暂时无法进入,请稍后再试。'), + ).toBeNull(); +}); + +test('home recommendation puzzle next level uses unified recommend switching', async () => { const user = userEvent.setup(); const entryWork = { workId: 'puzzle-work-public-guest-1', @@ -7547,17 +7835,17 @@ test('home recommendation puzzle next level switches to similar work detail', as }, ], } satisfies PuzzleWorkSummary; - const similarWork = { + const nextRecommendWork = { ...entryWork, - workId: 'puzzle-work-similar-guest-1', - profileId: 'puzzle-profile-similar-guest-1', + workId: 'puzzle-work-public-guest-2', + profileId: 'puzzle-profile-public-guest-2', levelName: '风塔试炼', - summary: '另一套奇幻机关拼图。', + summary: '另一套推荐拼图。', levels: [ { - levelId: 'similar-level-1', + levelId: 'next-recommend-level-1', levelName: '风塔试炼', - pictureDescription: '相似作品首关。', + pictureDescription: '推荐队列下一张拼图。', candidates: [], selectedCandidateId: null, coverImageSrc: null, @@ -7586,47 +7874,35 @@ test('home recommendation puzzle next level switches to similar work detail', as entryWork.profileId, entryWork.levelName, ); - const similarRun = { - ...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName), - runId: clearedRun.runId, - entryProfileId: entryWork.profileId, - currentLevelIndex: 2, - currentLevel: { - ...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName) - .currentLevel!, - runId: clearedRun.runId, - levelIndex: 2, - levelId: 'similar-level-1', - startedAtMs: Date.now(), - }, - }; + const nextRecommendRun = buildMockPuzzleRun( + nextRecommendWork.profileId, + nextRecommendWork.levelName, + ); vi.mocked(listPuzzleGallery).mockResolvedValue({ - items: [entryWork], + items: [entryWork, nextRecommendWork], }); vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({ - item: profileId === similarWork.profileId ? similarWork : entryWork, + item: profileId === nextRecommendWork.profileId ? nextRecommendWork : entryWork, })); - vi.mocked(startPuzzleRun).mockResolvedValue({ - run: { - ...startedRun, - currentLevel: { - ...startedRun.currentLevel!, - startedAtMs: Date.now(), + vi.mocked(startPuzzleRun).mockImplementation(async (payload) => { + const run = + payload.profileId === nextRecommendWork.profileId + ? nextRecommendRun + : startedRun; + return { + run: { + ...run, + currentLevel: { + ...run.currentLevel!, + startedAtMs: Date.now(), + }, }, - }, + }; }); vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({ run: clearedRunWithSameWorkNext, }); - let resolveAdvancePuzzleNextLevel!: (value: { - run: PuzzleRunSnapshot; - }) => void; - vi.mocked(advancePuzzleNextLevel).mockReturnValue( - new Promise((resolve) => { - resolveAdvancePuzzleNextLevel = resolve; - }), - ); vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedRun); render(); @@ -7655,24 +7931,23 @@ test('home recommendation puzzle next level switches to similar work detail', as await user.click(within(dialog).getByRole('button', { name: '下一关' })); await waitFor(() => { - expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, { - preferSimilarWork: true, - }); + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: nextRecommendWork.profileId, + levelId: null, + }, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, + ); }); + expect(advancePuzzleNextLevel).not.toHaveBeenCalled(); expect(screen.getByTestId('puzzle-board')).toBeTruthy(); expect(screen.queryByText('加载中...')).toBeNull(); - - resolveAdvancePuzzleNextLevel({ run: similarRun }); - await waitFor(() => { - expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(similarWork.profileId); - }); expect( await screen.findByLabelText('风塔试炼 作品信息', undefined, { timeout: 3000, }), ).toBeTruthy(); - expect(screen.getAllByText('风塔试炼').length).toBeGreaterThan(0); - expect(startPuzzleRun).toHaveBeenCalledTimes(1); + expect(startPuzzleRun).toHaveBeenCalledTimes(2); }); test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => { @@ -8331,10 +8606,84 @@ test('direct jump hop result route restores work detail by profile id', async () expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull(); expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith( 'jump-hop-profile-restore-1', + { audience: 'creation' }, ); expect(jumpHopClient.getSession).not.toHaveBeenCalled(); }); +test('completed unpublished jump hop draft opens result page without starting runtime', async () => { + const user = userEvent.setup(); + const work = buildMockJumpHopWork({ + summary: { + runtimeKind: 'jump-hop', + workId: 'jump-hop-work-draft-ready-1', + profileId: 'jump-hop-profile-draft-ready-1', + ownerUserId: 'user-1', + sourceSessionId: 'jump-hop-session-draft-ready-1', + themeText: '未发布跳一跳草稿', + workTitle: '未发布跳一跳草稿', + workDescription: '已经生成完成,但还没有发布。', + themeTags: ['草稿'], + difficulty: 'standard', + stylePreset: 'paper-toy', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-30T10:00:00.000Z', + publishedAt: null, + publishReady: true, + generationStatus: 'ready', + }, + }); + vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ + items: [work.summary], + }); + vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({ + item: work, + } satisfies JumpHopWorkDetailResponse); + vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({ + ...testCreationEntryConfig, + creationTypes: [ + ...testCreationEntryConfig.creationTypes, + { + id: 'jump-hop', + title: '跳一跳', + subtitle: '主题驱动平台跳跃', + badge: '可创建', + imageSrc: '/creation-type-references/jump-hop.webp', + visible: true, + open: true, + sortOrder: 55, + categoryId: 'recommended', + categoryLabel: '热门推荐', + categorySortOrder: 20, + updatedAtMicros: 1, + }, + ], + }); + + render(); + + await openDraftHub(user); + const draftPanel = getPlatformTabPanel('saves'); + await user.click( + await within(draftPanel).findByRole('button', { + name: /继续创作《未发布跳一跳草稿》/u, + }), + ); + + expect(await screen.findByText('未发布跳一跳草稿')).toBeTruthy(); + expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith( + 'jump-hop-profile-draft-ready-1', + { audience: 'creation' }, + ); + expect(jumpHopClient.startRun).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe('/creation/jump-hop/result'); + expect(window.location.search).toContain( + 'profileId=jump-hop-profile-draft-ready-1', + ); +}); + test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => { const user = userEvent.setup(); diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index e5e82f09..2ce6f3a5 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -23,6 +23,7 @@ import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; +import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes'; import type { SquareHoleHoleOption, SquareHoleShapeOption, @@ -55,8 +56,12 @@ export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4; export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match'; export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物'; +export type PlatformCustomWorldGalleryCard = CustomWorldGalleryCard & { + sourceType?: 'custom-world'; +}; + export type PlatformWorldCardLike = - | CustomWorldGalleryCard + | PlatformCustomWorldGalleryCard | CustomWorldLibraryEntry | PlatformBigFishGalleryCard | PlatformMatch3DGalleryCard @@ -319,7 +324,7 @@ export type PlatformBarkBattleGalleryCard = { }; export type PlatformPublicGalleryCard = - | CustomWorldGalleryCard + | PlatformCustomWorldGalleryCard | PlatformBigFishGalleryCard | PlatformMatch3DGalleryCard | PlatformSquareHoleGalleryCard @@ -343,6 +348,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 { @@ -403,28 +416,62 @@ export function isBarkBattleGalleryEntry( return 'sourceType' in entry && entry.sourceType === 'bark-battle'; } +export function resolvePlatformPublicWorkSourceType( + entry: PlatformPublicGalleryCard, +): PublicWorkSourceType { + if (isCustomWorldGalleryEntry(entry)) { + return 'custom-world'; + } + + if (isBigFishGalleryEntry(entry)) { + return 'big-fish'; + } + + if (isPuzzleGalleryEntry(entry)) { + return 'puzzle'; + } + + if (isPuzzleClearGalleryEntry(entry)) { + return 'puzzle-clear'; + } + + if (isJumpHopGalleryEntry(entry)) { + return 'jump-hop'; + } + + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + + if (isMatch3DGalleryEntry(entry)) { + return 'match3d'; + } + + if (isSquareHoleGalleryEntry(entry)) { + return 'square-hole'; + } + + if (isVisualNovelGalleryEntry(entry)) { + return 'visual-novel'; + } + + if (isBarkBattleGalleryEntry(entry)) { + return 'bark-battle'; + } + + if (isEdutainmentGalleryEntry(entry)) { + return 'edutainment'; + } + + throw new Error('未知公开作品类型。'); +} + export function buildPlatformPublicGalleryCardKey( entry: PlatformPublicGalleryCard, ) { - const kind = isBigFishGalleryEntry(entry) - ? 'big-fish' - : isPuzzleGalleryEntry(entry) - ? 'puzzle' - : isJumpHopGalleryEntry(entry) - ? 'jump-hop' - : isWoodenFishGalleryEntry(entry) - ? 'wooden-fish' - : isMatch3DGalleryEntry(entry) - ? 'match3d' - : isSquareHoleGalleryEntry(entry) - ? 'square-hole' - : isVisualNovelGalleryEntry(entry) - ? 'visual-novel' - : isBarkBattleGalleryEntry(entry) - ? 'bark-battle' - : isEdutainmentGalleryEntry(entry) - ? `edutainment:${entry.templateId}` - : 'rpg'; + const kind = isEdutainmentGalleryEntry(entry) + ? `edutainment:${entry.templateId}` + : resolvePlatformPublicWorkSourceType(entry); return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } @@ -874,7 +921,11 @@ export function resolvePlatformWorldFallbackCoverImage( return '/creation-type-references/bark-battle.webp'; } - return '/creation-type-references/rpg.webp'; + if (isCustomWorldGalleryEntry(entry) || isLibraryWorldEntry(entry)) { + return '/creation-type-references/rpg.webp'; + } + + throw new Error('未知公开作品类型。'); } export function resolvePlatformWorldCoverSlides( diff --git a/src/components/rpg-entry/useRpgEntryBootstrap.ts b/src/components/rpg-entry/useRpgEntryBootstrap.ts index ded70c37..2e149512 100644 --- a/src/components/rpg-entry/useRpgEntryBootstrap.ts +++ b/src/components/rpg-entry/useRpgEntryBootstrap.ts @@ -74,8 +74,7 @@ export function useRpgEntryBootstrap( PlatformBrowseHistoryEntry[] >([]); const [saveEntries, setSaveEntries] = useState([]); - const [platformTab, setPlatformTabState] = - useState('category'); + const [platformTab, setPlatformTabState] = useState('home'); const [platformError, setPlatformError] = useState(null); const [dashboardError, setDashboardError] = useState(null); const [historyError, setHistoryError] = useState(null); @@ -351,8 +350,8 @@ export function useRpgEntryBootstrap( !hasInitialAgentSession && !hasExplicitPlatformTabSelectionRef.current ) { - // 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。 - setPlatformTabState(isAuthenticated ? 'home' : 'category'); + // 中文注释:新用户也先进入推荐页;真正受保护的动作再单独做登录门禁。 + setPlatformTabState('home'); } } finally { if (isActive) { @@ -369,7 +368,6 @@ export function useRpgEntryBootstrap( canReadProtectedData, getProfileDashboard, hasInitialAgentSession, - isAuthenticated, user, ]); diff --git a/src/components/unified-creation/UnifiedCreationPage.test.tsx b/src/components/unified-creation/UnifiedCreationPage.test.tsx index 5f47c6fa..e681ce6f 100644 --- a/src/components/unified-creation/UnifiedCreationPage.test.tsx +++ b/src/components/unified-creation/UnifiedCreationPage.test.tsx @@ -59,13 +59,15 @@ describe('UnifiedCreationPage', () => { .getByText('敲木鱼工作台') .closest('.unified-creation-page__content') ?.className, - ).toContain('min-h-max'); + ).toContain('flex-1'); expect( screen .getByText('敲木鱼工作台') .closest('.unified-creation-page__content') ?.className, - ).not.toContain('min-h-0'); + ).toContain('min-h-0'); expect(root?.className).toContain('overflow-y-auto'); + expect(root?.className).toContain('overscroll-contain'); + expect(root?.className).toContain('bg-[image:var(--platform-body-fill)]'); }); }); diff --git a/src/components/unified-creation/UnifiedCreationPage.tsx b/src/components/unified-creation/UnifiedCreationPage.tsx index 1f027c06..3e6596a7 100644 --- a/src/components/unified-creation/UnifiedCreationPage.tsx +++ b/src/components/unified-creation/UnifiedCreationPage.tsx @@ -18,7 +18,7 @@ export function UnifiedCreationPage({ }: UnifiedCreationPageProps) { return (
field.kind).join(',')} data-workspace-stage={spec.workspaceStage} @@ -65,7 +65,7 @@ export function UnifiedCreationPage({ ))}
-
+
{children}
diff --git a/src/components/unified-creation/unifiedCreationSpecs.test.ts b/src/components/unified-creation/unifiedCreationSpecs.test.ts index 8e6daa98..9754d249 100644 --- a/src/components/unified-creation/unifiedCreationSpecs.test.ts +++ b/src/components/unified-creation/unifiedCreationSpecs.test.ts @@ -16,6 +16,7 @@ describe('unified creation specs', () => { 'jump-hop', 'match3d', 'puzzle', + 'puzzle-clear', 'rpg', 'square-hole', 'visual-novel', @@ -36,41 +37,54 @@ describe('unified creation specs', () => { test('主要链路都映射到统一创作、生成、结果阶段', () => { expect(getUnifiedCreationSpec('rpg')).toMatchObject({ + title: '文字冒险', workspaceStage: 'agent-workspace', generationStage: 'custom-world-generating', resultStage: 'custom-world-result', }); expect(getUnifiedCreationSpec('puzzle')).toMatchObject({ + title: '拼图', workspaceStage: 'puzzle-agent-workspace', 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', generationStage: 'match3d-generating', resultStage: 'match3d-result', }); expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({ + title: '跳一跳', workspaceStage: 'jump-hop-workspace', generationStage: 'jump-hop-generating', resultStage: 'jump-hop-result', }); expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({ + title: '敲木鱼', workspaceStage: 'wooden-fish-workspace', generationStage: 'wooden-fish-generating', resultStage: 'wooden-fish-result', }); expect(getUnifiedCreationSpec('bark-battle')).toMatchObject({ + title: '汪汪声浪', workspaceStage: 'bark-battle-workspace', generationStage: 'bark-battle-generating', resultStage: 'bark-battle-result', }); expect(getUnifiedCreationSpec('visual-novel')).toMatchObject({ + title: '视觉小说', workspaceStage: 'visual-novel-agent-workspace', generationStage: 'visual-novel-generating', resultStage: 'visual-novel-result', }); expect(getUnifiedCreationSpec('baby-object-match')).toMatchObject({ + title: '宝贝识物', workspaceStage: 'baby-object-match-workspace', generationStage: 'baby-object-match-generating', resultStage: 'baby-object-match-result', diff --git a/src/components/unified-creation/unifiedCreationSpecs.ts b/src/components/unified-creation/unifiedCreationSpecs.ts index 3fb7c204..1c5f30e2 100644 --- a/src/components/unified-creation/unifiedCreationSpecs.ts +++ b/src/components/unified-creation/unifiedCreationSpecs.ts @@ -2,11 +2,13 @@ import type { CreationEntryTypeConfig, UnifiedCreationSpec, } from '../../services/creationEntryConfigService'; +import type { PlatformCreationTypeId } from '../../../packages/shared/src/contracts/playTypes'; export const UNIFIED_CREATION_PLAY_IDS = [ 'rpg', 'big-fish', 'puzzle', + 'puzzle-clear', 'match3d', 'jump-hop', 'wooden-fish', @@ -15,7 +17,7 @@ export const UNIFIED_CREATION_PLAY_IDS = [ 'visual-novel', 'baby-object-match', 'creative-agent', -] as const; +] as const satisfies readonly PlatformCreationTypeId[]; export type UnifiedCreationPlayId = (typeof UNIFIED_CREATION_PLAY_IDS)[number]; @@ -27,7 +29,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< > = { rpg: { playId: 'rpg', - title: '想做个什么玩法?', + title: '文字冒险', workspaceStage: 'agent-workspace', generationStage: 'custom-world-generating', resultStage: 'custom-world-result', @@ -42,7 +44,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'big-fish': { playId: 'big-fish', - title: '想做个什么玩法?', + title: '摸鱼', workspaceStage: 'big-fish-agent-workspace', generationStage: 'big-fish-generating', resultStage: 'big-fish-result', @@ -57,7 +59,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, puzzle: { playId: 'puzzle', - title: '想做个什么玩法?', + title: '拼图', workspaceStage: 'puzzle-agent-workspace', generationStage: 'puzzle-generating', resultStage: 'puzzle-result', @@ -82,9 +84,36 @@ 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: '想做个什么玩法?', + title: '抓大鹅', workspaceStage: 'match3d-agent-workspace', generationStage: 'match3d-generating', resultStage: 'match3d-result', @@ -105,7 +134,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'jump-hop': { playId: 'jump-hop', - title: '想做个什么玩法?', + title: '跳一跳', workspaceStage: 'jump-hop-workspace', generationStage: 'jump-hop-generating', resultStage: 'jump-hop-result', @@ -120,7 +149,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'wooden-fish': { playId: 'wooden-fish', - title: '想做个什么玩法?', + title: '敲木鱼', workspaceStage: 'wooden-fish-workspace', generationStage: 'wooden-fish-generating', resultStage: 'wooden-fish-result', @@ -153,7 +182,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'square-hole': { playId: 'square-hole', - title: '想做个什么玩法?', + title: '方洞', workspaceStage: 'square-hole-agent-workspace', generationStage: 'square-hole-generating', resultStage: 'square-hole-result', @@ -168,7 +197,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'bark-battle': { playId: 'bark-battle', - title: '想做个什么玩法?', + title: '汪汪声浪', workspaceStage: 'bark-battle-workspace', generationStage: 'bark-battle-generating', resultStage: 'bark-battle-result', @@ -213,7 +242,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'visual-novel': { playId: 'visual-novel', - title: '想做个什么玩法?', + title: '视觉小说', workspaceStage: 'visual-novel-agent-workspace', generationStage: 'visual-novel-generating', resultStage: 'visual-novel-result', @@ -234,7 +263,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'baby-object-match': { playId: 'baby-object-match', - title: '想做个什么玩法?', + title: '宝贝识物', workspaceStage: 'baby-object-match-workspace', generationStage: 'baby-object-match-generating', resultStage: 'baby-object-match-result', @@ -255,7 +284,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record< }, 'creative-agent': { playId: 'creative-agent', - title: '想做个什么玩法?', + title: '智能体创作', workspaceStage: 'creative-agent-workspace', generationStage: 'puzzle-generating', resultStage: 'puzzle-result', diff --git a/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx b/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx index a36992ad..856dbc5b 100644 --- a/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx +++ b/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx @@ -78,7 +78,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat />, ); - expect(screen.getByText('想做个什么玩法?')).toBeTruthy(); + expect(screen.getByText('抓大鹅')).toBeTruthy(); expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy(); expect(screen.queryByText('2D素材风格')).toBeNull(); expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull(); @@ -130,7 +130,7 @@ test('match3d workspace can defer visible chrome to the unified creation page', expect(workspace?.className).not.toContain('h-full'); expect(workspace?.className).not.toContain('overflow-hidden'); expect(workspace?.className).not.toContain('platform-remap-surface'); - expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull(); + expect(screen.queryByRole('heading', { name: '抓大鹅' })).toBeNull(); const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?'); expect(themeInput).toBeTruthy(); expect(themeInput.className).not.toContain('h-full'); diff --git a/src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx b/src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx index 916dd9eb..d9fb2864 100644 --- a/src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx +++ b/src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx @@ -115,7 +115,7 @@ export function Match3DCreationWorkspace({ onCreateFromForm, initialFormPayload = null, showBackButton = true, - title = '想做个什么玩法?', + title = '抓大鹅', unifiedChrome = false, }: Match3DCreationWorkspaceProps) { const [formState, setFormState] = useState(() => diff --git a/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx b/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx index 0ed7ebc0..350e7258 100644 --- a/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx +++ b/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx @@ -188,7 +188,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => { expect(screen.queryByLabelText('作品名称')).toBeNull(); expect(screen.queryByLabelText('作品描述')).toBeNull(); - expect(screen.getByText('想做个什么玩法?')).toBeTruthy(); + expect(screen.getByText('拼图')).toBeTruthy(); expect(screen.queryByText('try')).toBeNull(); expect(screen.queryByText('Template')).toBeNull(); @@ -238,7 +238,7 @@ test('puzzle workspace can defer visible chrome to the unified creation page', ( expect(workspace?.className).not.toContain('platform-remap-surface'); expect(imagePanel?.className).toContain('flex-none'); expect(imagePanel?.className).not.toContain('flex-1'); - expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull(); + expect(screen.queryByRole('heading', { name: '拼图' })).toBeNull(); expect(screen.getByLabelText('画面描述')).toBeTruthy(); }); diff --git a/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx b/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx index 74c184a6..024ad826 100644 --- a/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx +++ b/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx @@ -246,7 +246,7 @@ export function PuzzleCreationWorkspace({ onAutoSaveForm, initialFormPayload = null, showBackButton = true, - title = '想做个什么玩法?', + title = '拼图', unifiedChrome = false, }: PuzzleCreationWorkspaceProps) { const [formState, setFormState] = useState(() => diff --git a/src/index.css b/src/index.css index 9017c5b0..e401aff2 100644 --- a/src/index.css +++ b/src/index.css @@ -79,17 +79,19 @@ body { -webkit-font-smoothing: antialiased; } +html[data-mobile-keyboard-open='true'], +html[data-mobile-keyboard-open='true'] body, +html[data-mobile-keyboard-open='true'] #root { + background: var( + --platform-keyboard-exposed-fill, + linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%) + ); +} + .platform-viewport-shell { height: var(--platform-layout-viewport-height, 100vh); max-height: var(--platform-layout-viewport-height, 100vh); min-height: var(--platform-layout-viewport-height, 100vh); - transform: translate3d( - 0, - calc(-1 * var(--platform-keyboard-focus-offset, 0px)), - 0 - ); - transform-origin: top center; - transition: transform 180ms ease; } @supports (height: 100dvh) { @@ -220,6 +222,16 @@ body { } } +@keyframes platform-recommend-runtime-loading { + 0% { + transform: translateX(-110%); + } + + 100% { + transform: translateX(240%); + } +} + @keyframes puzzle-clear-card-clear-pop { 0% { opacity: 1; @@ -5281,6 +5293,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .platform-recommend-runtime-viewport { position: absolute; inset: 0; + z-index: 1; + isolation: isolate; min-width: 0; overflow: hidden; background: var(--platform-recommend-runtime-fill); @@ -5298,18 +5312,48 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .platform-recommend-runtime-cover { position: absolute; inset: 0; - z-index: 3; + z-index: 30; + isolation: isolate; overflow: hidden; background: var(--platform-recommend-runtime-fill); opacity: 1; pointer-events: auto; - transition: opacity 420ms ease; will-change: opacity; } + .platform-recommend-runtime-loading { + position: absolute; + right: 1rem; + bottom: 0.72rem; + left: 1rem; + z-index: 4; + height: 0.18rem; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.24); + box-shadow: 0 0 18px rgba(255, 255, 255, 0.24); + } + + .platform-recommend-runtime-loading::before { + position: absolute; + inset: 0; + width: 42%; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.38), + rgba(255, 255, 255, 0.92), + rgba(255, 180, 97, 0.86) + ); + content: ''; + transform: translateX(-110%); + animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite; + } + .platform-recommend-runtime-cover--hidden { opacity: 0; pointer-events: none; + transition: opacity 420ms ease; } .platform-recommend-swipe-stage { @@ -5335,6 +5379,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { transition: none; } + .platform-recommend-swipe-rail--resetting { + transition: none; + } + .platform-recommend-swipe-page { position: absolute; inset: 0; diff --git a/src/index.test.ts b/src/index.test.ts index d01d35b4..e440fb84 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -59,6 +59,30 @@ describe('index stylesheet unread dots', () => { expect(css).toContain('::-webkit-scrollbar-thumb'); }); + it('uses the platform fill for root background exposed by mobile keyboard shift', () => { + const css = readIndexCss(); + + const keyboardRootBlock = getCssBlock( + css, + "html[data-mobile-keyboard-open='true'],\nhtml[data-mobile-keyboard-open='true'] body,\nhtml[data-mobile-keyboard-open='true'] #root", + ); + expect(keyboardRootBlock).toContain('--platform-keyboard-exposed-fill'); + expect(keyboardRootBlock).toContain('#fffdf9'); + expect(keyboardRootBlock).not.toContain('#0a0a0a'); + }); + + it('does not globally transform the platform shell while the mobile keyboard is open', () => { + const css = readIndexCss(); + + const platformShellBlock = getCssBlock( + css, + '.platform-viewport-shell {\n height', + ); + expect(platformShellBlock).toContain('--platform-layout-viewport-height'); + expect(platformShellBlock).not.toContain('translate3d'); + expect(platformShellBlock).not.toContain('--platform-keyboard-focus-offset'); + }); + it('uses warm brown tokens for draft unread markers instead of red literals', () => { const css = readIndexCss(); @@ -129,3 +153,43 @@ describe('index stylesheet creation agent hero contrast', () => { expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important'); }); }); + +describe('index stylesheet recommend runtime cover', () => { + it('keeps the card cover above embedded runtime and only fades it when ready', () => { + const css = readIndexCss(); + + const viewportBlock = getCssBlock( + css, + '.platform-recommend-runtime-viewport', + ); + expect(viewportBlock).toContain('z-index: 1;'); + expect(viewportBlock).toContain('isolation: isolate;'); + + const coverBlock = getCssBlock(css, '.platform-recommend-runtime-cover'); + expect(coverBlock).toContain('z-index: 30;'); + expect(coverBlock).toContain('isolation: isolate;'); + expect(coverBlock).not.toContain('transition: opacity'); + + const loadingBlock = getCssBlock( + css, + '.platform-recommend-runtime-loading', + ); + expect(loadingBlock).toContain('position: absolute;'); + expect(loadingBlock).toContain('z-index: 4;'); + + const loadingAnimationBlock = getCssBlock( + css, + '.platform-recommend-runtime-loading::before', + ); + expect(loadingAnimationBlock).toContain( + 'animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite;', + ); + expect(css).toContain('@keyframes platform-recommend-runtime-loading'); + + const hiddenCoverBlock = getCssBlock( + css, + '.platform-recommend-runtime-cover--hidden', + ); + expect(hiddenCoverBlock).toContain('transition: opacity 420ms ease;'); + }); +}); diff --git a/src/mobileViewportKeyboardFocus.test.ts b/src/mobileViewportKeyboardFocus.test.ts index 31b3339a..eb724701 100644 --- a/src/mobileViewportKeyboardFocus.test.ts +++ b/src/mobileViewportKeyboardFocus.test.ts @@ -1,12 +1,59 @@ /* @vitest-environment jsdom */ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - calculateMobileKeyboardFocusShift, isEditableKeyboardTarget, + resolveMobileKeyboardExposedFill, + resolveMobileKeyboardState, + stabilizeMobileViewportKeyboardFocus, } from './mobileViewportKeyboardFocus'; +const originalMatchMedia = window.matchMedia; +const originalRequestAnimationFrame = window.requestAnimationFrame; +const originalCancelAnimationFrame = window.cancelAnimationFrame; +const originalInnerHeight = window.innerHeight; +const originalMaxTouchPoints = navigator.maxTouchPoints; +const originalVisualViewport = window.visualViewport; + +function defineWindowValue(key: Key, value: Window[Key]) { + Object.defineProperty(window, key, { + configurable: true, + value, + }); +} + +function defineNavigatorValue( + key: Key, + value: Navigator[Key], +) { + Object.defineProperty(navigator, key, { + configurable: true, + value, + }); +} + +beforeEach(() => { + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-mobile-keyboard-open'); + document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus'); + document.documentElement.removeAttribute('style'); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-mobile-keyboard-open'); + document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus'); + document.documentElement.removeAttribute('style'); + defineWindowValue('matchMedia', originalMatchMedia); + defineWindowValue('requestAnimationFrame', originalRequestAnimationFrame); + defineWindowValue('cancelAnimationFrame', originalCancelAnimationFrame); + defineWindowValue('innerHeight', originalInnerHeight); + defineNavigatorValue('maxTouchPoints', originalMaxTouchPoints); + defineWindowValue('visualViewport', originalVisualViewport); +}); + describe('isEditableKeyboardTarget', () => { it('matches controls that open the mobile keyboard', () => { const input = document.createElement('input'); @@ -31,47 +78,125 @@ describe('isEditableKeyboardTarget', () => { }); }); -describe('calculateMobileKeyboardFocusShift', () => { - it('moves a bottom input above the visible keyboard area', () => { - expect( - calculateMobileKeyboardFocusShift({ - layoutHeight: 800, - visualTop: 0, - visualHeight: 500, - targetTop: 720, - targetBottom: 770, - currentShift: 0, - margin: 20, - }), - ).toBe(290); +describe('resolveMobileKeyboardExposedFill', () => { + it('uses the active platform shell fill for exposed mini-program keyboard space', () => { + document.body.innerHTML = ` +
+ `; + + expect(resolveMobileKeyboardExposedFill()).toContain('rgb(255, 253, 249)'); }); - it('does not move when the focused input is already visible', () => { - expect( - calculateMobileKeyboardFocusShift({ - layoutHeight: 800, - visualTop: 0, - visualHeight: 500, - targetTop: 250, - targetBottom: 300, - currentShift: 0, - margin: 20, - }), - ).toBe(0); - }); + it('falls back to the light platform fill before the shell mounts', () => { + document.body.innerHTML = ''; - it('caps movement to keyboard inset plus safety margin', () => { - expect( - calculateMobileKeyboardFocusShift({ - layoutHeight: 800, - visualTop: 0, - visualHeight: 500, - targetTop: 790, - targetBottom: 860, - currentShift: 0, - margin: 20, - maxExtraShift: 20, - }), - ).toBe(320); + expect(resolveMobileKeyboardExposedFill()).toContain('#fffdf9'); + }); +}); + +describe('resolveMobileKeyboardState', () => { + it('detects the keyboard inset without asking the app shell to shift', () => { + expect( + resolveMobileKeyboardState({ + layoutHeight: 800, + visualTop: 0, + visualHeight: 500, + hasEditableTarget: true, + }), + ).toEqual({ + isOpen: true, + insetBottom: 300, + }); + }); + + it('stays closed when no editable target is focused', () => { + expect( + resolveMobileKeyboardState({ + layoutHeight: 800, + visualTop: 0, + visualHeight: 500, + hasEditableTarget: false, + }), + ).toEqual({ + isOpen: false, + insetBottom: 0, + }); + }); + + it('accounts for browser visual viewport panning without returning a shell offset', () => { + expect( + resolveMobileKeyboardState({ + layoutHeight: 800, + visualTop: 120, + visualHeight: 500, + hasEditableTarget: true, + }), + ).toEqual({ + isOpen: true, + insetBottom: 180, + }); + }); +}); + +describe('stabilizeMobileViewportKeyboardFocus', () => { + it('marks H5 keyboard state without applying a global shell transform offset', () => { + vi.useFakeTimers(); + document.body.innerHTML = ` +
+ +
+ `; + defineNavigatorValue('maxTouchPoints', 1); + defineWindowValue( + 'matchMedia', + vi.fn().mockReturnValue({ matches: true }) as unknown as Window['matchMedia'], + ); + defineWindowValue( + 'requestAnimationFrame', + ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as Window['requestAnimationFrame'], + ); + defineWindowValue( + 'cancelAnimationFrame', + vi.fn() as unknown as Window['cancelAnimationFrame'], + ); + defineWindowValue('innerHeight', 800); + defineWindowValue('visualViewport', { + height: 500, + offsetTop: 120, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as VisualViewport); + + stabilizeMobileViewportKeyboardFocus(); + + document.getElementById('theme-input')?.focus(); + document.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + vi.runAllTimers(); + + expect(document.documentElement.dataset.mobileKeyboardOpen).toBe('true'); + expect( + document.documentElement.style.getPropertyValue( + '--platform-keyboard-focus-offset', + ), + ).toBe('0px'); + expect( + document.documentElement.style.getPropertyValue( + '--platform-keyboard-inset-bottom', + ), + ).toBe('180px'); + expect( + document.documentElement.style.getPropertyValue( + '--platform-keyboard-exposed-fill', + ), + ).toContain('rgb(255, 253, 249)'); }); }); diff --git a/src/mobileViewportKeyboardFocus.ts b/src/mobileViewportKeyboardFocus.ts index c35469a4..68c0b7a7 100644 --- a/src/mobileViewportKeyboardFocus.ts +++ b/src/mobileViewportKeyboardFocus.ts @@ -1,31 +1,43 @@ const MOBILE_POINTER_QUERY = '(pointer: coarse)'; const KEYBOARD_OPEN_THRESHOLD_PX = 96; -const FOCUS_MARGIN_PX = 18; const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320; const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height'; const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset'; const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom'; +const KEYBOARD_EXPOSED_FILL_VAR = '--platform-keyboard-exposed-fill'; +const KEYBOARD_EXPOSED_FILL_FALLBACK = + 'linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)'; type KeyboardFocusShiftInput = { layoutHeight: number; visualTop: number; visualHeight: number; - targetTop: number; - targetBottom: number; - currentShift: number; - margin?: number; - maxExtraShift?: number; + hasEditableTarget: boolean; + threshold?: number; }; -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function readVisualViewport() { return typeof window !== 'undefined' ? window.visualViewport : undefined; } +export function resolveMobileKeyboardExposedFill() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return KEYBOARD_EXPOSED_FILL_FALLBACK; + } + + const platformShell = document.querySelector('.platform-viewport-shell'); + if (!(platformShell instanceof HTMLElement)) { + return KEYBOARD_EXPOSED_FILL_FALLBACK; + } + + const platformFill = window + .getComputedStyle(platformShell) + .getPropertyValue('--platform-body-fill') + .trim(); + return platformFill || KEYBOARD_EXPOSED_FILL_FALLBACK; +} + function readLayoutViewportHeight() { if (typeof window === 'undefined' || typeof document === 'undefined') { return MIN_LAYOUT_VIEWPORT_HEIGHT_PX; @@ -110,34 +122,22 @@ export function isEditableKeyboardTarget( ]).has(inputType); } -export function calculateMobileKeyboardFocusShift({ +export function resolveMobileKeyboardState({ layoutHeight, visualTop, visualHeight, - targetTop, - targetBottom, - currentShift, - margin = FOCUS_MARGIN_PX, - maxExtraShift = FOCUS_MARGIN_PX, + hasEditableTarget, + threshold = KEYBOARD_OPEN_THRESHOLD_PX, }: KeyboardFocusShiftInput) { const visualBottom = visualTop + visualHeight; - const safeTop = visualTop + margin; - const safeBottom = visualBottom - margin; - const unshiftedTargetTop = targetTop + currentShift; - const unshiftedTargetBottom = targetBottom + currentShift; - let nextShift = currentShift; + const insetBottom = Math.max(0, Math.round(layoutHeight - visualBottom)); + const isOpen = + hasEditableTarget && layoutHeight - visualHeight > threshold; - if (unshiftedTargetBottom - nextShift > safeBottom) { - nextShift = unshiftedTargetBottom - safeBottom; - } - - if (unshiftedTargetTop - nextShift < safeTop) { - nextShift = Math.max(0, unshiftedTargetTop - safeTop); - } - - const keyboardInset = Math.max(0, layoutHeight - visualBottom); - const maxShift = keyboardInset + maxExtraShift; - return Math.round(clamp(nextShift, 0, Math.max(0, maxShift))); + return { + isOpen, + insetBottom: isOpen ? insetBottom : 0, + }; } export function stabilizeMobileViewportKeyboardFocus() { @@ -154,7 +154,6 @@ export function stabilizeMobileViewportKeyboardFocus() { const root = document.documentElement; const visualViewport = readVisualViewport(); let stableLayoutHeight = readLayoutViewportHeight(); - let currentShift = 0; let frameId = 0; const setLayoutHeight = (nextHeight: number) => { @@ -167,6 +166,10 @@ export function stabilizeMobileViewportKeyboardFocus() { const setKeyboardState = (isOpen: boolean, insetBottom = 0) => { if (isOpen) { + root.style.setProperty( + KEYBOARD_EXPOSED_FILL_VAR, + resolveMobileKeyboardExposedFill(), + ); root.dataset.mobileKeyboardOpen = 'true'; } else { delete root.dataset.mobileKeyboardOpen; @@ -178,9 +181,8 @@ export function stabilizeMobileViewportKeyboardFocus() { ); }; - const setFocusShift = (nextShift: number) => { - currentShift = Math.max(0, Math.round(nextShift)); - root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, `${currentShift}px`); + const resetFocusShift = () => { + root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, '0px'); }; const readActiveTarget = () => @@ -193,15 +195,16 @@ export function stabilizeMobileViewportKeyboardFocus() { const viewport = readVisualViewport(); const visualTop = viewport?.offsetTop ?? 0; const visualHeight = viewport?.height ?? window.innerHeight; - const visualBottom = visualTop + visualHeight; - const keyboardInset = Math.max(0, stableLayoutHeight - visualBottom); - const keyboardOpen = - Boolean(activeTarget) && - stableLayoutHeight - visualHeight > KEYBOARD_OPEN_THRESHOLD_PX; + const keyboardState = resolveMobileKeyboardState({ + layoutHeight: stableLayoutHeight, + visualTop, + visualHeight, + hasEditableTarget: Boolean(activeTarget), + }); - if (!keyboardOpen || !activeTarget) { + if (!keyboardState.isOpen || !activeTarget) { setKeyboardState(false); - setFocusShift(0); + resetFocusShift(); if (!activeTarget) { setLayoutHeight(readLayoutViewportHeight()); @@ -209,19 +212,10 @@ export function stabilizeMobileViewportKeyboardFocus() { return; } - // 中文注释:先保持整页布局高度,再只移动画布,让输入框避开键盘。 - const targetRect = activeTarget.getBoundingClientRect(); - const nextShift = calculateMobileKeyboardFocusShift({ - layoutHeight: stableLayoutHeight, - visualTop, - visualHeight, - targetTop: targetRect.top, - targetBottom: targetRect.bottom, - currentShift, - }); - - setKeyboardState(true, keyboardInset); - setFocusShift(nextShift); + // 中文注释:H5 浏览器和小程序 web-view 已会自行处理输入框可见性。 + // 这里只记录键盘状态、隐藏底部 dock,并给可能露出的宿主区域补浅色背景。 + setKeyboardState(true, keyboardState.insetBottom); + resetFocusShift(); }; const scheduleSync = () => { @@ -243,14 +237,14 @@ export function stabilizeMobileViewportKeyboardFocus() { setLayoutHeight(stableLayoutHeight); setKeyboardState(false); - setFocusShift(0); + resetFocusShift(); document.addEventListener('focusin', scheduleKeyboardAnimationSync, true); document.addEventListener('focusout', scheduleKeyboardAnimationSync, true); window.addEventListener('resize', scheduleKeyboardAnimationSync); window.addEventListener('orientationchange', () => { setKeyboardState(false); - setFocusShift(0); + resetFocusShift(); window.setTimeout(() => { setLayoutHeight(readLayoutViewportHeight()); scheduleSync(); diff --git a/src/services/creationEntryConfigService.ts b/src/services/creationEntryConfigService.ts index 7ca6adef..b3bfab43 100644 --- a/src/services/creationEntryConfigService.ts +++ b/src/services/creationEntryConfigService.ts @@ -1,8 +1,9 @@ +import type { PlatformCreationTypeId } from '../../packages/shared/src/contracts/playTypes'; import { requestJson } from './apiClient'; /** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */ export type CreationEntryTypeConfig = { - id: string; + id: PlatformCreationTypeId; title: string; subtitle: string; badge: string; @@ -27,7 +28,7 @@ export type UnifiedCreationField = { /** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */ export type UnifiedCreationSpec = { - playId: string; + playId: PlatformCreationTypeId; title: string; workspaceStage: string; generationStage: string; diff --git a/src/services/jump-hop/jumpHopClient.ts b/src/services/jump-hop/jumpHopClient.ts index 21184562..dfb1f245 100644 --- a/src/services/jump-hop/jumpHopClient.ts +++ b/src/services/jump-hop/jumpHopClient.ts @@ -188,9 +188,16 @@ export function executeJumpHopCreationAction( .then(normalizeJumpHopActionResponse); } -export async function getJumpHopWorkDetail(profileId: string) { +export async function getJumpHopWorkDetail( + profileId: string, + options: { audience?: 'creation' | 'runtime' } = {}, +) { + const base = + options.audience === 'creation' + ? JUMP_HOP_WORKS_API_BASE + : `${JUMP_HOP_RUNTIME_API_BASE}/works`; const response = await requestJson( - `${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`, + `${base}/${encodeURIComponent(profileId)}`, { method: 'GET' }, '读取跳一跳作品详情失败', ); diff --git a/src/services/jump-hop/useJumpHopLeaderboard.test.tsx b/src/services/jump-hop/useJumpHopLeaderboard.test.tsx index b21079bd..c7e41f0f 100644 --- a/src/services/jump-hop/useJumpHopLeaderboard.test.tsx +++ b/src/services/jump-hop/useJumpHopLeaderboard.test.tsx @@ -30,6 +30,7 @@ const leaderboardResponse: JumpHopLeaderboardResponse = { { rank: 1, playerId: 'player-1', + displayName: '玩家一号', successfulJumpCount: 10, durationMs: 3210, updatedAt: '2026-05-27T00:00:00Z', diff --git a/src/services/miniGameDraftGenerationProgress.test.ts b/src/services/miniGameDraftGenerationProgress.test.ts index a2c43ac2..1f7e4649 100644 --- a/src/services/miniGameDraftGenerationProgress.test.ts +++ b/src/services/miniGameDraftGenerationProgress.test.ts @@ -530,6 +530,44 @@ describe('miniGameDraftGenerationProgress', () => { ]); }); + test('jump hop generation anchors hide unused style preset fallback', () => { + const entries = buildJumpHopGenerationAnchorEntries({ + sessionId: 'jump-hop-session-style-hidden', + ownerUserId: 'user-1', + status: 'generating', + draft: { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: 'jump-hop-profile-style-hidden', + themeText: '水果', + workTitle: '水果跳一跳', + workDescription: '水果主题跳一跳。', + themeTags: ['水果'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + characterPrompt: '内置默认 3D 角色', + tilePrompt: '', + endMoodPrompt: null, + characterAsset: null, + tileAtlasAsset: null, + tileAssets: [], + path: null, + coverComposite: null, + generationStatus: 'generating', + }, + createdAt: '2026-06-06T10:00:00.000Z', + updatedAt: '2026-06-06T10:00:00.000Z', + }); + + expect(entries).toEqual([ + { + id: 'jump-hop-theme', + label: '主题', + value: '水果', + }, + ]); + }); + test('wooden fish draft generation exposes hit object, background and back button pipeline', () => { const state = createMiniGameDraftGenerationState('wooden-fish'); diff --git a/src/services/miniGameDraftGenerationProgress.ts b/src/services/miniGameDraftGenerationProgress.ts index b3216071..a53d87e6 100644 --- a/src/services/miniGameDraftGenerationProgress.ts +++ b/src/services/miniGameDraftGenerationProgress.ts @@ -1163,7 +1163,7 @@ export function buildJumpHopGenerationAnchorEntries( workTitle?: string; themeText?: string; characterPrompt?: string; - stylePreset?: string; + tilePrompt?: string; } | null; } | null @@ -1187,7 +1187,7 @@ export function buildJumpHopGenerationAnchorEntries( value: formPayload?.tilePrompt?.trim() || config?.tilePrompt?.trim() || - draft?.stylePreset?.trim() || + draft?.tilePrompt?.trim() || '', }, ]; diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index 501d6a9e..1e314fe4 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -93,7 +93,7 @@ export async function dragPuzzlePieceOrGroup( } /** - * 进入推荐出的下一关。 + * 进入当前 run 的下一关。 */ export async function advancePuzzleNextLevel( runId: string, @@ -101,10 +101,8 @@ export async function advancePuzzleNextLevel( options: PuzzleRuntimeRequestOptions = {}, ) { const targetProfileId = payload.targetProfileId?.trim() ?? ''; - const preferSimilarWork = payload.preferSimilarWork === true; const requestPayload = { ...(targetProfileId ? { targetProfileId } : {}), - ...(preferSimilarWork ? { preferSimilarWork: true } : {}), }; const hasRequestPayload = Object.keys(requestPayload).length > 0; return requestRuntimeJson({ diff --git a/src/services/recommendedRuntimeGuestLaunch.test.ts b/src/services/recommendedRuntimeGuestLaunch.test.ts index 514f00c2..e7cd8345 100644 --- a/src/services/recommendedRuntimeGuestLaunch.test.ts +++ b/src/services/recommendedRuntimeGuestLaunch.test.ts @@ -130,10 +130,10 @@ describe('recommended runtime guest launch clients', () => { }, ); - it('puzzle next level can carry preferSimilarWork through the runtime guest request', async () => { + it('puzzle next level keeps the default current-run handoff without a request body', async () => { await advancePuzzleNextLevel( 'run-puzzle-1', - { preferSimilarWork: true }, + {}, { runtimeGuestToken: 'runtime-guest-token' }, ); @@ -144,11 +144,10 @@ describe('recommended runtime guest launch clients', () => { method: 'POST', headers: expect.objectContaining({ Authorization: 'Bearer runtime-guest-token', - 'Content-Type': 'application/json', }), - body: JSON.stringify({ preferSimilarWork: true }), }), ); + expect(init.body).toBeUndefined(); expect(options).toEqual( expect.objectContaining({ skipAuth: true,