diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md index 49bc6cbd..46687974 100644 --- a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -28,7 +28,7 @@ 12. `WEB_PORT` 必须在 `构建并部署` 与 `部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local` 的 `GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退。 13. `DATABASE` 必须匹配 SpacetimeDB CLI 数据库名规则 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会被拒绝,否则 `spacetime publish` 会报 `invalid characters in database name`。 14. Jenkins 日志必须能看到构建参数中的 SpacetimeDB 发布数据库,以及 `start.sh` 最终加载环境文件后的运行时数据库、server 和 root-dir,避免 `.env.local` 覆盖默认值后无法判断实际发布目标。 -15. `构建并部署` 流水线开头固定把 Jenkins 用户下的 Node、Cargo、SpacetimeDB 常用安装目录加入 `PATH` 前缀:`/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin`。 +15. `构建并部署` 流水线开头通过 `GENARRATIVE_TOOLS_PATH` 固定声明 Jenkins 用户下的 Node、Cargo、SpacetimeDB 常用安装目录:`/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin`,并显式保留 `/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,避免覆盖系统路径导致 `sh` 步骤无法启动。 ## 3. 节点与工作区要求 @@ -155,7 +155,7 @@ jenkins/Jenkinsfile.build-and-deploy 10. `MIGRATION_IMPORT_TOKEN`:可选,新库已授权迁移操作员 token,只在清库发布新 wasm 后导入回灌时使用。 如果当前 Jenkins 没有额外配置独立 Agent,而是直接在控制器自身执行任务,`AGENT_LABEL` 应填写 `built-in`。 -如果 `node`、`cargo` 或 `spacetime` 安装在 Jenkins 用户目录下,`构建并部署` 已默认把 `/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin`、`/var/lib/jenkins/.cargo/bin`、`/var/lib/jenkins/.local/bin`、`/var/lib/jenkins/bin` 追加到流水线 `PATH` 前缀;仍应确保这些目录和其中二进制文件对 Jenkins 运行用户可读可执行。 +如果 `node`、`cargo` 或 `spacetime` 安装在 Jenkins 用户目录下,`构建并部署` 已默认把 `/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin`、`/var/lib/jenkins/.cargo/bin`、`/var/lib/jenkins/.local/bin`、`/var/lib/jenkins/bin` 写入流水线 `PATH` 前缀;仍应确保这些目录和其中二进制文件对 Jenkins 运行用户可读可执行。 如果 Jenkins 进程以默认 `jenkins` 用户运行,部署目录建议直接放在 `/var/lib/jenkins/deploy/Genarrative` 这类 Jenkins 自有目录下,避免再依赖 `/home/ubuntu/*` 的额外写权限。 如果目标 Ubuntu 的 Jenkins `sh` 默认实际落到 `/bin/sh -> dash`,而流水线脚本又使用了 `set -euo pipefail`,则必须显式通过 `bash -lc` 执行命令,不能直接依赖 Jenkins 默认 `sh` 解释器。 diff --git a/docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md b/docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md new file mode 100644 index 00000000..40a8b19f --- /dev/null +++ b/docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md @@ -0,0 +1,146 @@ +# 抓大鹅 Match3D Q1 集成验收与收口记录 2026-05-01 + +## 1. 本轮目标 + +Q1 不新增玩法规则,只把第一至第三波已经形成的 Match3D 独立玩法域接成可跑主链: + +1. 创作 Agent 前端从本地 mock 切到 `api-server` HTTP/SSE facade。 +2. 结果页从临时草稿承接页升级为可编辑、可保存、可试玩、可发布的作品工作台。 +3. 试玩运行态从结果页启动真实 `/api/runtime/match3d/*` run,并继续保持“前端即时反馈 + 后端权威确认”。 +4. 创作中心至少能读取当前用户 Match3D 作品列表,并支持打开草稿继续编辑。 + +本轮结论:已按合并顺序完成 Q1 主链集成。第一至第三波的主体能力均已落到工程,Q1 已把它们串成“创作 Agent -> 结果页保存/发布/试玩 -> 公开详情/作品号搜索 -> 运行态”的最小可跑链路。 + +## 2. 第一至第三波验收口径 + +### 第一波 A0 + +文档已存在: + +```text +docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md +``` + +结论:已完成。该文档冻结了独立玩法域、表与 procedure、HTTP facade、前端即时反馈协议和合并顺序。 + +### 第二波 B1 + B2 + +已落点: + +```text +server-rs/crates/module-match3d/ +server-rs/crates/shared-contracts/src/match3d_*.rs +packages/shared/src/contracts/match3d*.ts +``` + +结论:已完成。领域 crate、Rust DTO、TypeScript DTO 已存在,并已通过 Q1 定向复跑。 + +### 第二波 B3 + +已落点: + +```text +server-rs/crates/spacetime-module/src/match3d/ +server-rs/crates/spacetime-module/src/migration.rs +``` + +结论:已完成。四张 Match3D 表已纳入 migration,procedure 已接 `module-match3d` 领域规则。本轮不改表结构,不需要新增 migration。 + +### 第二波 F1 + +已落点: + +```text +src/components/match3d-creation/ +src/services/match3d-creation/ +src/components/platform-entry/ +``` + +结论:已完成并已接入 Q1。入口与 Agent UI 已存在,`match3dCreationClient` 已从本地 mock 切到 `api-server` HTTP/SSE facade;本地 mock 只保留在测试夹具和 `/match3d` playground 运行调试链路中。 + +### 第二波 F3 + +已落点: + +```text +src/components/match3d-runtime/ +src/services/match3d-runtime/match3dLocalRuntime.ts +src/Match3DPlaygroundApp.tsx +``` + +结论:已完成并已接入 Q1。圆形空间、7 格备选栏、乐观点击、三消反馈、结算面板和回滚校正语义已存在;Q1 已补真实 runtime client 与平台入口接线。 + +### 第三波 B4 + B5 + +已落点: + +```text +server-rs/crates/spacetime-client/src/match3d.rs +server-rs/crates/api-server/src/match3d.rs +server-rs/crates/api-server/src/app.rs +``` + +结论:已完成。HTTP facade 路由已注册,Q1 前端已按这些稳定路由接入。 + +### 第三波 F2 + +目标落点: + +```text +src/components/match3d-result/ +src/services/match3d-works/ +``` + +结论:已完成并已接入 Q1。新增 `Match3DResultView` 和 `match3d-works` service,支持基础信息编辑、保存、发布、试玩入口;发布仍要求封面和标签门槛,试玩只要求基础配置可保存。 + +### 第三波 F4 + +结论:已完成 Q1 最小平台分发。创作中心作品货架、公开卡片映射、统一作品详情、`M3-xxxxxxxx` 作品号搜索和详情页启动运行态已接入;排行榜、点赞、改造统计和更复杂推荐策略仍留到后续优化。 + +## 3. Q1 本轮代码落点 + +本轮实际落点: + +1. `src/services/match3d-creation/`:替换本地 mock 为 HTTP/SSE facade。 +2. `src/services/match3d-works/`:新增作品读取、保存、发布 service。 +3. `src/services/match3d-runtime/`:新增真实运行态 service,保留本地 playground mock。 +4. `src/components/match3d-result/`:新增结果页组件。 +5. `src/components/platform-entry/`:串起结果页、试玩 run、作品列表刷新。 +6. `src/components/custom-world-home/` 与展示映射:扩展 Match3D 作品货架、公开卡片、统一详情页。 +7. `src/services/publicWorkCode.ts` 与 `src/routing/appPageRoutes.ts`:新增 `M3-xxxxxxxx` 作品号与公开详情路由识别。 +8. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`:补齐 Match3D 作品号搜索启动运行态回归,并同步统一详情页后的 RPG/Big Fish 旧测试语义。 + +## 4. 验收命令 + +本轮已通过: + +```powershell +npm test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/routing/appPageRoutes.test.ts src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx --reporter=verbose --silent +``` + +结果:`6 passed`,`65 passed`。 + +```powershell +cargo test -p module-match3d +cargo test -p shared-contracts +cargo check -p api-server +cargo check -p spacetime-client +npm run check:encoding +``` + +结果: + +1. `module-match3d`:`7 passed`。 +2. `shared-contracts`:`47 passed`。 +3. `api-server`:`cargo check` 通过。 +4. `spacetime-client`:`cargo check` 通过。 +5. 编码检查:`2804 file(s)` 通过。 + +## 5. 本轮不做与遗留风险 + +1. 不改 Match3D 表结构。 +2. 不扩展排行榜、点赞、二次创作统计。 +3. 不把 Match3D 公开广场并入更复杂的推荐、排行和运营榜单策略。 +4. 不删除 `/match3d` 本地 playground;它作为开发调试入口继续保留。 +5. 全量 `npm run typecheck` 曾存在非 Match3D 既有阻塞,本轮以 Q1 定向测试和后端定向检查作为集成验收口径。 +6. Maincloud 运行态仍依赖当前 SpacetimeDB 环境稳定性;如 `npm run api-server:maincloud` 现场遇到订阅 HTTP 500,应按 Maincloud/SpacetimeDB 联调链路单独排查。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 779c9316..2316bed5 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -14,6 +14,7 @@ - [MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md](./MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md):冻结抓大鹅 F2 结果页、基础信息编辑、发布前试玩入口、发布门槛、自动保存和已发布作品二次编辑恢复口径。 - [MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md](./MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md):记录抓大鹅 B4+B5 已落地的 SpacetimeDB bindings、`spacetime-client` facade、`api-server` HTTP 路由、shared contract 对齐和验收命令。 - [MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md](./MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md):记录抓大鹅创作页入口临时改为“敬请期待”、不可点击,以及保留既有 Match3D 能力不删除的边界。 +- [MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md](./MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md):记录抓大鹅 Match3D 第一至第三波完成度复核、Q1 主链集成落点、定向验收命令和遗留风险。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 - [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。 diff --git a/jenkins/Jenkinsfile.build b/jenkins/Jenkinsfile.build deleted file mode 100644 index db165ede..00000000 --- a/jenkins/Jenkinsfile.build +++ /dev/null @@ -1,69 +0,0 @@ -pipeline { - agent none - - options { - disableConcurrentBuilds() - timestamps() - } - - parameters { - string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签') - string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') - string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') - } - - stages { - stage('构建发布包') { - agent { - label "${params.AGENT_LABEL}" - } - - steps { - script { - // 统一在脚本块里计算版本号,避免 declarative environment 对表达式求值不一致。 - env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER - // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 - env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() - } - - dir("${env.WORKSPACE_ROOT}") { - checkout scm - - sh ''' - bash -lc ' - set -euo pipefail - # 构建前清理工作区内的 Git 变更和未跟踪文件,避免复用固定源码目录时受到上次构建残留影响。 - # 这里不使用 -x,避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。 - git reset --hard HEAD - git clean -fd - ' - ''' - - script { - // 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。 - if (params.RUN_NPM_CI) { - sh 'bash -lc "npm ci"' - } - } - - sh """ - bash -lc ' - set -euo pipefail - npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" - test -d "build/${env.EFFECTIVE_BUILD_VERSION}" - ' - """ - - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/**", fingerprint: true - } - } - } - } - - post { - success { - echo "构建完成,版本号: ${env.EFFECTIVE_BUILD_VERSION}" - } - } -} diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy index c33fb5b7..149f496c 100644 --- a/jenkins/Jenkinsfile.build-and-deploy +++ b/jenkins/Jenkinsfile.build-and-deploy @@ -7,7 +7,8 @@ pipeline { } environment { - PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:${env.PATH}" + GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" + PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" } parameters { diff --git a/jenkins/Jenkinsfile.database-export b/jenkins/Jenkinsfile.database-export index e9f8a653..f8d8876a 100644 --- a/jenkins/Jenkinsfile.database-export +++ b/jenkins/Jenkinsfile.database-export @@ -6,6 +6,11 @@ pipeline { timestamps() } + environment { + GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" + PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + } + parameters { string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '执行节点标签') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') diff --git a/jenkins/Jenkinsfile.database-import b/jenkins/Jenkinsfile.database-import index 6ab8b74b..afd0ad97 100644 --- a/jenkins/Jenkinsfile.database-import +++ b/jenkins/Jenkinsfile.database-import @@ -6,6 +6,11 @@ pipeline { timestamps() } + environment { + GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" + PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + } + parameters { string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '执行节点标签') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') diff --git a/jenkins/Jenkinsfile.deploy b/jenkins/Jenkinsfile.deploy index 6af8dffb..a342f78e 100644 --- a/jenkins/Jenkinsfile.deploy +++ b/jenkins/Jenkinsfile.deploy @@ -6,6 +6,11 @@ pipeline { timestamps() } + environment { + GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" + PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + } + parameters { string(name: 'SOURCE_NODE_NAME', defaultValue: '', description: '上游构建节点名') string(name: 'SOURCE_WORKSPACE_ROOT', defaultValue: '', description: '上游源码根目录') diff --git a/packages/shared/src/contracts/match3dAgent.ts b/packages/shared/src/contracts/match3dAgent.ts index 37ee3667..743bf32c 100644 --- a/packages/shared/src/contracts/match3dAgent.ts +++ b/packages/shared/src/contracts/match3dAgent.ts @@ -75,6 +75,7 @@ export interface Match3DCreatorConfig { } export interface Match3DResultDraft { + profileId: string; gameName: string; themeText: string; summaryText?: string; diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 4db45f2a..87fbc943 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldProfile } from '../../types'; @@ -44,6 +45,9 @@ type CustomWorldCreationHubProps = { bigFishItems?: BigFishWorkSummary[]; onOpenBigFishDetail?: (item: BigFishWorkSummary) => void; onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null; + match3dItems?: Match3DWorkSummary[]; + onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void; + onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; @@ -130,6 +134,9 @@ export function CustomWorldCreationHub({ bigFishItems = [], onOpenBigFishDetail, onDeleteBigFish = null, + match3dItems = [], + onOpenMatch3DDetail, + onDeleteMatch3D = null, puzzleItems = [], onOpenPuzzleDetail, onDeletePuzzle = null, @@ -144,15 +151,19 @@ export function CustomWorldCreationHub({ rpgItems: items, rpgLibraryEntries, bigFishItems, + match3dItems, puzzleItems, canDeleteRpg: Boolean(onDeletePublished), canDeleteBigFish: Boolean(onDeleteBigFish), + canDeleteMatch3D: Boolean(onDeleteMatch3D), canDeletePuzzle: Boolean(onDeletePuzzle), }), [ bigFishItems, items, + match3dItems, onDeleteBigFish, + onDeleteMatch3D, onDeletePublished, onDeletePuzzle, puzzleItems, @@ -187,6 +198,9 @@ export function CustomWorldCreationHub({ case 'big-fish': onOpenBigFishDetail?.(item.source.item); return; + case 'match3d': + onOpenMatch3DDetail?.(item.source.item); + return; case 'rpg': if (item.status === 'draft') { onOpenDraft(item.source.item); @@ -217,6 +231,12 @@ export function CustomWorldCreationHub({ onDeleteBigFish?.(sourceItem); }; } + case 'match3d': { + const sourceItem = item.source.item; + return () => { + onDeleteMatch3D?.(sourceItem); + }; + } case 'rpg': { const sourceItem = item.source.item; return () => { diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 3be8e6db..07f4f4eb 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -1,15 +1,17 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBigFishPublicWorkCode, + buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, } from '../../services/publicWorkCode'; import type { CustomWorldProfile } from '../../types'; -export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle'; +export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'match3d' | 'puzzle'; export type CreationWorkShelfStatus = 'draft' | 'published'; export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral'; @@ -50,6 +52,10 @@ export type CreationWorkShelfSource = kind: 'big-fish'; item: BigFishWorkSummary; } + | { + kind: 'match3d'; + item: Match3DWorkSummary; + } | { kind: 'puzzle'; item: PuzzleWorkSummary; @@ -80,18 +86,22 @@ export function buildCreationWorkShelfItems(params: { rpgItems: CustomWorldWorkSummary[]; rpgLibraryEntries?: CustomWorldLibraryEntry[]; bigFishItems: BigFishWorkSummary[]; + match3dItems?: Match3DWorkSummary[]; puzzleItems: PuzzleWorkSummary[]; canDeleteRpg?: boolean; canDeleteBigFish?: boolean; + canDeleteMatch3D?: boolean; canDeletePuzzle?: boolean; }) { const { rpgItems, rpgLibraryEntries = [], bigFishItems, + match3dItems = [], puzzleItems, canDeleteRpg = false, canDeleteBigFish = false, + canDeleteMatch3D = false, canDeletePuzzle = false, } = params; @@ -102,6 +112,9 @@ export function buildCreationWorkShelfItems(params: { ...bigFishItems.map((item) => mapBigFishWorkToShelfItem(item, canDeleteBigFish), ), + ...match3dItems.map((item) => + mapMatch3DWorkToShelfItem(item, canDeleteMatch3D), + ), ...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle), ), @@ -203,6 +216,48 @@ function mapBigFishWorkToShelfItem( }; } +function mapMatch3DWorkToShelfItem( + item: Match3DWorkSummary, + canDelete: boolean, +): CreationWorkShelfItem { + const status = item.publicationStatus === 'published' ? 'published' : 'draft'; + const publicWorkCode = + status === 'published' ? buildMatch3DPublicWorkCode(item.profileId) : null; + + return { + id: item.workId, + kind: 'match3d', + status, + title: item.gameName, + summary: item.summary, + updatedAt: item.updatedAt, + coverImageSrc: item.coverImageSrc ?? null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + publicWorkCode, + sharePath: + publicWorkCode && status === 'published' + ? buildPublicWorkStagePath('work-detail', publicWorkCode) + : null, + openActionLabel: status === 'published' ? '查看详情' : '继续创作', + canDelete, + canShare: status === 'published' && Boolean(publicWorkCode), + badges: [ + buildStatusBadge(status), + { id: 'type', label: '抓鹅', tone: 'neutral' }, + ], + metrics: + status === 'published' + ? buildPublishedMetrics({ + playCount: item.playCount, + remixCount: 0, + likeCount: 0, + }) + : [], + source: { kind: 'match3d', item }, + }; +} + function mapPuzzleWorkToShelfItem( item: PuzzleWorkSummary, canDelete: boolean, diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx new file mode 100644 index 00000000..ecaab07f --- /dev/null +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks'; +import * as match3dWorksService from '../../services/match3d-works'; +import { Match3DResultView } from './Match3DResultView'; + +vi.mock('../ResolvedAssetImage', () => ({ + ResolvedAssetImage: ({ + src, + alt, + className, + }: { + src?: string | null; + alt?: string; + className?: string; + }) => (src ? {alt} : null), +})); + +vi.mock('../../services/match3d-works', () => ({ + publishMatch3DWork: vi.fn(), + updateMatch3DWork: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function createProfile( + overrides: Partial = {}, +): Match3DWorkProfile { + return { + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-1', + gameName: '水果抓大鹅', + themeText: '水果', + summary: '水果主题的经典消除玩法。', + tags: ['水果'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 4, + difficulty: 3, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-01T00:00:00.000Z', + publishedAt: null, + publishReady: false, + ...overrides, + }; +} + +describe('Match3DResultView', () => { + test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => { + const profile = createProfile(); + const onStartTestRun = vi.fn(); + vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({ + item: profile, + }); + + render( + {}} + onStartTestRun={onStartTestRun} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '试玩' })); + + await waitFor(() => { + expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith( + 'match3d-profile-1', + expect.objectContaining({ + clearCount: 4, + difficulty: 3, + gameName: '水果抓大鹅', + }), + ); + }); + expect(onStartTestRun).toHaveBeenCalledWith(profile); + expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled(); + }); + + test('发布仍要求封面和标签数量满足门槛', () => { + render( + {}} + onStartTestRun={() => {}} + />, + ); + + const publishButton = screen.getByRole('button', { name: '发布' }); + expect(publishButton).toHaveProperty('disabled', true); + + fireEvent.click(publishButton); + expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx new file mode 100644 index 00000000..91c5b80c --- /dev/null +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -0,0 +1,588 @@ +import { + ArrowLeft, + CheckCircle2, + ImagePlus, + Loader2, + Play, + Send, +} from 'lucide-react'; +import { type ChangeEvent, useEffect, useMemo, useState } from 'react'; + +import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { + Match3DWorkProfile, + PutMatch3DWorkRequest, +} from '../../../packages/shared/src/contracts/match3dWorks'; +import { + publishMatch3DWork, + updateMatch3DWork, +} from '../../services/match3d-works'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; + +type Match3DResultViewProps = { + profile: Match3DWorkProfile; + draft?: Match3DResultDraft | null; + isBusy?: boolean; + error?: string | null; + onBack: () => void; + onSaved?: (profile: Match3DWorkProfile) => void; + onPublished?: (profile: Match3DWorkProfile) => void; + onStartTestRun: (profile: Match3DWorkProfile) => void; +}; + +type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error'; + +type Match3DResultEditState = { + gameName: string; + summary: string; + tagsText: string; + coverImageSrc: string; + themeText: string; + clearCountText: string; + difficultyText: string; +}; + +const MATCH3D_MIN_TAG_COUNT = 3; +const MATCH3D_MAX_TAG_COUNT = 6; +const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600; + +function normalizeTags(value: string) { + return [ + ...new Set( + value + .split(/[\n,,、]/u) + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; +} + +function normalizePositiveInteger(value: string) { + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function normalizeDifficulty(value: string) { + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10 + ? parsed + : null; +} + +function createEditState(profile: Match3DWorkProfile): Match3DResultEditState { + return { + gameName: profile.gameName, + summary: profile.summary, + tagsText: profile.tags.join(','), + coverImageSrc: + profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '', + themeText: profile.themeText, + clearCountText: String(profile.clearCount), + difficultyText: String(profile.difficulty), + }; +} + +function buildSavePayload( + editState: Match3DResultEditState, +): PutMatch3DWorkRequest | null { + const clearCount = normalizePositiveInteger(editState.clearCountText); + const difficulty = normalizeDifficulty(editState.difficultyText); + const gameName = editState.gameName.trim(); + const themeText = editState.themeText.trim(); + const summary = editState.summary.trim(); + const tags = normalizeTags(editState.tagsText); + + if (!gameName || !themeText || !summary || !clearCount || !difficulty) { + return null; + } + + return { + gameName, + themeText, + summary, + tags, + coverImageSrc: editState.coverImageSrc.trim() || null, + clearCount, + difficulty, + }; +} + +function buildPublishBlockers(editState: Match3DResultEditState) { + const tags = normalizeTags(editState.tagsText); + const blockers = [ + ...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']), + ...(editState.themeText.trim() ? [] : ['题材主题不能为空。']), + ...(editState.summary.trim() ? [] : ['简介不能为空。']), + ...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']), + ...(tags.length >= MATCH3D_MIN_TAG_COUNT && tags.length <= MATCH3D_MAX_TAG_COUNT + ? [] + : [`标签数量需要在 ${MATCH3D_MIN_TAG_COUNT} 到 ${MATCH3D_MAX_TAG_COUNT} 个之间。`]), + ...(normalizePositiveInteger(editState.clearCountText) + ? [] + : ['需要消除次数必须为正整数。']), + ...(normalizeDifficulty(editState.difficultyText) + ? [] + : ['难度必须为 1 到 10。']), + ]; + + return [...new Set(blockers)]; +} + +function buildTestRunBlockers(editState: Match3DResultEditState) { + const blockers = [ + ...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']), + ...(editState.themeText.trim() ? [] : ['题材主题不能为空。']), + ...(editState.summary.trim() ? [] : ['简介不能为空。']), + ...(normalizePositiveInteger(editState.clearCountText) + ? [] + : ['需要消除次数必须为正整数。']), + ...(normalizeDifficulty(editState.difficultyText) + ? [] + : ['难度必须为 1 到 10。']), + ]; + + return [...new Set(blockers)]; +} + +function readImageAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + if (!file.type.startsWith('image/')) { + reject(new Error('请选择图片文件。')); + return; + } + + const reader = new FileReader(); + reader.onerror = () => reject(new Error('封面图读取失败,请重试。')); + reader.onload = () => resolve(String(reader.result || '')); + reader.readAsDataURL(file); + }); +} + +function buildPlayableProfile( + profile: Match3DWorkProfile, + editState: Match3DResultEditState, +) { + const payload = buildSavePayload(editState); + if (!payload) { + return profile; + } + + return { + ...profile, + gameName: payload.gameName, + themeText: payload.themeText ?? profile.themeText, + summary: payload.summary, + tags: payload.tags, + coverImageSrc: payload.coverImageSrc, + clearCount: payload.clearCount, + difficulty: payload.difficulty, + }; +} + +function Match3DResultHeader({ + autoSaveState, + isBusy, + onBack, +}: { + autoSaveState: Match3DAutoSaveState; + isBusy: boolean; + onBack: () => void; +}) { + const badge = + autoSaveState === 'saving' ? ( +
+ 保存中 +
+ ) : autoSaveState === 'saved' ? ( +
+ 已自动保存 +
+ ) : autoSaveState === 'error' ? ( +
+ 保存失败 +
+ ) : null; + + return ( +
+ + {badge} +
+ ); +} + +export function Match3DResultView({ + profile, + draft = null, + isBusy = false, + error = null, + onBack, + onSaved, + onPublished, + onStartTestRun, +}: Match3DResultViewProps) { + const [editState, setEditState] = useState(() => createEditState(profile)); + const [autoSaveState, setAutoSaveState] = + useState('idle'); + const [localError, setLocalError] = useState(null); + const [isPublishing, setIsPublishing] = useState(false); + const [isStartingTestRun, setIsStartingTestRun] = useState(false); + const blockers = useMemo(() => buildPublishBlockers(editState), [editState]); + const testRunBlockers = useMemo( + () => buildTestRunBlockers(editState), + [editState], + ); + const canStartTestRun = testRunBlockers.length === 0; + const canSubmit = blockers.length === 0; + const totalItemCount = + (normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) * + 3; + + useEffect(() => { + setEditState(createEditState(profile)); + setAutoSaveState('idle'); + setLocalError(null); + }, [profile.profileId, profile.updatedAt]); + + useEffect(() => { + const payload = buildSavePayload(editState); + if (!payload) { + return undefined; + } + + const currentTags = normalizeTags(profile.tags.join(',')); + const nextTags = payload.tags; + const changed = + payload.gameName !== profile.gameName || + payload.themeText !== profile.themeText || + payload.summary !== profile.summary || + (payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') || + payload.clearCount !== profile.clearCount || + payload.difficulty !== profile.difficulty || + nextTags.length !== currentTags.length || + nextTags.some((tag, index) => tag !== currentTags[index]); + + if (!changed) { + return undefined; + } + + setAutoSaveState('saving'); + setLocalError(null); + let cancelled = false; + const timer = window.setTimeout(() => { + void updateMatch3DWork(profile.profileId, payload) + .then(({ item }) => { + if (cancelled) { + return; + } + setAutoSaveState('saved'); + onSaved?.(item); + }) + .catch((saveError) => { + if (cancelled) { + return; + } + setAutoSaveState('error'); + setLocalError( + saveError instanceof Error ? saveError.message : '自动保存失败。', + ); + }); + }, MATCH3D_AUTOSAVE_DEBOUNCE_MS); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [editState, onSaved, profile]); + + const saveNow = async () => { + const payload = buildSavePayload(editState); + if (!payload) { + setLocalError(testRunBlockers[0] ?? '请补全作品信息。'); + return null; + } + + setAutoSaveState('saving'); + setLocalError(null); + const { item } = await updateMatch3DWork(profile.profileId, payload); + setAutoSaveState('saved'); + onSaved?.(item); + return item; + }; + + const handleCoverImageChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] ?? null; + event.target.value = ''; + if (!file) { + return; + } + + try { + const dataUrl = await readImageAsDataUrl(file); + setEditState((current) => ({ + ...current, + coverImageSrc: dataUrl, + })); + setLocalError(null); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error ? caughtError.message : '封面图读取失败。', + ); + } + }; + + const handleStartTestRun = async () => { + if (!canStartTestRun || isStartingTestRun) { + setLocalError(testRunBlockers[0] ?? null); + return; + } + + setIsStartingTestRun(true); + try { + const savedProfile = await saveNow(); + onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState)); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。', + ); + } finally { + setIsStartingTestRun(false); + } + }; + + const handlePublish = async () => { + if (!canSubmit || isPublishing) { + setLocalError(blockers[0] ?? null); + return; + } + + setIsPublishing(true); + try { + const savedProfile = await saveNow(); + const { item } = await publishMatch3DWork( + savedProfile?.profileId ?? profile.profileId, + ); + onPublished?.(item); + setLocalError(null); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error ? caughtError.message : '发布抓大鹅作品失败。', + ); + } finally { + setIsPublishing(false); + } + }; + + const busy = isBusy || isPublishing || isStartingTestRun; + const displayError = error ?? localError; + + return ( +
+ + +
+
+
+
+ {editState.coverImageSrc ? ( +
+ +
+
+ {totalItemCount} 件 +
+
+ {editState.clearCountText || '-'} 组 +
+
+ 难度 {editState.difficultyText || '-'} +
+
+
+ +
+
+ + + + +