Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-01 16:26:19 +08:00
30 changed files with 2104 additions and 436 deletions
@@ -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` 解释器。
@@ -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 表已纳入 migrationprocedure 已接 `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 联调链路单独排查。
+1
View File
@@ -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 规避参数。
-69
View File
@@ -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}"
}
}
}
+2 -1
View File
@@ -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 {
+5
View File
@@ -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 工作区')
+5
View File
@@ -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 工作区')
+5
View File
@@ -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: '上游源码根目录')
@@ -75,6 +75,7 @@ export interface Match3DCreatorConfig {
}
export interface Match3DResultDraft {
profileId: string;
gameName: string;
themeText: string;
summaryText?: string;
@@ -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 () => {
@@ -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<CustomWorldProfile>[];
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,
@@ -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 ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/match3d-works', () => ({
publishMatch3DWork: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
function createProfile(
overrides: Partial<Match3DWorkProfile> = {},
): 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(
<Match3DResultView
profile={profile}
onBack={() => {}}
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(
<Match3DResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
const publishButton = screen.getByRole('button', { name: '发布' });
expect(publishButton).toHaveProperty('disabled', true);
fireEvent.click(publishButton);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { Match3DResultView } from './Match3DResultView';
@@ -70,6 +70,22 @@ function buildClientEventId(itemInstanceId: string) {
)}`;
}
function isRunState(
status: Match3DRunSnapshot['status'],
expected: 'running' | 'won' | 'failed' | 'stopped',
) {
return String(status).toLowerCase() === expected;
}
function isItemState(
state: Match3DItemSnapshot['state'],
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
) {
return String(state)
.replace(/([a-z])([A-Z])/gu, '$1_$2')
.toLowerCase() === expected;
}
function isPointInsideCircle(
pointX: number,
pointY: number,
@@ -86,7 +102,7 @@ function findHitItem(
return run.items
.filter(
(item) =>
item.state === 'InBoard' &&
isItemState(item.state, 'in_board') &&
item.clickable &&
isPointInsideCircle(pointX, pointY, item),
)
@@ -137,13 +153,13 @@ function Match3DToken({
const visualSeed = resolveVisualSeed(item.visualKey);
const size = `${item.radius * 200}%`;
const itemStateClass =
item.state === 'Flying'
isItemState(item.state, 'flying')
? 'scale-75 opacity-0'
: item.clickable
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
: 'opacity-48';
if (item.state !== 'InBoard' && item.state !== 'Flying') {
if (!isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying')) {
return null;
}
@@ -160,7 +176,7 @@ function Match3DToken({
}}
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
data-testid={`match3d-item-${item.itemInstanceId}`}
disabled={disabled || !item.clickable || item.state !== 'InBoard'}
disabled={disabled || !item.clickable || !isItemState(item.state, 'in_board')}
onClick={() => onClick(item)}
>
<span className="relative z-10">{visualSeed.label}</span>
@@ -193,11 +209,11 @@ function Match3DSettlement({
onBack: () => void;
onRestart: () => void;
}) {
if (run.status === 'Running') {
if (isRunState(run.status, 'running')) {
return null;
}
const won = run.status === 'Won';
const stopped = run.status === 'Stopped';
const won = isRunState(run.status, 'won');
const stopped = isRunState(run.status, 'stopped');
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
const description = won
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
@@ -265,7 +281,7 @@ export function Match3DRuntimeShell({
}, [run?.remainingMs, run?.snapshotVersion]);
useEffect(() => {
if (!run || run.status !== 'Running') {
if (!run || !isRunState(run.status, 'running')) {
return undefined;
}
const timer = window.setInterval(() => {
@@ -296,7 +312,7 @@ export function Match3DRuntimeShell({
}, [run]);
const handleItemClick = async (item: Match3DItemSnapshot) => {
if (!run || run.status !== 'Running' || pendingClick) {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
return;
}
const optimisticRun = buildOptimisticRun(run, item);
@@ -337,7 +353,7 @@ export function Match3DRuntimeShell({
};
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!run || run.status !== 'Running' || pendingClick) {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
return;
}
const rect = stageRef.current?.getBoundingClientRect();
File diff suppressed because it is too large Load Diff
@@ -53,6 +53,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
if ('sourceType' in entry && entry.sourceType === 'match3d') {
return '抓大鹅';
}
return 'RPG';
}
@@ -24,6 +24,7 @@ export type SelectionStage =
| 'big-fish-runtime'
| 'match3d-agent-workspace'
| 'match3d-result'
| 'match3d-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-result'
@@ -10,6 +10,8 @@ import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
@@ -30,6 +32,20 @@ import {
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
clickMatch3DItem,
finishMatch3DTimeUp,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import {
createPuzzleAgentSession,
getPuzzleAgentSession,
@@ -242,10 +258,34 @@ vi.mock('../../services/big-fish-gallery', () => ({
vi.mock('../../services/big-fish-runtime', () => ({
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
recordBigFishPlay: vi.fn(),
recordBigFishPlay: vi.fn(() => Promise.resolve()),
startLocalBigFishRuntimeRun: vi.fn(),
}));
vi.mock('../../services/match3d-creation', () => ({
match3dCreationClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getSession: vi.fn(),
streamMessage: vi.fn(),
},
}));
vi.mock('../../services/match3d-works', () => ({
deleteMatch3DWork: vi.fn(),
getMatch3DWorkDetail: vi.fn(),
listMatch3DGallery: vi.fn(),
listMatch3DWorks: vi.fn(),
}));
vi.mock('../../services/match3d-runtime', () => ({
clickMatch3DItem: vi.fn(),
finishMatch3DTimeUp: vi.fn(),
restartMatch3DRun: vi.fn(),
startMatch3DRun: vi.fn(),
stopMatch3DRun: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
const actual = await vi.importActual<
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
@@ -399,6 +439,23 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
),
}));
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
Match3DRuntimeShell: ({
run,
onBack,
}: {
run: Match3DRunSnapshot | null;
onBack: () => void;
}) => (
<div className="match3d-runtime-shell-mock">
<div>{run?.runId ?? 'missing-run'}</div>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
@@ -599,6 +656,26 @@ function buildClearedPuzzleRun(params: {
};
}
function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
return {
runId: `match3d-run-${profileId}`,
profileId,
ownerUserId: 'user-2',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1_000,
durationLimitMs: 600_000,
serverNowMs: 1_000,
remainingMs: 600_000,
clearCount: 4,
totalItemCount: 12,
clearedItemCount: 0,
items: [],
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
failureReason: null,
};
}
function buildMockRpgGalleryDetail(
entry: CustomWorldGalleryCard,
): CustomWorldLibraryEntry<CustomWorldProfile> {
@@ -1056,6 +1133,7 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
async (ownerUserId, profileId) => ({
ownerUserId,
@@ -1500,8 +1578,43 @@ beforeEach(() => {
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(recordBigFishPlay).mockResolvedValue({
session: {} as never,
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: null,
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: null,
});
vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue(null);
vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({
session: null,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [],
});
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [],
});
vi.mocked(getMatch3DWorkDetail).mockRejectedValue(
new Error('未找到抓大鹅作品'),
);
vi.mocked(deleteMatch3DWork).mockResolvedValue({
items: [],
});
vi.mocked(startMatch3DRun).mockRejectedValue(
new Error('未启动抓大鹅运行态'),
);
vi.mocked(clickMatch3DItem).mockRejectedValue(
new Error('未执行抓大鹅点击'),
);
vi.mocked(restartMatch3DRun).mockRejectedValue(
new Error('未重新开始抓大鹅运行态'),
);
vi.mocked(finishMatch3DTimeUp).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-time-up'),
});
vi.mocked(stopMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
runId: 'big-fish-run-1',
@@ -2057,7 +2170,7 @@ test('logged out public detail gates big fish start before local runtime', async
);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2518,7 +2631,6 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
const button = screen.getByRole('button', { name: /.*/u });
await user.click(button);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(
@@ -2527,9 +2639,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
).length,
).toBeGreaterThan(0);
});
expect(
screen.getByRole('button', { name: '生成草稿' }) as HTMLButtonElement,
).toHaveProperty('disabled', false);
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
expect(screen.queryByText(//u)).toBeNull();
});
@@ -2734,7 +2844,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2807,7 +2917,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2850,7 +2960,7 @@ test('public code search opens a published big fish work by BF code', async () =
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2872,6 +2982,56 @@ test('public code search opens a published big fish work by BF code', async () =
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens a published Match3D work by M3 code and starts runtime', async () => {
const user = userEvent.setup();
const match3dWork: Match3DWorkSummary = {
workId: 'match3d-work-public-1',
profileId: 'match3d-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-public-1',
gameName: '水果抓大鹅',
themeText: '水果消除',
summary: '把圆形空间里的水果全部消除。',
tags: ['水果', '消除'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 4,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dWork],
});
vi.mocked(startMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun(match3dWork.profileId),
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'M3-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
});
expect(
await screen.findByText('抓大鹅运行态:match3d-run-match3d-profile-public-1'),
).toBeTruthy();
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();
@@ -620,7 +620,7 @@ test('mobile home search submits public work code', async () => {
);
const searchInput = screen.getByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'PZ-PROFILE1{enter}');
@@ -70,6 +70,7 @@ import {
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
isBigFishGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
@@ -303,7 +304,7 @@ function PublicCodeSearchBar({
onSubmit();
}
}}
placeholder="输入 SY / CW / BF / PZ 编号"
placeholder="输入 SY / CW / BF / M3 / PZ 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
@@ -1020,7 +1021,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
: isMatch3DGalleryEntry(entry)
? 'match3d'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -1029,7 +1032,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode);
: isMatch3DGalleryEntry(entry)
? '抓鹅'
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind);
}
@@ -1,4 +1,5 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
@@ -9,6 +10,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
@@ -20,6 +22,7 @@ export type PlatformWorldCardLike =
| CustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile>
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformPuzzleGalleryCard;
export type PlatformPuzzleGalleryCard = {
@@ -71,9 +74,31 @@ export type PlatformBigFishGalleryCard = {
updatedAt: string;
};
export type PlatformMatch3DGalleryCard = {
sourceType: 'match3d';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformPuzzleGalleryCard;
export function isLibraryWorldEntry(
@@ -94,6 +119,12 @@ export function isBigFishGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'big-fish';
}
export function isMatch3DGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformMatch3DGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'match3d';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -120,6 +151,31 @@ export function mapPuzzleWorkToPlatformGalleryCard(
};
}
export function mapMatch3DWorkToPlatformGalleryCard(
work: Match3DWorkSummary,
): PlatformMatch3DGalleryCard {
return {
sourceType: 'match3d',
workId: work.workId,
profileId: work.profileId,
publicWorkCode: buildMatch3DPublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: '玩家',
worldName: work.gameName,
subtitle: '经典消除玩法',
summaryText: work.summary,
coverImageSrc: work.coverImageSrc ?? null,
themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '抓大鹅'],
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: work.publishedAt ?? null,
updatedAt: work.updatedAt,
};
}
export function mapBigFishWorkToPlatformGalleryCard(
work: BigFishWorkSummary,
): PlatformBigFishGalleryCard {
@@ -307,6 +363,10 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
}
if (isMatch3DGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅'];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
@@ -381,6 +441,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isMatch3DGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}
+6
View File
@@ -27,6 +27,9 @@ describe('appPageRoutes', () => {
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
'big-fish-result',
);
expect(resolveSelectionStageFromPath('/creation/match3d/result')).toBe(
'match3d-result',
);
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
'puzzle-gallery-detail',
);
@@ -68,5 +71,8 @@ describe('appPageRoutes', () => {
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
'/runtime/big-fish?work=BF-00000003',
);
expect(buildPublicWorkStagePath('match3d-runtime', 'M3-00000004')).toBe(
'/runtime/match3d?work=M3-00000004',
);
});
});
+3
View File
@@ -14,6 +14,9 @@ const STAGE_ROUTE_ENTRIES = [
['big-fish-agent-workspace', '/creation/big-fish/agent'],
['big-fish-result', '/creation/big-fish/result'],
['big-fish-runtime', '/runtime/big-fish'],
['match3d-agent-workspace', '/creation/match3d/agent'],
['match3d-result', '/creation/match3d/result'],
['match3d-runtime', '/runtime/match3d'],
['puzzle-agent-workspace', '/creation/puzzle/agent'],
['puzzle-result', '/creation/puzzle/result'],
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
@@ -2,360 +2,87 @@ import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
Match3DActionResponse,
Match3DAgentMessageResponse,
Match3DAgentSessionSnapshot,
Match3DAnchorItemResponse,
Match3DCreatorConfig,
Match3DSessionResponse,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { TextStreamOptions } from '../aiTypes';
import { createCreationAgentClient } from '../creation-agent';
const MOCK_RESPONSE_DELAY_MS = 180;
const MATCH3D_SESSION_PREFIX = 'match3d-session';
const MATCH3D_AGENT_API_BASE = '/api/creation/match3d/sessions';
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
themeText: '缤纷玩具',
clearCount: 12,
difficulty: 4,
};
const match3dAgentHttpClient = createCreationAgentClient<
CreateMatch3DSessionRequest,
Match3DSessionResponse,
Match3DSessionResponse,
Match3DAgentSessionSnapshot,
SendMatch3DMessageRequest,
Match3DSessionResponse,
ExecuteMatch3DActionRequest,
Match3DActionResponse
>({
apiBase: MATCH3D_AGENT_API_BASE,
messages: {
createSession: '创建抓大鹅共创会话失败',
getSession: '读取抓大鹅共创会话失败',
sendMessage: '发送抓大鹅共创消息失败',
streamIncomplete: '抓大鹅共创消息流式结果不完整',
executeAction: '执行抓大鹅共创操作失败',
},
});
let match3dSessionCounter = 0;
const mockSessions = new Map<string, Match3DAgentSessionSnapshot>();
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, ms));
}
function nowIso() {
return new Date().toISOString();
}
function createMessage(
sessionId: string,
role: Match3DAgentMessageResponse['role'],
text: string,
kind: Match3DAgentMessageResponse['kind'] = 'chat',
): Match3DAgentMessageResponse {
return {
id: `${sessionId}-message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
role,
kind,
text,
createdAt: nowIso(),
};
}
function buildAnchor(
key: string,
label: string,
value: string,
): Match3DAnchorItemResponse {
return {
key,
label,
value,
status: value.trim() ? 'confirmed' : 'missing',
};
}
function buildAnchorPack(config: Partial<Match3DCreatorConfig>) {
return {
theme: buildAnchor('theme', '题材主题', config.themeText ?? ''),
clearCount: buildAnchor(
'clearCount',
'需要消除次数',
typeof config.clearCount === 'number' ? String(config.clearCount) : '',
),
difficulty: buildAnchor(
'difficulty',
'难度',
typeof config.difficulty === 'number' ? String(config.difficulty) : '',
),
};
}
function normalizePositiveInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const normalized = Math.floor(value);
return normalized > 0 ? normalized : null;
}
function normalizeDifficulty(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
return Math.max(1, Math.min(10, Math.round(value)));
}
function buildConfigFromPartial(
partial: Partial<Match3DCreatorConfig>,
): Match3DCreatorConfig | null {
const themeText = partial.themeText?.trim();
const clearCount = normalizePositiveInteger(partial.clearCount);
const difficulty = normalizeDifficulty(partial.difficulty);
if (!themeText || !clearCount || !difficulty) {
return null;
}
return {
themeText,
referenceImageSrc: partial.referenceImageSrc ?? null,
clearCount,
difficulty,
};
}
function parseConfigFromText(
text: string,
current: Partial<Match3DCreatorConfig>,
): Partial<Match3DCreatorConfig> {
const next = { ...current };
const trimmedText = text.trim();
const themeMatch =
trimmedText.match(/(?:|)[:\s]*([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})/u) ??
trimmedText.match(/(?:|||使)([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})(?:|)/u);
const clearCountMatch =
trimmedText.match(/(?:|)[:\s]*(\d+)/u) ??
trimmedText.match(/(\d+)\s*(?:|)/u);
const difficultyMatch =
trimmedText.match(/(?:)[:\s]*(10|[1-9])/u) ??
trimmedText.match(/(?:|)/u);
if (themeMatch?.[1]) {
next.themeText = themeMatch[1].trim();
}
if (clearCountMatch?.[1]) {
next.clearCount = Number(clearCountMatch[1]);
}
if (difficultyMatch?.[1]) {
next.difficulty = Number(difficultyMatch[1]);
} else if (difficultyMatch?.[0]) {
next.difficulty = 7;
}
if (!next.themeText && trimmedText.length >= 2 && trimmedText.length <= 24) {
next.themeText = trimmedText;
}
return next;
}
function resolveSessionProgress(config: Partial<Match3DCreatorConfig>) {
const completed = [
Boolean(config.themeText?.trim()),
Boolean(normalizePositiveInteger(config.clearCount)),
Boolean(normalizeDifficulty(config.difficulty)),
].filter(Boolean).length;
return Math.round((completed / 3) * 100);
}
function buildAssistantReply(config: Partial<Match3DCreatorConfig>) {
const missing: string[] = [];
if (!config.themeText?.trim()) {
missing.push('题材主题');
}
if (!normalizePositiveInteger(config.clearCount)) {
missing.push('需要消除次数');
}
if (!normalizeDifficulty(config.difficulty)) {
missing.push('难度');
}
if (missing.length === 0) {
const readyConfig = buildConfigFromPartial(config) ?? DEFAULT_MATCH3D_CONFIG;
return `已确认:${readyConfig.themeText}题材,消除 ${readyConfig.clearCount} 次,共 ${readyConfig.clearCount * 3} 件物品,难度 ${readyConfig.difficulty}。可以生成结果页。`;
}
return `还需要确认:${missing.join('、')}`;
}
function updateSessionConfig(
session: Match3DAgentSessionSnapshot,
partialConfig: Partial<Match3DCreatorConfig>,
) {
const progressPercent = resolveSessionProgress(partialConfig);
const config = buildConfigFromPartial(partialConfig);
return {
...session,
progressPercent,
stage: 'collecting_config',
anchorPack: buildAnchorPack(partialConfig),
config,
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
}
function ensureMockSession(sessionId: string) {
const session = mockSessions.get(sessionId);
if (!session) {
throw new Error('抓大鹅创作会话不存在,请重新开始创作。');
}
return session;
}
function buildDraft(config: Match3DCreatorConfig) {
return {
gameName: `${config.themeText}抓大鹅`,
themeText: config.themeText,
summaryText: `${config.themeText}题材的经典三消收纳关卡。`,
tags: [config.themeText, '抓大鹅', '消除'].slice(0, 3),
coverImageSrc: config.referenceImageSrc ?? null,
clearCount: config.clearCount,
difficulty: config.difficulty,
totalItemCount: config.clearCount * 3,
};
}
export async function createMatch3DCreationSession(
/**
* 创建抓大鹅 Agent 共创会话。
* Q1 起前端只走 Axum facade,避免本地 mock 成为创作真相源。
*/
export function createMatch3DCreationSession(
payload: CreateMatch3DSessionRequest = {},
): Promise<Match3DSessionResponse> {
await delay();
match3dSessionCounter += 1;
const sessionId = `${MATCH3D_SESSION_PREFIX}-${match3dSessionCounter}`;
const partialConfig: Partial<Match3DCreatorConfig> = {
themeText: payload.themeText ?? payload.seedText,
referenceImageSrc: payload.referenceImageSrc ?? null,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
};
const now = nowIso();
const session: Match3DAgentSessionSnapshot = updateSessionConfig(
{
sessionId,
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_config',
anchorPack: buildAnchorPack(partialConfig),
config: null,
draft: null,
messages: [
createMessage(
sessionId,
'assistant',
'先确认题材、需要消除次数和难度。也可以直接说“自动配置”。',
),
],
lastAssistantReply: null,
updatedAt: now,
},
partialConfig,
);
mockSessions.set(sessionId, session);
return { session };
) {
return match3dAgentHttpClient.createSession(payload);
}
export async function getMatch3DCreationSession(sessionId: string) {
await delay(80);
return { session: ensureMockSession(sessionId) };
/**
* 读取抓大鹅 Agent 会话快照。
*/
export function getMatch3DCreationSession(sessionId: string) {
return match3dAgentHttpClient.getSession(sessionId);
}
export async function streamMatch3DCreationMessage(
/**
* 非流式发送抓大鹅 Agent 消息,保留为 SSE 降级入口。
*/
export function sendMatch3DCreationMessage(
sessionId: string,
payload: SendMatch3DMessageRequest,
) {
return match3dAgentHttpClient.sendMessage(sessionId, payload);
}
/**
* 流式发送抓大鹅 Agent 消息。
*/
export function streamMatch3DCreationMessage(
sessionId: string,
payload: SendMatch3DMessageRequest,
options: TextStreamOptions = {},
): Promise<Match3DAgentSessionSnapshot> {
await delay(120);
const session = ensureMockSession(sessionId);
const text = payload.text.trim();
const currentConfig: Partial<Match3DCreatorConfig> = session.config ?? {
themeText: session.anchorPack.theme.value,
clearCount: Number(session.anchorPack.clearCount.value) || undefined,
difficulty: Number(session.anchorPack.difficulty.value) || undefined,
};
const nextConfig =
payload.quickFillRequested || //u.test(text)
? {
...DEFAULT_MATCH3D_CONFIG,
themeText: currentConfig.themeText || DEFAULT_MATCH3D_CONFIG.themeText,
}
: parseConfigFromText(text, currentConfig);
const userMessage = {
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text,
createdAt: nowIso(),
} satisfies Match3DAgentMessageResponse;
const assistantReply = buildAssistantReply(nextConfig);
options.onUpdate?.(assistantReply.slice(0, Math.ceil(assistantReply.length / 2)));
await delay(80);
options.onUpdate?.(assistantReply);
await delay(80);
const nextSession = updateSessionConfig(
{
...session,
currentTurn: session.currentTurn + 1,
messages: [
...session.messages,
userMessage,
createMessage(sessionId, 'assistant', assistantReply),
],
lastAssistantReply: assistantReply,
},
{
...nextConfig,
referenceImageSrc:
payload.referenceImageSrc ?? currentConfig.referenceImageSrc ?? null,
},
);
mockSessions.set(sessionId, nextSession);
return nextSession;
) {
return match3dAgentHttpClient.streamMessage(sessionId, payload, options);
}
export async function executeMatch3DCreationAction(
/**
* 执行抓大鹅创作操作,例如生成草稿作品。
*/
export function executeMatch3DCreationAction(
sessionId: string,
payload: ExecuteMatch3DActionRequest,
): Promise<Match3DActionResponse> {
await delay(220);
const session = ensureMockSession(sessionId);
if (payload.action !== 'match3d_compile_draft') {
throw new Error('未知抓大鹅创作操作。');
}
const config = session.config ?? buildConfigFromPartial(DEFAULT_MATCH3D_CONFIG);
if (!config) {
throw new Error('请先确认题材、需要消除次数和难度。');
}
const nextSession = {
...session,
stage: 'draft_ready',
progressPercent: 100,
config,
draft: buildDraft(config),
lastAssistantReply: '抓大鹅草稿已准备完成。',
messages: [
...session.messages,
createMessage(sessionId, 'assistant', '抓大鹅草稿已准备完成。', 'summary'),
],
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
mockSessions.set(sessionId, nextSession);
return { session: nextSession };
) {
return match3dAgentHttpClient.executeAction(sessionId, payload);
}
export const match3dCreationClient = {
createSession: createMatch3DCreationSession,
getSession: getMatch3DCreationSession,
sendMessage: sendMatch3DCreationMessage,
streamMessage: streamMatch3DCreationMessage,
executeAction: executeMatch3DCreationAction,
};

Some files were not shown because too many files have changed in this diff Show More