This commit is contained in:
146
docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md
Normal file
146
docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md
Normal file
@@ -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 联调链路单独排查。
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
- [MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md](./MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md):冻结抓大鹅 F2 结果页、基础信息编辑、发布前试玩入口、发布门槛、自动保存和已发布作品二次编辑恢复口径。
|
- [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_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_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`、固定底部锚点和安全区占位的修复口径。
|
- [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 自动迁移回灌和导入脚本参数。
|
- [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 规避参数。
|
- [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 规避参数。
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface Match3DCreatorConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Match3DResultDraft {
|
export interface Match3DResultDraft {
|
||||||
|
profileId: string;
|
||||||
gameName: string;
|
gameName: string;
|
||||||
themeText: string;
|
themeText: string;
|
||||||
summaryText?: string;
|
summaryText?: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
@@ -44,6 +45,9 @@ type CustomWorldCreationHubProps = {
|
|||||||
bigFishItems?: BigFishWorkSummary[];
|
bigFishItems?: BigFishWorkSummary[];
|
||||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||||
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||||
|
match3dItems?: Match3DWorkSummary[];
|
||||||
|
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
|
||||||
|
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
|
||||||
puzzleItems?: PuzzleWorkSummary[];
|
puzzleItems?: PuzzleWorkSummary[];
|
||||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||||
@@ -130,6 +134,9 @@ export function CustomWorldCreationHub({
|
|||||||
bigFishItems = [],
|
bigFishItems = [],
|
||||||
onOpenBigFishDetail,
|
onOpenBigFishDetail,
|
||||||
onDeleteBigFish = null,
|
onDeleteBigFish = null,
|
||||||
|
match3dItems = [],
|
||||||
|
onOpenMatch3DDetail,
|
||||||
|
onDeleteMatch3D = null,
|
||||||
puzzleItems = [],
|
puzzleItems = [],
|
||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onDeletePuzzle = null,
|
onDeletePuzzle = null,
|
||||||
@@ -144,15 +151,19 @@ export function CustomWorldCreationHub({
|
|||||||
rpgItems: items,
|
rpgItems: items,
|
||||||
rpgLibraryEntries,
|
rpgLibraryEntries,
|
||||||
bigFishItems,
|
bigFishItems,
|
||||||
|
match3dItems,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
canDeleteRpg: Boolean(onDeletePublished),
|
canDeleteRpg: Boolean(onDeletePublished),
|
||||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||||
|
canDeleteMatch3D: Boolean(onDeleteMatch3D),
|
||||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
bigFishItems,
|
bigFishItems,
|
||||||
items,
|
items,
|
||||||
|
match3dItems,
|
||||||
onDeleteBigFish,
|
onDeleteBigFish,
|
||||||
|
onDeleteMatch3D,
|
||||||
onDeletePublished,
|
onDeletePublished,
|
||||||
onDeletePuzzle,
|
onDeletePuzzle,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
@@ -187,6 +198,9 @@ export function CustomWorldCreationHub({
|
|||||||
case 'big-fish':
|
case 'big-fish':
|
||||||
onOpenBigFishDetail?.(item.source.item);
|
onOpenBigFishDetail?.(item.source.item);
|
||||||
return;
|
return;
|
||||||
|
case 'match3d':
|
||||||
|
onOpenMatch3DDetail?.(item.source.item);
|
||||||
|
return;
|
||||||
case 'rpg':
|
case 'rpg':
|
||||||
if (item.status === 'draft') {
|
if (item.status === 'draft') {
|
||||||
onOpenDraft(item.source.item);
|
onOpenDraft(item.source.item);
|
||||||
@@ -217,6 +231,12 @@ export function CustomWorldCreationHub({
|
|||||||
onDeleteBigFish?.(sourceItem);
|
onDeleteBigFish?.(sourceItem);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'match3d': {
|
||||||
|
const sourceItem = item.source.item;
|
||||||
|
return () => {
|
||||||
|
onDeleteMatch3D?.(sourceItem);
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'rpg': {
|
case 'rpg': {
|
||||||
const sourceItem = item.source.item;
|
const sourceItem = item.source.item;
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
import {
|
import {
|
||||||
buildBigFishPublicWorkCode,
|
buildBigFishPublicWorkCode,
|
||||||
|
buildMatch3DPublicWorkCode,
|
||||||
buildPuzzlePublicWorkCode,
|
buildPuzzlePublicWorkCode,
|
||||||
} from '../../services/publicWorkCode';
|
} from '../../services/publicWorkCode';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
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 CreationWorkShelfStatus = 'draft' | 'published';
|
||||||
|
|
||||||
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
||||||
@@ -50,6 +52,10 @@ export type CreationWorkShelfSource =
|
|||||||
kind: 'big-fish';
|
kind: 'big-fish';
|
||||||
item: BigFishWorkSummary;
|
item: BigFishWorkSummary;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'match3d';
|
||||||
|
item: Match3DWorkSummary;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'puzzle';
|
kind: 'puzzle';
|
||||||
item: PuzzleWorkSummary;
|
item: PuzzleWorkSummary;
|
||||||
@@ -80,18 +86,22 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
rpgItems: CustomWorldWorkSummary[];
|
rpgItems: CustomWorldWorkSummary[];
|
||||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||||
bigFishItems: BigFishWorkSummary[];
|
bigFishItems: BigFishWorkSummary[];
|
||||||
|
match3dItems?: Match3DWorkSummary[];
|
||||||
puzzleItems: PuzzleWorkSummary[];
|
puzzleItems: PuzzleWorkSummary[];
|
||||||
canDeleteRpg?: boolean;
|
canDeleteRpg?: boolean;
|
||||||
canDeleteBigFish?: boolean;
|
canDeleteBigFish?: boolean;
|
||||||
|
canDeleteMatch3D?: boolean;
|
||||||
canDeletePuzzle?: boolean;
|
canDeletePuzzle?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
rpgItems,
|
rpgItems,
|
||||||
rpgLibraryEntries = [],
|
rpgLibraryEntries = [],
|
||||||
bigFishItems,
|
bigFishItems,
|
||||||
|
match3dItems = [],
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
canDeleteRpg = false,
|
canDeleteRpg = false,
|
||||||
canDeleteBigFish = false,
|
canDeleteBigFish = false,
|
||||||
|
canDeleteMatch3D = false,
|
||||||
canDeletePuzzle = false,
|
canDeletePuzzle = false,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
@@ -102,6 +112,9 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
...bigFishItems.map((item) =>
|
...bigFishItems.map((item) =>
|
||||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||||
),
|
),
|
||||||
|
...match3dItems.map((item) =>
|
||||||
|
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D),
|
||||||
|
),
|
||||||
...puzzleItems.map((item) =>
|
...puzzleItems.map((item) =>
|
||||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
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(
|
function mapPuzzleWorkToShelfItem(
|
||||||
item: PuzzleWorkSummary,
|
item: PuzzleWorkSummary,
|
||||||
canDelete: boolean,
|
canDelete: boolean,
|
||||||
|
|||||||
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal file
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
588
src/components/match3d-result/Match3DResultView.tsx
Normal file
588
src/components/match3d-result/Match3DResultView.tsx
Normal file
@@ -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<string>((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' ? (
|
||||||
|
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||||
|
保存中
|
||||||
|
</div>
|
||||||
|
) : autoSaveState === 'saved' ? (
|
||||||
|
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||||
|
已自动保存
|
||||||
|
</div>
|
||||||
|
) : autoSaveState === 'error' ? (
|
||||||
|
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||||||
|
保存失败
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isBusy}
|
||||||
|
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
返回
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Match3DAutoSaveState>('idle');
|
||||||
|
const [localError, setLocalError] = useState<string | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
|
||||||
|
<Match3DResultHeader
|
||||||
|
autoSaveState={autoSaveState}
|
||||||
|
isBusy={busy}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[minmax(17rem,0.72fr)_minmax(0,1fr)]">
|
||||||
|
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
|
||||||
|
<div className="aspect-[4/3] overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))]">
|
||||||
|
{editState.coverImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={editState.coverImageSrc}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center text-emerald-700">
|
||||||
|
<ImagePlus className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="platform-button platform-button--ghost mt-3 flex min-h-10 cursor-pointer items-center justify-center gap-2 px-3 py-2 text-sm">
|
||||||
|
<ImagePlus className="h-4 w-4" />
|
||||||
|
封面图
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={handleCoverImageChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs font-bold text-[var(--platform-text-base)]">
|
||||||
|
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
|
||||||
|
{totalItemCount} 件
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
|
||||||
|
{editState.clearCountText || '-'} 组
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
|
||||||
|
难度 {editState.difficultyText || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
|
游戏名称
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editState.gameName}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditState({ ...editState, gameName: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
|
标签
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editState.tagsText}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditState({ ...editState, tagsText: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
|
简介
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={editState.summary}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditState({ ...editState, summary: event.target.value })
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
|
题材主题
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editState.themeText}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditState({ ...editState, themeText: event.target.value })
|
||||||
|
}
|
||||||
|
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
|
需要消除次数
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editState.clearCountText}
|
||||||
|
inputMode="numeric"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditState({
|
||||||
|
...editState,
|
||||||
|
clearCountText: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
|
难度
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editState.difficultyText}
|
||||||
|
inputMode="numeric"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditState({
|
||||||
|
...editState,
|
||||||
|
difficultyText: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft?.referenceImageSrc || profile.referenceImageSrc ? (
|
||||||
|
<div className="mt-4 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2">
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={draft?.referenceImageSrc ?? profile.referenceImageSrc ?? ''}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-24 w-full rounded-[0.8rem] object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayError ? (
|
||||||
|
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||||
|
{displayError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStartTestRun}
|
||||||
|
disabled={!canStartTestRun || busy}
|
||||||
|
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||||
|
>
|
||||||
|
{isStartingTestRun ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
试玩
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={!canSubmit || busy}
|
||||||
|
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||||
|
>
|
||||||
|
{isPublishing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : profile.publicationStatus === 'published' ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Match3DResultView;
|
||||||
1
src/components/match3d-result/index.ts
Normal file
1
src/components/match3d-result/index.ts
Normal 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(
|
function isPointInsideCircle(
|
||||||
pointX: number,
|
pointX: number,
|
||||||
pointY: number,
|
pointY: number,
|
||||||
@@ -86,7 +102,7 @@ function findHitItem(
|
|||||||
return run.items
|
return run.items
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.state === 'InBoard' &&
|
isItemState(item.state, 'in_board') &&
|
||||||
item.clickable &&
|
item.clickable &&
|
||||||
isPointInsideCircle(pointX, pointY, item),
|
isPointInsideCircle(pointX, pointY, item),
|
||||||
)
|
)
|
||||||
@@ -137,13 +153,13 @@ function Match3DToken({
|
|||||||
const visualSeed = resolveVisualSeed(item.visualKey);
|
const visualSeed = resolveVisualSeed(item.visualKey);
|
||||||
const size = `${item.radius * 200}%`;
|
const size = `${item.radius * 200}%`;
|
||||||
const itemStateClass =
|
const itemStateClass =
|
||||||
item.state === 'Flying'
|
isItemState(item.state, 'flying')
|
||||||
? 'scale-75 opacity-0'
|
? 'scale-75 opacity-0'
|
||||||
: item.clickable
|
: item.clickable
|
||||||
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
|
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
|
||||||
: 'opacity-48';
|
: 'opacity-48';
|
||||||
|
|
||||||
if (item.state !== 'InBoard' && item.state !== 'Flying') {
|
if (!isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +176,7 @@ function Match3DToken({
|
|||||||
}}
|
}}
|
||||||
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
||||||
data-testid={`match3d-item-${item.itemInstanceId}`}
|
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)}
|
onClick={() => onClick(item)}
|
||||||
>
|
>
|
||||||
<span className="relative z-10">{visualSeed.label}</span>
|
<span className="relative z-10">{visualSeed.label}</span>
|
||||||
@@ -193,11 +209,11 @@ function Match3DSettlement({
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onRestart: () => void;
|
onRestart: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (run.status === 'Running') {
|
if (isRunState(run.status, 'running')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const won = run.status === 'Won';
|
const won = isRunState(run.status, 'won');
|
||||||
const stopped = run.status === 'Stopped';
|
const stopped = isRunState(run.status, 'stopped');
|
||||||
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
|
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
|
||||||
const description = won
|
const description = won
|
||||||
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
|
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
|
||||||
@@ -265,7 +281,7 @@ export function Match3DRuntimeShell({
|
|||||||
}, [run?.remainingMs, run?.snapshotVersion]);
|
}, [run?.remainingMs, run?.snapshotVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!run || run.status !== 'Running') {
|
if (!run || !isRunState(run.status, 'running')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -296,7 +312,7 @@ export function Match3DRuntimeShell({
|
|||||||
}, [run]);
|
}, [run]);
|
||||||
|
|
||||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||||
if (!run || run.status !== 'Running' || pendingClick) {
|
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const optimisticRun = buildOptimisticRun(run, item);
|
const optimisticRun = buildOptimisticRun(run, item);
|
||||||
@@ -337,7 +353,7 @@ export function Match3DRuntimeShell({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
if (!run || run.status !== 'Running' || pendingClick) {
|
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rect = stageRef.current?.getBoundingClientRect();
|
const rect = stageRef.current?.getBoundingClientRect();
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import type {
|
|||||||
Match3DSessionResponse,
|
Match3DSessionResponse,
|
||||||
SendMatch3DMessageRequest,
|
SendMatch3DMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||||
|
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||||
|
import type {
|
||||||
|
Match3DWorkProfile,
|
||||||
|
Match3DWorkSummary,
|
||||||
|
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||||
import type {
|
import type {
|
||||||
PuzzleAgentActionRequest,
|
PuzzleAgentActionRequest,
|
||||||
PuzzleAgentOperationRecord,
|
PuzzleAgentOperationRecord,
|
||||||
@@ -85,6 +90,19 @@ import {
|
|||||||
shouldRestoreCustomWorldAgentUiState,
|
shouldRestoreCustomWorldAgentUiState,
|
||||||
} from '../../services/customWorldAgentUiState';
|
} from '../../services/customWorldAgentUiState';
|
||||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
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 {
|
import {
|
||||||
buildBigFishGenerationAnchorEntries,
|
buildBigFishGenerationAnchorEntries,
|
||||||
buildMiniGameDraftGenerationProgress,
|
buildMiniGameDraftGenerationProgress,
|
||||||
@@ -95,8 +113,10 @@ import {
|
|||||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||||
import {
|
import {
|
||||||
buildBigFishPublicWorkCode,
|
buildBigFishPublicWorkCode,
|
||||||
|
buildMatch3DPublicWorkCode,
|
||||||
buildPuzzlePublicWorkCode,
|
buildPuzzlePublicWorkCode,
|
||||||
isSameBigFishPublicWorkCode,
|
isSameBigFishPublicWorkCode,
|
||||||
|
isSameMatch3DPublicWorkCode,
|
||||||
isSamePuzzlePublicWorkCode,
|
isSamePuzzlePublicWorkCode,
|
||||||
} from '../../services/publicWorkCode';
|
} from '../../services/publicWorkCode';
|
||||||
import {
|
import {
|
||||||
@@ -153,8 +173,10 @@ import type { CustomWorldProfile } from '../../types';
|
|||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import {
|
import {
|
||||||
isBigFishGalleryEntry,
|
isBigFishGalleryEntry,
|
||||||
|
isMatch3DGalleryEntry,
|
||||||
isPuzzleGalleryEntry,
|
isPuzzleGalleryEntry,
|
||||||
mapBigFishWorkToPlatformGalleryCard,
|
mapBigFishWorkToPlatformGalleryCard,
|
||||||
|
mapMatch3DWorkToPlatformGalleryCard,
|
||||||
mapPuzzleWorkToPlatformGalleryCard,
|
mapPuzzleWorkToPlatformGalleryCard,
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||||
@@ -239,7 +261,9 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
|||||||
? 'big-fish'
|
? 'big-fish'
|
||||||
: isPuzzleGalleryEntry(entry)
|
: isPuzzleGalleryEntry(entry)
|
||||||
? 'puzzle'
|
? 'puzzle'
|
||||||
: 'rpg';
|
: isMatch3DGalleryEntry(entry)
|
||||||
|
? 'match3d'
|
||||||
|
: 'rpg';
|
||||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,12 +306,76 @@ function mapPuzzleWorkToPublicWorkDetail(
|
|||||||
return mapPuzzleWorkToPlatformGalleryCard(item);
|
return mapPuzzleWorkToPlatformGalleryCard(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapMatch3DWorkToPublicWorkDetail(
|
||||||
|
item: Match3DWorkSummary,
|
||||||
|
): PlatformPublicGalleryCard {
|
||||||
|
return mapMatch3DWorkToPlatformGalleryCard(item);
|
||||||
|
}
|
||||||
|
|
||||||
function mapBigFishWorkToPublicWorkDetail(
|
function mapBigFishWorkToPublicWorkDetail(
|
||||||
item: BigFishWorkSummary,
|
item: BigFishWorkSummary,
|
||||||
): PlatformPublicGalleryCard {
|
): PlatformPublicGalleryCard {
|
||||||
return mapBigFishWorkToPlatformGalleryCard(item);
|
return mapBigFishWorkToPlatformGalleryCard(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapPublicWorkDetailToMatch3DWork(
|
||||||
|
entry: PlatformPublicGalleryCard,
|
||||||
|
): Match3DWorkSummary | null {
|
||||||
|
if (!isMatch3DGalleryEntry(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workId: entry.workId,
|
||||||
|
profileId: entry.profileId,
|
||||||
|
ownerUserId: entry.ownerUserId,
|
||||||
|
sourceSessionId: null,
|
||||||
|
gameName: entry.worldName,
|
||||||
|
themeText: entry.themeTags[0] ?? '经典消除',
|
||||||
|
summary: entry.summaryText,
|
||||||
|
tags: entry.themeTags,
|
||||||
|
coverImageSrc: entry.coverImageSrc,
|
||||||
|
referenceImageSrc: null,
|
||||||
|
clearCount: 12,
|
||||||
|
difficulty: 4,
|
||||||
|
publicationStatus: 'published',
|
||||||
|
playCount: entry.playCount ?? 0,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
publishedAt: entry.publishedAt,
|
||||||
|
publishReady: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatch3DProfileFromSession(
|
||||||
|
session: Match3DAgentSessionSnapshot | null,
|
||||||
|
): Match3DWorkProfile | null {
|
||||||
|
const draft = session?.draft;
|
||||||
|
if (!session || !draft?.profileId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = session.updatedAt || new Date().toISOString();
|
||||||
|
return {
|
||||||
|
workId: draft.profileId,
|
||||||
|
profileId: draft.profileId,
|
||||||
|
ownerUserId: 'current-user',
|
||||||
|
sourceSessionId: session.sessionId,
|
||||||
|
gameName: draft.gameName,
|
||||||
|
themeText: draft.themeText,
|
||||||
|
summary: draft.summary ?? draft.summaryText ?? '',
|
||||||
|
tags: draft.tags,
|
||||||
|
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
|
||||||
|
referenceImageSrc: draft.referenceImageSrc ?? null,
|
||||||
|
clearCount: draft.clearCount,
|
||||||
|
difficulty: draft.difficulty,
|
||||||
|
publicationStatus: 'draft',
|
||||||
|
playCount: 0,
|
||||||
|
updatedAt: now,
|
||||||
|
publishedAt: null,
|
||||||
|
publishReady: Boolean(draft.publishReady),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapPublicWorkDetailToPuzzleWork(
|
function mapPublicWorkDetailToPuzzleWork(
|
||||||
entry: PlatformPublicGalleryCard,
|
entry: PlatformPublicGalleryCard,
|
||||||
): PuzzleWorkSummary | null {
|
): PuzzleWorkSummary | null {
|
||||||
@@ -686,10 +774,17 @@ const Match3DAgentWorkspace = lazy(async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const Match3DDraftReadyView = lazy(async () => {
|
const Match3DResultView = lazy(async () => {
|
||||||
const module = await import('../match3d-creation/Match3DDraftReadyView');
|
const module = await import('../match3d-result/Match3DResultView');
|
||||||
return {
|
return {
|
||||||
default: module.Match3DDraftReadyView,
|
default: module.Match3DResultView,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const Match3DRuntimeShell = lazy(async () => {
|
||||||
|
const module = await import('../match3d-runtime/Match3DRuntimeShell');
|
||||||
|
return {
|
||||||
|
default: module.Match3DRuntimeShell,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -823,6 +918,16 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
|
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
|
||||||
BigFishWorkSummary[]
|
BigFishWorkSummary[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [match3dWorks, setMatch3DWorks] = useState<Match3DWorkSummary[]>([]);
|
||||||
|
const [match3dGalleryEntries, setMatch3DGalleryEntries] = useState<
|
||||||
|
Match3DWorkSummary[]
|
||||||
|
>([]);
|
||||||
|
const [match3dProfile, setMatch3DProfile] =
|
||||||
|
useState<Match3DWorkProfile | null>(null);
|
||||||
|
const [match3dRun, setMatch3DRun] = useState<Match3DRunSnapshot | null>(null);
|
||||||
|
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] =
|
||||||
|
useState<'match3d-result' | 'work-detail'>('match3d-result');
|
||||||
|
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
|
||||||
const [bigFishRun, setBigFishRun] =
|
const [bigFishRun, setBigFishRun] =
|
||||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||||
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
|
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
|
||||||
@@ -955,6 +1060,34 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
}, [resolveBigFishErrorMessage]);
|
}, [resolveBigFishErrorMessage]);
|
||||||
|
|
||||||
|
const refreshMatch3DShelf = useCallback(async () => {
|
||||||
|
setIsMatch3DLoadingLibrary(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worksResponse = await listMatch3DWorks();
|
||||||
|
setMatch3DWorks(worksResponse.items);
|
||||||
|
setMatch3DError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setMatch3DError(
|
||||||
|
resolveMatch3DErrorMessage(error, '读取抓大鹅作品列表失败。'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsMatch3DLoadingLibrary(false);
|
||||||
|
}
|
||||||
|
}, [resolveMatch3DErrorMessage]);
|
||||||
|
|
||||||
|
const refreshMatch3DGallery = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const galleryResponse = await listMatch3DGallery();
|
||||||
|
setMatch3DGalleryEntries(galleryResponse.items);
|
||||||
|
return galleryResponse.items;
|
||||||
|
} catch (error) {
|
||||||
|
setMatch3DGalleryEntries([]);
|
||||||
|
setMatch3DError(resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [resolveMatch3DErrorMessage]);
|
||||||
|
|
||||||
const refreshPuzzleShelf = useCallback(async () => {
|
const refreshPuzzleShelf = useCallback(async () => {
|
||||||
setIsPuzzleLoadingLibrary(true);
|
setIsPuzzleLoadingLibrary(true);
|
||||||
|
|
||||||
@@ -1136,16 +1269,20 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const bigFishPublicEntries = isBigFishCreationVisible
|
const bigFishPublicEntries = isBigFishCreationVisible
|
||||||
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||||
: [];
|
: [];
|
||||||
|
const match3dPublicEntries = match3dGalleryEntries.map(
|
||||||
|
mapMatch3DWorkToPlatformGalleryCard,
|
||||||
|
);
|
||||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||||
mapPuzzleWorkToPlatformGalleryCard,
|
mapPuzzleWorkToPlatformGalleryCard,
|
||||||
);
|
);
|
||||||
return mergePlatformPublicGalleryEntries(
|
return mergePlatformPublicGalleryEntries(
|
||||||
platformBootstrap.publishedGalleryEntries,
|
platformBootstrap.publishedGalleryEntries,
|
||||||
[...bigFishPublicEntries, ...puzzlePublicEntries],
|
[...bigFishPublicEntries, ...match3dPublicEntries, ...puzzlePublicEntries],
|
||||||
).slice(0, 6);
|
).slice(0, 6);
|
||||||
}, [
|
}, [
|
||||||
isBigFishCreationVisible,
|
isBigFishCreationVisible,
|
||||||
bigFishGalleryEntries,
|
bigFishGalleryEntries,
|
||||||
|
match3dGalleryEntries,
|
||||||
platformBootstrap.publishedGalleryEntries,
|
platformBootstrap.publishedGalleryEntries,
|
||||||
puzzleGalleryEntries,
|
puzzleGalleryEntries,
|
||||||
]);
|
]);
|
||||||
@@ -1157,12 +1294,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
...(isBigFishCreationVisible
|
...(isBigFishCreationVisible
|
||||||
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||||
: []),
|
: []),
|
||||||
|
...match3dGalleryEntries.map(mapMatch3DWorkToPlatformGalleryCard),
|
||||||
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
isBigFishCreationVisible,
|
isBigFishCreationVisible,
|
||||||
bigFishGalleryEntries,
|
bigFishGalleryEntries,
|
||||||
|
match3dGalleryEntries,
|
||||||
platformBootstrap.publishedGalleryEntries,
|
platformBootstrap.publishedGalleryEntries,
|
||||||
puzzleGalleryEntries,
|
puzzleGalleryEntries,
|
||||||
],
|
],
|
||||||
@@ -1336,8 +1475,25 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
onSessionOpened: () => {
|
onSessionOpened: () => {
|
||||||
setShowCreationTypeModal(false);
|
setShowCreationTypeModal(false);
|
||||||
},
|
},
|
||||||
onActionComplete: ({ response, setSession }) => {
|
onActionComplete: async ({ payload, response, setSession }) => {
|
||||||
setSession(response.session);
|
setSession(response.session);
|
||||||
|
if (payload.action !== 'match3d_compile_draft') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileId = response.session.draft?.profileId;
|
||||||
|
if (!profileId) {
|
||||||
|
setMatch3DProfile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { item } = await getMatch3DWorkDetail(profileId);
|
||||||
|
setMatch3DProfile(item);
|
||||||
|
await refreshMatch3DShelf().catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
setMatch3DProfile(buildMatch3DProfileFromSession(response.session));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1495,6 +1651,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const openMatch3DAgentWorkspace = useCallback(async () => {
|
const openMatch3DAgentWorkspace = useCallback(async () => {
|
||||||
setMatch3DSession(null);
|
setMatch3DSession(null);
|
||||||
|
setMatch3DProfile(null);
|
||||||
|
setMatch3DRun(null);
|
||||||
setMatch3DError(null);
|
setMatch3DError(null);
|
||||||
setStreamingMatch3DReplyText('');
|
setStreamingMatch3DReplyText('');
|
||||||
setIsStreamingMatch3DReply(false);
|
setIsStreamingMatch3DReply(false);
|
||||||
@@ -1503,6 +1661,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
match3dFlow,
|
match3dFlow,
|
||||||
setIsStreamingMatch3DReply,
|
setIsStreamingMatch3DReply,
|
||||||
setMatch3DError,
|
setMatch3DError,
|
||||||
|
setMatch3DProfile,
|
||||||
|
setMatch3DRun,
|
||||||
setMatch3DSession,
|
setMatch3DSession,
|
||||||
setStreamingMatch3DReplyText,
|
setStreamingMatch3DReplyText,
|
||||||
]);
|
]);
|
||||||
@@ -1595,6 +1755,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setBigFishGenerationState(null);
|
setBigFishGenerationState(null);
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
setMatch3DSession(null);
|
setMatch3DSession(null);
|
||||||
|
setMatch3DProfile(null);
|
||||||
|
setMatch3DWorks([]);
|
||||||
|
setMatch3DGalleryEntries([]);
|
||||||
|
setMatch3DRun(null);
|
||||||
|
setMatch3DRuntimeReturnStage('match3d-result');
|
||||||
setMatch3DError(null);
|
setMatch3DError(null);
|
||||||
setStreamingMatch3DReplyText('');
|
setStreamingMatch3DReplyText('');
|
||||||
setIsStreamingMatch3DReply(false);
|
setIsStreamingMatch3DReply(false);
|
||||||
@@ -1689,6 +1854,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}, [bigFishFlow]);
|
}, [bigFishFlow]);
|
||||||
|
|
||||||
const leaveMatch3DFlow = useCallback(() => {
|
const leaveMatch3DFlow = useCallback(() => {
|
||||||
|
setMatch3DRun(null);
|
||||||
|
setMatch3DRuntimeReturnStage('match3d-result');
|
||||||
match3dFlow.leaveFlow();
|
match3dFlow.leaveFlow();
|
||||||
}, [match3dFlow]);
|
}, [match3dFlow]);
|
||||||
|
|
||||||
@@ -1758,7 +1925,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
match3dSession ? 'match3d-agent-workspace' : 'platform',
|
match3dSession ? 'match3d-agent-workspace' : 'platform',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [match3dSession, selectionStage, setSelectionStage]);
|
if (selectionStage === 'match3d-runtime' && !match3dRun) {
|
||||||
|
setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform');
|
||||||
|
}
|
||||||
|
}, [match3dRun, match3dSession, selectionStage, setSelectionStage]);
|
||||||
|
|
||||||
const startBigFishRun = useCallback(() => {
|
const startBigFishRun = useCallback(() => {
|
||||||
if (!bigFishSession) {
|
if (!bigFishSession) {
|
||||||
@@ -1875,6 +2045,54 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const startMatch3DRunFromProfile = useCallback(
|
||||||
|
async (
|
||||||
|
profile: Match3DWorkProfile | Match3DWorkSummary,
|
||||||
|
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
|
||||||
|
mirrorErrorToPublicDetail = false,
|
||||||
|
) => {
|
||||||
|
if (isMatch3DBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match3dFlow.setIsBusy(true);
|
||||||
|
setMatch3DError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { run } = await startMatch3DRun(profile.profileId);
|
||||||
|
setMatch3DRun(run);
|
||||||
|
setMatch3DRuntimeReturnStage(returnStage);
|
||||||
|
setSelectionStage('match3d-runtime');
|
||||||
|
if (profile.publicationStatus === 'published') {
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath(
|
||||||
|
'work-detail',
|
||||||
|
buildMatch3DPublicWorkCode(profile.profileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = resolveMatch3DErrorMessage(
|
||||||
|
error,
|
||||||
|
'启动抓大鹅玩法失败。',
|
||||||
|
);
|
||||||
|
setMatch3DError(message);
|
||||||
|
if (mirrorErrorToPublicDetail) {
|
||||||
|
setPublicWorkDetailError(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
match3dFlow.setIsBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isMatch3DBusy,
|
||||||
|
match3dFlow,
|
||||||
|
resolveMatch3DErrorMessage,
|
||||||
|
setMatch3DError,
|
||||||
|
setSelectionStage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const buildPuzzleTestWork = useCallback(
|
const buildPuzzleTestWork = useCallback(
|
||||||
(draft: PuzzleResultDraft) => {
|
(draft: PuzzleResultDraft) => {
|
||||||
const profileId =
|
const profileId =
|
||||||
@@ -2595,6 +2813,47 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDeleteMatch3DWork = useCallback(
|
||||||
|
(work: Match3DWorkSummary) => {
|
||||||
|
if (deletingCreationWorkId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runProtectedAction(() => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`确认删除作品《${work.gameName}》吗?删除后会从你的作品列表中移除。`,
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingCreationWorkId(work.workId);
|
||||||
|
setMatch3DError(null);
|
||||||
|
|
||||||
|
void deleteMatch3DWork(work.profileId)
|
||||||
|
.then((response) => {
|
||||||
|
setMatch3DWorks(response.items);
|
||||||
|
void refreshMatch3DGallery();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setMatch3DError(
|
||||||
|
resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDeletingCreationWorkId(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
deletingCreationWorkId,
|
||||||
|
refreshMatch3DGallery,
|
||||||
|
resolveMatch3DErrorMessage,
|
||||||
|
runProtectedAction,
|
||||||
|
setMatch3DError,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const clearSelectedPublicWorkAuthor = useCallback(() => {
|
const clearSelectedPublicWorkAuthor = useCallback(() => {
|
||||||
publicWorkAuthorRequestKeyRef.current += 1;
|
publicWorkAuthorRequestKeyRef.current += 1;
|
||||||
setSelectedPublicWorkAuthor(null);
|
setSelectedPublicWorkAuthor(null);
|
||||||
@@ -2911,6 +3170,45 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openMatch3DPublicWorkDetail = useCallback(
|
||||||
|
async (profileId: string) => {
|
||||||
|
setIsPublicWorkDetailBusy(true);
|
||||||
|
setMatch3DError(null);
|
||||||
|
setPublicWorkDetailError(null);
|
||||||
|
setSelectionStage('work-detail');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries =
|
||||||
|
match3dGalleryEntries.length > 0
|
||||||
|
? match3dGalleryEntries
|
||||||
|
: await refreshMatch3DGallery();
|
||||||
|
const matchedEntry = entries.find(
|
||||||
|
(entry) => entry.profileId === profileId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchedEntry) {
|
||||||
|
throw new Error('未找到抓大鹅作品。');
|
||||||
|
}
|
||||||
|
|
||||||
|
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(matchedEntry));
|
||||||
|
} catch (error) {
|
||||||
|
setPublicWorkDetailError(
|
||||||
|
resolveMatch3DErrorMessage(error, '读取抓大鹅详情失败。'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsPublicWorkDetailBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
match3dGalleryEntries,
|
||||||
|
openPublicWorkDetail,
|
||||||
|
refreshMatch3DGallery,
|
||||||
|
resolveMatch3DErrorMessage,
|
||||||
|
setMatch3DError,
|
||||||
|
setSelectionStage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const openPuzzleDetail = useCallback(
|
const openPuzzleDetail = useCallback(
|
||||||
async (
|
async (
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -2986,6 +3284,47 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openMatch3DDraft = useCallback(
|
||||||
|
async (item: Match3DWorkSummary) => {
|
||||||
|
setMatch3DRun(null);
|
||||||
|
setMatch3DError(null);
|
||||||
|
setMatch3DProfile(null);
|
||||||
|
|
||||||
|
if (item.publicationStatus === 'published') {
|
||||||
|
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.sourceSessionId?.trim()) {
|
||||||
|
setMatch3DError('这份抓大鹅草稿缺少会话信息,请重新开始创作。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredSession = await match3dFlow.restoreDraft(item.sourceSessionId);
|
||||||
|
if (!restoredSession) {
|
||||||
|
await refreshMatch3DShelf().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { item: profile } = await getMatch3DWorkDetail(item.profileId);
|
||||||
|
setMatch3DProfile(profile);
|
||||||
|
} catch (error) {
|
||||||
|
setMatch3DProfile(buildMatch3DProfileFromSession(restoredSession));
|
||||||
|
setMatch3DError(
|
||||||
|
resolveMatch3DErrorMessage(error, '读取抓大鹅作品详情失败。'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
match3dFlow,
|
||||||
|
openPublicWorkDetail,
|
||||||
|
refreshMatch3DShelf,
|
||||||
|
resolveMatch3DErrorMessage,
|
||||||
|
setMatch3DError,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const startBigFishRunFromWork = useCallback(
|
const startBigFishRunFromWork = useCallback(
|
||||||
(
|
(
|
||||||
item: BigFishWorkSummary,
|
item: BigFishWorkSummary,
|
||||||
@@ -3047,6 +3386,17 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) {
|
||||||
|
const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail);
|
||||||
|
if (!work) {
|
||||||
|
setPublicWorkDetailError('当前抓大鹅作品信息不完整,暂时无法进入玩法。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPublicWorkDetailError(null);
|
||||||
|
void startMatch3DRunFromProfile(work, 'work-detail', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const launchEntry =
|
const launchEntry =
|
||||||
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
|
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
|
||||||
? selectedDetailEntry
|
? selectedDetailEntry
|
||||||
@@ -3085,6 +3435,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
selectedDetailEntry,
|
selectedDetailEntry,
|
||||||
selectedPublicWorkDetail,
|
selectedPublicWorkDetail,
|
||||||
startBigFishRunFromWork,
|
startBigFishRunFromWork,
|
||||||
|
startMatch3DRunFromProfile,
|
||||||
startPuzzleRunFromProfile,
|
startPuzzleRunFromProfile,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -3135,6 +3486,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMatch3DGalleryEntry(entry)) {
|
||||||
|
setPublicWorkDetailError('抓大鹅作品改造将在后续版本开放。');
|
||||||
|
setIsPublicWorkDetailBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
|
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const nextEntry = response.entry;
|
const nextEntry = response.entry;
|
||||||
@@ -3194,10 +3551,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
normalizedKeyword,
|
normalizedKeyword,
|
||||||
);
|
);
|
||||||
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
|
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
|
||||||
|
const shouldSearchMatch3DFirst = upperKeyword.startsWith('M3');
|
||||||
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
|
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
|
||||||
const shouldSearchWorkFirst =
|
const shouldSearchWorkFirst =
|
||||||
!shouldSearchUserIdFirst &&
|
!shouldSearchUserIdFirst &&
|
||||||
!shouldSearchBigFishFirst &&
|
!shouldSearchBigFishFirst &&
|
||||||
|
!shouldSearchMatch3DFirst &&
|
||||||
!shouldSearchPuzzleFirst &&
|
!shouldSearchPuzzleFirst &&
|
||||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||||
const shouldSearchUserFirst =
|
const shouldSearchUserFirst =
|
||||||
@@ -3205,6 +3564,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
upperKeyword.startsWith('SY') ||
|
upperKeyword.startsWith('SY') ||
|
||||||
(!shouldSearchWorkFirst &&
|
(!shouldSearchWorkFirst &&
|
||||||
!shouldSearchBigFishFirst &&
|
!shouldSearchBigFishFirst &&
|
||||||
|
!shouldSearchMatch3DFirst &&
|
||||||
!shouldSearchPuzzleFirst);
|
!shouldSearchPuzzleFirst);
|
||||||
|
|
||||||
const tryOpenGalleryEntry = async () => {
|
const tryOpenGalleryEntry = async () => {
|
||||||
@@ -3265,6 +3625,21 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry));
|
openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry));
|
||||||
};
|
};
|
||||||
|
const tryOpenMatch3DGalleryEntry = async () => {
|
||||||
|
const entries =
|
||||||
|
match3dGalleryEntries.length > 0
|
||||||
|
? match3dGalleryEntries
|
||||||
|
: await refreshMatch3DGallery();
|
||||||
|
const matchedEntry = entries.find((entry) =>
|
||||||
|
isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchedEntry) {
|
||||||
|
throw new Error('未找到抓大鹅作品。');
|
||||||
|
}
|
||||||
|
|
||||||
|
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(matchedEntry));
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (shouldSearchUserIdFirst) {
|
if (shouldSearchUserIdFirst) {
|
||||||
@@ -3283,6 +3658,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldSearchMatch3DFirst) {
|
||||||
|
await tryOpenMatch3DGalleryEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldSearchWorkFirst) {
|
if (shouldSearchWorkFirst) {
|
||||||
try {
|
try {
|
||||||
await tryOpenGalleryEntry();
|
await tryOpenGalleryEntry();
|
||||||
@@ -3319,6 +3699,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
bigFishGalleryEntries,
|
bigFishGalleryEntries,
|
||||||
|
match3dGalleryEntries,
|
||||||
|
refreshMatch3DGallery,
|
||||||
openPuzzlePublicWorkDetail,
|
openPuzzlePublicWorkDetail,
|
||||||
openPublicWorkDetail,
|
openPublicWorkDetail,
|
||||||
platformBootstrap.platformTab,
|
platformBootstrap.platformTab,
|
||||||
@@ -3360,6 +3742,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
worldType === 'match3d' ||
|
||||||
|
worldType === 'match_3d' ||
|
||||||
|
work.worldKey.startsWith('match3d:')
|
||||||
|
) {
|
||||||
|
const profileId =
|
||||||
|
work.profileId ?? work.worldKey.replace(/^match3d:/u, '');
|
||||||
|
if (profileId) {
|
||||||
|
void openMatch3DPublicWorkDetail(profileId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
worldType === 'big_fish' ||
|
worldType === 'big_fish' ||
|
||||||
worldType === 'big-fish' ||
|
worldType === 'big-fish' ||
|
||||||
@@ -3437,6 +3832,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
openMatch3DPublicWorkDetail,
|
||||||
openPuzzlePublicWorkDetail,
|
openPuzzlePublicWorkDetail,
|
||||||
openPublicWorkDetail,
|
openPublicWorkDetail,
|
||||||
openRpgPublicWorkDetail,
|
openRpgPublicWorkDetail,
|
||||||
@@ -3476,11 +3872,13 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
if (isBigFishCreationVisible) {
|
if (isBigFishCreationVisible) {
|
||||||
void refreshBigFishGallery();
|
void refreshBigFishGallery();
|
||||||
}
|
}
|
||||||
|
void refreshMatch3DGallery();
|
||||||
void refreshPuzzleGallery();
|
void refreshPuzzleGallery();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isBigFishCreationVisible,
|
isBigFishCreationVisible,
|
||||||
refreshBigFishGallery,
|
refreshBigFishGallery,
|
||||||
|
refreshMatch3DGallery,
|
||||||
refreshPuzzleGallery,
|
refreshPuzzleGallery,
|
||||||
selectionStage,
|
selectionStage,
|
||||||
]);
|
]);
|
||||||
@@ -3492,10 +3890,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
platformBootstrap.canReadProtectedData
|
platformBootstrap.canReadProtectedData
|
||||||
) {
|
) {
|
||||||
void refreshPuzzleShelf();
|
void refreshPuzzleShelf();
|
||||||
|
void refreshMatch3DShelf();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
platformBootstrap.canReadProtectedData,
|
platformBootstrap.canReadProtectedData,
|
||||||
platformBootstrap.platformTab,
|
platformBootstrap.platformTab,
|
||||||
|
refreshMatch3DShelf,
|
||||||
refreshPuzzleShelf,
|
refreshPuzzleShelf,
|
||||||
selectionStage,
|
selectionStage,
|
||||||
]);
|
]);
|
||||||
@@ -3524,11 +3924,13 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
loading={
|
loading={
|
||||||
platformBootstrap.isLoadingPlatform ||
|
platformBootstrap.isLoadingPlatform ||
|
||||||
isBigFishLoadingLibrary ||
|
isBigFishLoadingLibrary ||
|
||||||
|
isMatch3DLoadingLibrary ||
|
||||||
isPuzzleLoadingLibrary
|
isPuzzleLoadingLibrary
|
||||||
}
|
}
|
||||||
error={
|
error={
|
||||||
platformBootstrap.isLoadingPlatform ||
|
platformBootstrap.isLoadingPlatform ||
|
||||||
isBigFishLoadingLibrary ||
|
isBigFishLoadingLibrary ||
|
||||||
|
isMatch3DLoadingLibrary ||
|
||||||
isPuzzleLoadingLibrary
|
isPuzzleLoadingLibrary
|
||||||
? null
|
? null
|
||||||
: (platformBootstrap.platformError ??
|
: (platformBootstrap.platformError ??
|
||||||
@@ -3550,6 +3952,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
if (isBigFishCreationVisible) {
|
if (isBigFishCreationVisible) {
|
||||||
void refreshBigFishShelf();
|
void refreshBigFishShelf();
|
||||||
}
|
}
|
||||||
|
void refreshMatch3DShelf();
|
||||||
void refreshPuzzleShelf();
|
void refreshPuzzleShelf();
|
||||||
}}
|
}}
|
||||||
createError={
|
createError={
|
||||||
@@ -3603,6 +4006,15 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
match3dItems={match3dWorks}
|
||||||
|
onOpenMatch3DDetail={(item) => {
|
||||||
|
runProtectedAction(() => {
|
||||||
|
void openMatch3DDraft(item);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDeleteMatch3D={(item) => {
|
||||||
|
handleDeleteMatch3DWork(item);
|
||||||
|
}}
|
||||||
puzzleItems={puzzleWorks}
|
puzzleItems={puzzleWorks}
|
||||||
onOpenPuzzleDetail={(item) => {
|
onOpenPuzzleDetail={(item) => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
@@ -3684,6 +4096,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMatch3DGalleryEntry(entry)) {
|
||||||
|
openPublicWorkDetail(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void openRpgPublicWorkDetail(entry);
|
void openRpgPublicWorkDetail(entry);
|
||||||
}}
|
}}
|
||||||
onOpenLibraryDetail={(entry) => {
|
onOpenLibraryDetail={(entry) => {
|
||||||
@@ -4076,13 +4493,106 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
<Suspense
|
<Suspense
|
||||||
fallback={<LazyPanelFallback label="正在加载抓大鹅结果..." />}
|
fallback={<LazyPanelFallback label="正在加载抓大鹅结果..." />}
|
||||||
>
|
>
|
||||||
<Match3DDraftReadyView
|
<Match3DResultView
|
||||||
session={match3dSession}
|
profile={
|
||||||
|
match3dProfile ?? buildMatch3DProfileFromSession(match3dSession)!
|
||||||
|
}
|
||||||
|
draft={match3dSession.draft}
|
||||||
isBusy={isMatch3DBusy}
|
isBusy={isMatch3DBusy}
|
||||||
error={match3dError}
|
error={match3dError}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setSelectionStage('match3d-agent-workspace');
|
setSelectionStage('match3d-agent-workspace');
|
||||||
}}
|
}}
|
||||||
|
onSaved={(profile) => {
|
||||||
|
setMatch3DProfile(profile);
|
||||||
|
}}
|
||||||
|
onPublished={(profile) => {
|
||||||
|
setMatch3DProfile(profile);
|
||||||
|
void Promise.allSettled([
|
||||||
|
refreshMatch3DShelf(),
|
||||||
|
refreshMatch3DGallery(),
|
||||||
|
]);
|
||||||
|
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(profile));
|
||||||
|
}}
|
||||||
|
onStartTestRun={(profile) => {
|
||||||
|
setMatch3DProfile(profile);
|
||||||
|
void startMatch3DRunFromProfile(profile, 'match3d-result');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectionStage === 'match3d-runtime' && (
|
||||||
|
<motion.div
|
||||||
|
key="match3d-runtime"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[100]"
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={<LazyPanelFallback label="正在加载抓大鹅玩法..." />}
|
||||||
|
>
|
||||||
|
<Match3DRuntimeShell
|
||||||
|
run={match3dRun}
|
||||||
|
isBusy={isMatch3DBusy}
|
||||||
|
error={match3dError}
|
||||||
|
onBack={() => {
|
||||||
|
if (match3dRun?.runId && match3dRun.status === 'running') {
|
||||||
|
void stopMatch3DRun(match3dRun.runId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
setSelectionStage(match3dRuntimeReturnStage);
|
||||||
|
}}
|
||||||
|
onRestart={() => {
|
||||||
|
if (!match3dRun?.runId || isMatch3DBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match3dFlow.setIsBusy(true);
|
||||||
|
setMatch3DError(null);
|
||||||
|
void restartMatch3DRun(match3dRun.runId)
|
||||||
|
.then(({ run }) => {
|
||||||
|
setMatch3DRun(run);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setMatch3DError(
|
||||||
|
resolveMatch3DErrorMessage(
|
||||||
|
error,
|
||||||
|
'重新开始抓大鹅玩法失败。',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
match3dFlow.setIsBusy(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onOptimisticRunChange={setMatch3DRun}
|
||||||
|
onClickItem={(payload) => {
|
||||||
|
const runId = payload.runId ?? match3dRun?.runId;
|
||||||
|
if (!runId) {
|
||||||
|
return Promise.reject(new Error('抓大鹅运行态缺少 runId。'));
|
||||||
|
}
|
||||||
|
return clickMatch3DItem(runId, payload);
|
||||||
|
}}
|
||||||
|
onTimeExpired={() => {
|
||||||
|
if (!match3dRun?.runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void finishMatch3DTimeUp(match3dRun.runId)
|
||||||
|
.then(({ run }) => {
|
||||||
|
setMatch3DRun(run);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setMatch3DError(
|
||||||
|
resolveMatch3DErrorMessage(
|
||||||
|
error,
|
||||||
|
'同步抓大鹅倒计时失败。',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
|||||||
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
||||||
return '大鱼吃小鱼';
|
return '大鱼吃小鱼';
|
||||||
}
|
}
|
||||||
|
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
||||||
|
return '抓大鹅';
|
||||||
|
}
|
||||||
return 'RPG';
|
return 'RPG';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type SelectionStage =
|
|||||||
| 'big-fish-runtime'
|
| 'big-fish-runtime'
|
||||||
| 'match3d-agent-workspace'
|
| 'match3d-agent-workspace'
|
||||||
| 'match3d-result'
|
| 'match3d-result'
|
||||||
|
| 'match3d-runtime'
|
||||||
| 'puzzle-agent-workspace'
|
| 'puzzle-agent-workspace'
|
||||||
| 'puzzle-generating'
|
| 'puzzle-generating'
|
||||||
| 'puzzle-result'
|
| 'puzzle-result'
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
CustomWorldAgentSessionSnapshot,
|
CustomWorldAgentSessionSnapshot,
|
||||||
CustomWorldWorkSummary,
|
CustomWorldWorkSummary,
|
||||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
} 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 { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||||
@@ -25,8 +27,25 @@ import {
|
|||||||
getBigFishCreationSession,
|
getBigFishCreationSession,
|
||||||
} from '../../services/big-fish-creation';
|
} from '../../services/big-fish-creation';
|
||||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||||
import { startLocalBigFishRuntimeRun } from '../../services/big-fish-runtime';
|
import {
|
||||||
|
recordBigFishPlay,
|
||||||
|
startLocalBigFishRuntimeRun,
|
||||||
|
} from '../../services/big-fish-runtime';
|
||||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
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 {
|
import {
|
||||||
createPuzzleAgentSession,
|
createPuzzleAgentSession,
|
||||||
getPuzzleAgentSession,
|
getPuzzleAgentSession,
|
||||||
@@ -233,9 +252,34 @@ vi.mock('../../services/big-fish-gallery', () => ({
|
|||||||
|
|
||||||
vi.mock('../../services/big-fish-runtime', () => ({
|
vi.mock('../../services/big-fish-runtime', () => ({
|
||||||
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
|
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
|
||||||
|
recordBigFishPlay: vi.fn(() => Promise.resolve()),
|
||||||
startLocalBigFishRuntimeRun: vi.fn(),
|
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 () => {
|
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
|
||||||
const actual = await vi.importActual<
|
const actual = await vi.importActual<
|
||||||
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
|
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
|
||||||
@@ -389,6 +433,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', () => ({
|
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||||||
CustomWorldAgentWorkspace: ({
|
CustomWorldAgentWorkspace: ({
|
||||||
session,
|
session,
|
||||||
@@ -589,6 +650,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(
|
function buildMockRpgGalleryDetail(
|
||||||
entry: CustomWorldGalleryCard,
|
entry: CustomWorldGalleryCard,
|
||||||
): CustomWorldLibraryEntry {
|
): CustomWorldLibraryEntry {
|
||||||
@@ -1030,6 +1111,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||||
|
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
|
||||||
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
|
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
|
||||||
async (ownerUserId, profileId) => ({
|
async (ownerUserId, profileId) => ({
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
@@ -1474,6 +1556,43 @@ beforeEach(() => {
|
|||||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
|
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({
|
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
|
||||||
runId: 'big-fish-run-1',
|
runId: 'big-fish-run-1',
|
||||||
sessionId: 'big-fish-session-public-1',
|
sessionId: 'big-fish-session-public-1',
|
||||||
@@ -2369,7 +2488,6 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
|||||||
|
|
||||||
const button = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
const button = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -2378,9 +2496,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
|||||||
).length,
|
).length,
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
expect(
|
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
|
||||||
screen.getByRole('button', { name: '生成草稿' }) as HTMLButtonElement,
|
|
||||||
).toHaveProperty('disabled', false);
|
|
||||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2585,7 +2701,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
|||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
const searchInput = await screen.findByPlaceholderText(
|
const searchInput = await screen.findByPlaceholderText(
|
||||||
'输入 SY / CW / BF / PZ 编号',
|
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||||
);
|
);
|
||||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||||
@@ -2594,6 +2710,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(startPuzzleRun).toHaveBeenCalledWith({
|
expect(startPuzzleRun).toHaveBeenCalledWith({
|
||||||
profileId: 'puzzle-profile-public-1',
|
profileId: 'puzzle-profile-public-1',
|
||||||
|
levelId: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2657,7 +2774,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
|||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
const searchInput = await screen.findByPlaceholderText(
|
const searchInput = await screen.findByPlaceholderText(
|
||||||
'输入 SY / CW / BF / PZ 编号',
|
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||||
);
|
);
|
||||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||||
@@ -2700,7 +2817,7 @@ test('public code search opens a published big fish work by BF code', async () =
|
|||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
const searchInput = await screen.findByPlaceholderText(
|
const searchInput = await screen.findByPlaceholderText(
|
||||||
'输入 SY / CW / BF / PZ 编号',
|
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||||
);
|
);
|
||||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||||
@@ -2722,6 +2839,56 @@ test('public code search opens a published big fish work by BF code', async () =
|
|||||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
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 () => {
|
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ test('mobile home search submits public work code', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText(
|
const searchInput = screen.getByPlaceholderText(
|
||||||
'输入 SY / CW / BF / PZ 编号',
|
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||||
);
|
);
|
||||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
formatPlatformWorkDisplayTag,
|
formatPlatformWorkDisplayTag,
|
||||||
formatPlatformWorldTime,
|
formatPlatformWorldTime,
|
||||||
isBigFishGalleryEntry,
|
isBigFishGalleryEntry,
|
||||||
|
isMatch3DGalleryEntry,
|
||||||
isPuzzleGalleryEntry,
|
isPuzzleGalleryEntry,
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
type PlatformWorldCardLike,
|
type PlatformWorldCardLike,
|
||||||
@@ -303,7 +304,7 @@ function PublicCodeSearchBar({
|
|||||||
onSubmit();
|
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)]"
|
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -1020,7 +1021,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
|||||||
? 'big-fish'
|
? 'big-fish'
|
||||||
: isPuzzleGalleryEntry(entry)
|
: isPuzzleGalleryEntry(entry)
|
||||||
? 'puzzle'
|
? 'puzzle'
|
||||||
: 'rpg';
|
: isMatch3DGalleryEntry(entry)
|
||||||
|
? 'match3d'
|
||||||
|
: 'rpg';
|
||||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,7 +1032,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
|||||||
? '大鱼'
|
? '大鱼'
|
||||||
: isPuzzleGalleryEntry(entry)
|
: isPuzzleGalleryEntry(entry)
|
||||||
? '拼图'
|
? '拼图'
|
||||||
: describePlatformThemeLabel(entry.themeMode);
|
: isMatch3DGalleryEntry(entry)
|
||||||
|
? '抓鹅'
|
||||||
|
: describePlatformThemeLabel(entry.themeMode);
|
||||||
return formatPlatformWorkDisplayTag(kind);
|
return formatPlatformWorkDisplayTag(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
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 { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type {
|
import type {
|
||||||
@@ -9,6 +10,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
|
|||||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||||
import {
|
import {
|
||||||
buildBigFishPublicWorkCode,
|
buildBigFishPublicWorkCode,
|
||||||
|
buildMatch3DPublicWorkCode,
|
||||||
buildPuzzlePublicWorkCode,
|
buildPuzzlePublicWorkCode,
|
||||||
} from '../../services/publicWorkCode';
|
} from '../../services/publicWorkCode';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
@@ -20,6 +22,7 @@ export type PlatformWorldCardLike =
|
|||||||
| CustomWorldGalleryCard
|
| CustomWorldGalleryCard
|
||||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||||
| PlatformBigFishGalleryCard
|
| PlatformBigFishGalleryCard
|
||||||
|
| PlatformMatch3DGalleryCard
|
||||||
| PlatformPuzzleGalleryCard;
|
| PlatformPuzzleGalleryCard;
|
||||||
|
|
||||||
export type PlatformPuzzleGalleryCard = {
|
export type PlatformPuzzleGalleryCard = {
|
||||||
@@ -71,9 +74,31 @@ export type PlatformBigFishGalleryCard = {
|
|||||||
updatedAt: string;
|
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 =
|
export type PlatformPublicGalleryCard =
|
||||||
| CustomWorldGalleryCard
|
| CustomWorldGalleryCard
|
||||||
| PlatformBigFishGalleryCard
|
| PlatformBigFishGalleryCard
|
||||||
|
| PlatformMatch3DGalleryCard
|
||||||
| PlatformPuzzleGalleryCard;
|
| PlatformPuzzleGalleryCard;
|
||||||
|
|
||||||
export function isLibraryWorldEntry(
|
export function isLibraryWorldEntry(
|
||||||
@@ -94,6 +119,12 @@ export function isBigFishGalleryEntry(
|
|||||||
return 'sourceType' in entry && entry.sourceType === 'big-fish';
|
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(
|
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||||
work: PuzzleWorkSummary,
|
work: PuzzleWorkSummary,
|
||||||
): PlatformPuzzleGalleryCard {
|
): 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(
|
export function mapBigFishWorkToPlatformGalleryCard(
|
||||||
work: BigFishWorkSummary,
|
work: BigFishWorkSummary,
|
||||||
): PlatformBigFishGalleryCard {
|
): PlatformBigFishGalleryCard {
|
||||||
@@ -307,6 +363,10 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
|||||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
|
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)) {
|
if (!isLibraryWorldEntry(entry)) {
|
||||||
return [
|
return [
|
||||||
describePlatformThemeLabel(entry.themeMode),
|
describePlatformThemeLabel(entry.themeMode),
|
||||||
@@ -381,6 +441,10 @@ export function resolvePlatformPublicWorkCode(
|
|||||||
return entry.publicWorkCode;
|
return entry.publicWorkCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMatch3DGalleryEntry(entry)) {
|
||||||
|
return entry.publicWorkCode;
|
||||||
|
}
|
||||||
|
|
||||||
return entry.publicWorkCode;
|
return entry.publicWorkCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ describe('appPageRoutes', () => {
|
|||||||
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
|
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
|
||||||
'big-fish-result',
|
'big-fish-result',
|
||||||
);
|
);
|
||||||
|
expect(resolveSelectionStageFromPath('/creation/match3d/result')).toBe(
|
||||||
|
'match3d-result',
|
||||||
|
);
|
||||||
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
|
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
|
||||||
'puzzle-gallery-detail',
|
'puzzle-gallery-detail',
|
||||||
);
|
);
|
||||||
@@ -68,5 +71,8 @@ describe('appPageRoutes', () => {
|
|||||||
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
|
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
|
||||||
'/runtime/big-fish?work=BF-00000003',
|
'/runtime/big-fish?work=BF-00000003',
|
||||||
);
|
);
|
||||||
|
expect(buildPublicWorkStagePath('match3d-runtime', 'M3-00000004')).toBe(
|
||||||
|
'/runtime/match3d?work=M3-00000004',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const STAGE_ROUTE_ENTRIES = [
|
|||||||
['big-fish-agent-workspace', '/creation/big-fish/agent'],
|
['big-fish-agent-workspace', '/creation/big-fish/agent'],
|
||||||
['big-fish-result', '/creation/big-fish/result'],
|
['big-fish-result', '/creation/big-fish/result'],
|
||||||
['big-fish-runtime', '/runtime/big-fish'],
|
['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-agent-workspace', '/creation/puzzle/agent'],
|
||||||
['puzzle-result', '/creation/puzzle/result'],
|
['puzzle-result', '/creation/puzzle/result'],
|
||||||
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
|
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
|
||||||
|
|||||||
@@ -2,360 +2,87 @@ import type {
|
|||||||
CreateMatch3DSessionRequest,
|
CreateMatch3DSessionRequest,
|
||||||
ExecuteMatch3DActionRequest,
|
ExecuteMatch3DActionRequest,
|
||||||
Match3DActionResponse,
|
Match3DActionResponse,
|
||||||
Match3DAgentMessageResponse,
|
|
||||||
Match3DAgentSessionSnapshot,
|
Match3DAgentSessionSnapshot,
|
||||||
Match3DAnchorItemResponse,
|
|
||||||
Match3DCreatorConfig,
|
|
||||||
Match3DSessionResponse,
|
Match3DSessionResponse,
|
||||||
SendMatch3DMessageRequest,
|
SendMatch3DMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||||
import type { TextStreamOptions } from '../aiTypes';
|
import type { TextStreamOptions } from '../aiTypes';
|
||||||
|
import { createCreationAgentClient } from '../creation-agent';
|
||||||
|
|
||||||
const MOCK_RESPONSE_DELAY_MS = 180;
|
const MATCH3D_AGENT_API_BASE = '/api/creation/match3d/sessions';
|
||||||
const MATCH3D_SESSION_PREFIX = 'match3d-session';
|
|
||||||
|
|
||||||
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
|
const match3dAgentHttpClient = createCreationAgentClient<
|
||||||
themeText: '缤纷玩具',
|
CreateMatch3DSessionRequest,
|
||||||
clearCount: 12,
|
Match3DSessionResponse,
|
||||||
difficulty: 4,
|
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>();
|
* 创建抓大鹅 Agent 共创会话。
|
||||||
|
* Q1 起前端只走 Axum facade,避免本地 mock 成为创作真相源。
|
||||||
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
|
*/
|
||||||
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, ms));
|
export function createMatch3DCreationSession(
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
payload: CreateMatch3DSessionRequest = {},
|
payload: CreateMatch3DSessionRequest = {},
|
||||||
): Promise<Match3DSessionResponse> {
|
) {
|
||||||
await delay();
|
return match3dAgentHttpClient.createSession(payload);
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMatch3DCreationSession(sessionId: string) {
|
/**
|
||||||
await delay(80);
|
* 读取抓大鹅 Agent 会话快照。
|
||||||
return { session: ensureMockSession(sessionId) };
|
*/
|
||||||
|
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,
|
sessionId: string,
|
||||||
payload: SendMatch3DMessageRequest,
|
payload: SendMatch3DMessageRequest,
|
||||||
options: TextStreamOptions = {},
|
options: TextStreamOptions = {},
|
||||||
): Promise<Match3DAgentSessionSnapshot> {
|
) {
|
||||||
await delay(120);
|
return match3dAgentHttpClient.streamMessage(sessionId, payload, options);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeMatch3DCreationAction(
|
/**
|
||||||
|
* 执行抓大鹅创作操作,例如生成草稿作品。
|
||||||
|
*/
|
||||||
|
export function executeMatch3DCreationAction(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
payload: ExecuteMatch3DActionRequest,
|
payload: ExecuteMatch3DActionRequest,
|
||||||
): Promise<Match3DActionResponse> {
|
) {
|
||||||
await delay(220);
|
return match3dAgentHttpClient.executeAction(sessionId, payload);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const match3dCreationClient = {
|
export const match3dCreationClient = {
|
||||||
createSession: createMatch3DCreationSession,
|
createSession: createMatch3DCreationSession,
|
||||||
getSession: getMatch3DCreationSession,
|
getSession: getMatch3DCreationSession,
|
||||||
|
sendMessage: sendMatch3DCreationMessage,
|
||||||
streamMessage: streamMatch3DCreationMessage,
|
streamMessage: streamMatch3DCreationMessage,
|
||||||
executeAction: executeMatch3DCreationAction,
|
executeAction: executeMatch3DCreationAction,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ export {
|
|||||||
startLocalMatch3DRun,
|
startLocalMatch3DRun,
|
||||||
stopLocalMatch3DRun,
|
stopLocalMatch3DRun,
|
||||||
} from './match3dLocalRuntime';
|
} from './match3dLocalRuntime';
|
||||||
|
export {
|
||||||
|
clickMatch3DItem,
|
||||||
|
finishMatch3DTimeUp,
|
||||||
|
getMatch3DRun,
|
||||||
|
match3dRuntimeClient,
|
||||||
|
restartMatch3DRun,
|
||||||
|
startMatch3DRun,
|
||||||
|
stopMatch3DRun,
|
||||||
|
} from './match3dRuntimeClient';
|
||||||
|
|||||||
162
src/services/match3d-runtime/match3dRuntimeClient.ts
Normal file
162
src/services/match3d-runtime/match3dRuntimeClient.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import type {
|
||||||
|
Match3DClickConfirmation,
|
||||||
|
Match3DClickItemRequest,
|
||||||
|
Match3DClickItemResult,
|
||||||
|
Match3DClickRejectReason,
|
||||||
|
Match3DClickResponse,
|
||||||
|
Match3DRunResponse,
|
||||||
|
StopMatch3DRunRequest,
|
||||||
|
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||||
|
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||||
|
|
||||||
|
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||||
|
maxRetries: 1,
|
||||||
|
baseDelayMs: 120,
|
||||||
|
maxDelayMs: 360,
|
||||||
|
};
|
||||||
|
const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||||
|
maxRetries: 1,
|
||||||
|
baseDelayMs: 120,
|
||||||
|
maxDelayMs: 360,
|
||||||
|
retryUnsafeMethods: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||||
|
switch (reason) {
|
||||||
|
case 'snapshot_version_mismatch':
|
||||||
|
return 'VersionConflict';
|
||||||
|
case 'tray_full':
|
||||||
|
return 'RejectedTrayFull';
|
||||||
|
case 'run_not_active':
|
||||||
|
return 'RunFinished';
|
||||||
|
case 'item_not_found':
|
||||||
|
case 'item_not_in_board':
|
||||||
|
return 'RejectedAlreadyMoved';
|
||||||
|
case 'item_not_clickable':
|
||||||
|
default:
|
||||||
|
return 'RejectedNotClickable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapClickConfirmation(
|
||||||
|
request: Match3DClickItemRequest,
|
||||||
|
confirmation: Match3DClickConfirmation,
|
||||||
|
): Match3DClickItemResult {
|
||||||
|
return {
|
||||||
|
status: confirmation.accepted
|
||||||
|
? 'Accepted'
|
||||||
|
: normalizeRejectStatus(confirmation.rejectReason),
|
||||||
|
run: confirmation.run,
|
||||||
|
acceptedItemInstanceId: confirmation.accepted
|
||||||
|
? request.itemInstanceId
|
||||||
|
: undefined,
|
||||||
|
clearedItemInstanceIds: confirmation.clearedItemInstanceIds,
|
||||||
|
failureReason: confirmation.run.failureReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于作品启动一局抓大鹅正式 run。
|
||||||
|
*/
|
||||||
|
export function startMatch3DRun(profileId: string) {
|
||||||
|
return requestJson<Match3DRunResponse>(
|
||||||
|
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ profileId }),
|
||||||
|
},
|
||||||
|
'启动抓大鹅玩法失败',
|
||||||
|
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取抓大鹅运行态快照。
|
||||||
|
*/
|
||||||
|
export function getMatch3DRun(runId: string) {
|
||||||
|
return requestJson<Match3DRunResponse>(
|
||||||
|
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
'读取抓大鹅运行快照失败',
|
||||||
|
{ retry: MATCH3D_RUNTIME_READ_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交一次点击,由后端做权威确认;返回值适配运行壳已实现的即时反馈语义。
|
||||||
|
*/
|
||||||
|
export async function clickMatch3DItem(
|
||||||
|
runId: string,
|
||||||
|
payload: Match3DClickItemRequest,
|
||||||
|
) {
|
||||||
|
const response = await requestJson<Match3DClickResponse>(
|
||||||
|
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
runId: payload.runId ?? runId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'确认抓大鹅点击失败',
|
||||||
|
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapClickConfirmation(payload, response.confirmation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止当前抓大鹅运行态。
|
||||||
|
*/
|
||||||
|
export function stopMatch3DRun(
|
||||||
|
runId: string,
|
||||||
|
payload: StopMatch3DRunRequest = {
|
||||||
|
clientActionId: `match3d-stop-${Date.now()}`,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return requestJson<Match3DRunResponse>(
|
||||||
|
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
'停止抓大鹅玩法失败',
|
||||||
|
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于当前 run 重开一局。
|
||||||
|
*/
|
||||||
|
export function restartMatch3DRun(runId: string) {
|
||||||
|
return requestJson<Match3DRunResponse>(
|
||||||
|
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
'重新开始抓大鹅玩法失败',
|
||||||
|
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端倒计时归零后通知后端确认失败状态。
|
||||||
|
*/
|
||||||
|
export function finishMatch3DTimeUp(runId: string) {
|
||||||
|
return requestJson<Match3DRunResponse>(
|
||||||
|
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
'同步抓大鹅倒计时失败',
|
||||||
|
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const match3dRuntimeClient = {
|
||||||
|
clickItem: clickMatch3DItem,
|
||||||
|
finishTimeUp: finishMatch3DTimeUp,
|
||||||
|
getRun: getMatch3DRun,
|
||||||
|
restartRun: restartMatch3DRun,
|
||||||
|
startRun: startMatch3DRun,
|
||||||
|
stopRun: stopMatch3DRun,
|
||||||
|
};
|
||||||
9
src/services/match3d-works/index.ts
Normal file
9
src/services/match3d-works/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
deleteMatch3DWork,
|
||||||
|
getMatch3DWorkDetail,
|
||||||
|
listMatch3DGallery,
|
||||||
|
listMatch3DWorks,
|
||||||
|
match3dWorksClient,
|
||||||
|
publishMatch3DWork,
|
||||||
|
updateMatch3DWork,
|
||||||
|
} from './match3dWorksClient';
|
||||||
113
src/services/match3d-works/match3dWorksClient.ts
Normal file
113
src/services/match3d-works/match3dWorksClient.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type {
|
||||||
|
Match3DWorkDetailResponse,
|
||||||
|
Match3DWorkMutationResponse,
|
||||||
|
Match3DWorksResponse,
|
||||||
|
PutMatch3DWorkRequest,
|
||||||
|
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||||
|
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||||
|
|
||||||
|
const MATCH3D_WORKS_API_BASE = '/api/creation/match3d/works';
|
||||||
|
const MATCH3D_GALLERY_API_BASE = '/api/runtime/match3d/gallery';
|
||||||
|
const MATCH3D_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||||
|
maxRetries: 1,
|
||||||
|
baseDelayMs: 120,
|
||||||
|
maxDelayMs: 360,
|
||||||
|
};
|
||||||
|
const MATCH3D_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||||
|
maxRetries: 1,
|
||||||
|
baseDelayMs: 120,
|
||||||
|
maxDelayMs: 360,
|
||||||
|
retryUnsafeMethods: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前用户的抓大鹅作品列表。
|
||||||
|
*/
|
||||||
|
export function listMatch3DWorks() {
|
||||||
|
return requestJson<Match3DWorksResponse>(
|
||||||
|
MATCH3D_WORKS_API_BASE,
|
||||||
|
{ method: 'GET' },
|
||||||
|
'读取抓大鹅作品列表失败',
|
||||||
|
{ retry: MATCH3D_WORKS_READ_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取公开抓大鹅作品列表。
|
||||||
|
*/
|
||||||
|
export function listMatch3DGallery() {
|
||||||
|
return requestJson<Match3DWorksResponse>(
|
||||||
|
MATCH3D_GALLERY_API_BASE,
|
||||||
|
{ method: 'GET' },
|
||||||
|
'读取抓大鹅广场失败',
|
||||||
|
{
|
||||||
|
retry: MATCH3D_WORKS_READ_RETRY,
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取抓大鹅作品详情。
|
||||||
|
*/
|
||||||
|
export function getMatch3DWorkDetail(profileId: string) {
|
||||||
|
return requestJson<Match3DWorkDetailResponse>(
|
||||||
|
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
'读取抓大鹅作品详情失败',
|
||||||
|
{ retry: MATCH3D_WORKS_READ_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存结果页可编辑字段。
|
||||||
|
*/
|
||||||
|
export function updateMatch3DWork(
|
||||||
|
profileId: string,
|
||||||
|
payload: PutMatch3DWorkRequest,
|
||||||
|
) {
|
||||||
|
return requestJson<Match3DWorkMutationResponse>(
|
||||||
|
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
'更新抓大鹅作品失败',
|
||||||
|
{ retry: MATCH3D_WORKS_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布抓大鹅作品。发布门槛由后端最终确认。
|
||||||
|
*/
|
||||||
|
export function publishMatch3DWork(profileId: string) {
|
||||||
|
return requestJson<Match3DWorkMutationResponse>(
|
||||||
|
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
'发布抓大鹅作品失败',
|
||||||
|
{ retry: MATCH3D_WORKS_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除当前用户的抓大鹅作品,并返回删除后的列表。
|
||||||
|
*/
|
||||||
|
export function deleteMatch3DWork(profileId: string) {
|
||||||
|
return requestJson<Match3DWorksResponse>(
|
||||||
|
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
'删除抓大鹅作品失败',
|
||||||
|
{ retry: MATCH3D_WORKS_WRITE_RETRY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const match3dWorksClient = {
|
||||||
|
delete: deleteMatch3DWork,
|
||||||
|
getDetail: getMatch3DWorkDetail,
|
||||||
|
listGallery: listMatch3DGallery,
|
||||||
|
list: listMatch3DWorks,
|
||||||
|
publish: publishMatch3DWork,
|
||||||
|
update: updateMatch3DWork,
|
||||||
|
};
|
||||||
@@ -21,6 +21,14 @@ export function buildBigFishPublicWorkCode(sessionId: string) {
|
|||||||
return `BF-${suffix}`;
|
return `BF-${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildMatch3DPublicWorkCode(profileId: string) {
|
||||||
|
const normalized = normalizePublicCodeText(profileId);
|
||||||
|
const fallback = normalized || '00000000';
|
||||||
|
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||||
|
|
||||||
|
return `M3-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||||
|
|
||||||
@@ -43,3 +51,13 @@ export function isSameBigFishPublicWorkCode(
|
|||||||
normalizedKeyword === normalizePublicCodeText(sessionId)
|
normalizedKeyword === normalizePublicCodeText(sessionId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string) {
|
||||||
|
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedKeyword ===
|
||||||
|
normalizePublicCodeText(buildMatch3DPublicWorkCode(profileId)) ||
|
||||||
|
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user