feat: unify phase one creation flow

This commit is contained in:
2026-05-30 05:05:02 +08:00
parent 3a87b2d966
commit 26975644b5
33 changed files with 2037 additions and 539 deletions

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ temp*build*/
/public/generated-character-drafts
/public/generated-characters
/.codex-temp
/.app/
/target/
/logs
/server-rs/crates/*/logs/

View File

@@ -57,6 +57,8 @@ npm run dev
- 主站 Vite
- 后台 Vite
`npm run dev` 和单模块 `dev:*` 命令会更新根目录 `.app/dev-stack.json`,记录四个本地服务的 pid、端口、URL、启动状态和当前命令。该目录只作本机运行态观测不提交 Git。
开启自动刷新:
```bash
@@ -240,6 +242,7 @@ npm run check:server-rs-ddd
- 移动端优先,再兼容网页端。
- 页面只展示后端返回的状态,不自行计算结论型业务状态。
- 创作中心入口配置事实源在 SpacetimeDB通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。
- 一期统一创作页字段 spec 同样跟随 `GET /api/creation-entry/config`,由 `creationTypes[].unifiedCreationSpec` 下发;拼图、抓大鹅、敲木鱼之外的模板不接入该扩展位,前端只保留旧后端缺字段时的兜底默认。
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
- 不在 UI 中默认写功能说明类文本。
- 弹出独立面板的交互不要实现成在当前面板下方追加内容。
@@ -253,6 +256,8 @@ npm run check:server-rs-ddd
## 提交前建议让 Hermes 执行
涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路或本地 dev 栈时,先按 `quality-gates/README.md` 和对应门禁文档执行自动脚本与体验检查。
```text
请检查当前 git diff指出
1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定;

View File

@@ -31,6 +31,8 @@ npm run dev
- 主站 Vite。
- 后台 Vite。
`npm run dev` 和单模块 `npm run dev:web``npm run dev:api-server``npm run dev:spacetime``npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime``api-server``web``admin-web``pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。
单独启动主站前端:
```bash
@@ -90,6 +92,8 @@ npm run build
npm run check:content
```
一期创作流程统一化新增 `quality-gates/` 提交前门禁。涉及拼图、抓大鹅、敲木鱼统一创作页、统一生成页或 dev 栈启动脚本时,先执行 `quality-gates/README.md` 列出的脚本,再按对应门禁文档完成体验检查。
综合检查:
```bash

View File

@@ -8,7 +8,9 @@
当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡点击模板卡后直接进入对应玩法已有的入口创作表单 stage不再经过空白占位页也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛``抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px``14px`,不使用 `text-lg``text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace``big-fish-agent-workspace``match3d-agent-workspace``square-hole-agent-workspace``jump-hop-workspace``wooden-fish-workspace``puzzle-agent-workspace``bark-battle-workspace``visual-novel-agent-workspace``baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页恢复时只认当前进入页的时间作为新`startedAtMs`,作品摘要里的 `updatedAt` 只用于排序与摘要展示,不作为生成进度起点
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里`startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 只用于排序与摘要展示,不作为前端自行推导业务状态的真相
一期创作流程统一化只覆盖拼图、抓大鹅和敲木鱼。三者在前端统一经过 `UnifiedCreationPage``UnifiedGenerationPage`:创作页字段清单由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec首期字段类型只保留 `text``select``image``audio`。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。视觉小说、`airp``component`、汪汪声浪、方洞、大鱼、跳一跳和宝贝识物不进入一期接线范围,已有链路保持现状。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。

18
quality-gates/README.md Normal file
View File

@@ -0,0 +1,18 @@
# 提交前质量门禁
本目录记录一期创作流程统一化提交前必须执行的检查。门禁既可以是自动脚本,也可以是需要人工确认的体验流程。
## 一期必跑
- [【玩法创作】统一创作页门禁-2026-05-29.md](./【玩法创作】统一创作页门禁-2026-05-29.md)
- [【玩法创作】统一生成页门禁-2026-05-29.md](./【玩法创作】统一生成页门禁-2026-05-29.md)
- [【开发运维】dev-stack状态文件门禁-2026-05-29.md](./【开发运维】dev-stack状态文件门禁-2026-05-29.md)
## 基线脚本
```bash
npm run check:encoding
npm run test -- src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedGenerationPage.test.tsx
npm run test -- scripts/dev.test.ts
node --check scripts/dev.mjs
```

View File

@@ -0,0 +1,16 @@
# dev-stack 状态文件门禁
## 自动检查
```bash
node --check scripts/dev.mjs
npm run test -- scripts/dev.test.ts
```
## 体验检查
- `npm run dev` 启动后根目录生成 `.app/dev-stack.json`
- `npm run dev:web``npm run dev:api-server``npm run dev:spacetime``npm run dev:admin-web` 单独启动时会更新对应服务状态。
- 文件包含 `schemaVersion``command``repoRoot``database``updatedAt` 和四个服务条目。
- 每个服务条目包含 `status``pid``host``port``url``command``updatedAt`
- 端口漂移后文件记录实际端口;服务退出或重启后 PID 与状态会更新。

View File

@@ -0,0 +1,16 @@
# 统一创作页门禁
## 自动检查
```bash
npm run test -- src/components/unified-creation/unifiedCreationSpecs.test.ts
npm run test -- src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx
```
## 体验检查
- 拼图、抓大鹅、敲木鱼均从 `/creation/<playId>` 进入创作工作台。
- 三条链路都经过 `UnifiedCreationPage`,字段 spec 只包含 `text``select``image``audio` 四类。
- 拼图参考图仍走 `CreativeImageInputPanel`,不新增专属上传入口。
- 敲木鱼音频槽位走 `CreativeAudioInputPanel`,上传、录音、重置和默认音效状态可用。
- 抓大鹅难度只显示轻松、标准、进阶、硬核四档,提交 payload 仍派生 `clearCount``difficulty`

View File

@@ -0,0 +1,16 @@
# 统一生成页门禁
## 自动检查
```bash
npm run test -- src/components/unified-creation/UnifiedGenerationPage.test.tsx
npm run test -- src/services/miniGameDraftGenerationProgress.test.ts
```
## 体验检查
- `/creation/puzzle/generating``/creation/match3d/generating``/creation/wooden-fish/generating` 均渲染 `UnifiedGenerationPage`
- 生成页展示后端阶段、当前步骤、总进度、错误和重试动作。
- 等待时间以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 时间戳。
- 失败后生成页保持可见错误和重试入口,作品架恢复时不得只依赖前端内存 notice。
- 不为一期新增客户端直连 SpacetimeDB 状态通道。

View File

@@ -26,6 +26,7 @@ import {
} from './dev-utils.mjs';
const repoRoot = process.cwd();
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const serverRsDir = resolve(repoRoot, 'server-rs');
const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
@@ -245,6 +246,136 @@ function normalizeServiceName(rawName) {
throw new Error(`未知模块: ${rawName}`);
}
function resolveDevStackStatePath(root = repoRoot) {
return resolve(root, '.app/dev-stack.json');
}
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
const services = {};
for (const serviceName of SERVICE_NAMES) {
services[serviceName] = buildDevStackServiceSnapshot(
runner,
serviceName,
updatedAt,
);
}
return {
schemaVersion: 1,
command: runner.command ?? 'all',
repoRoot,
database: runner.options.database,
watch: Boolean(runner.options.watch),
updatedAt,
services,
};
}
function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) {
const service = runner.services.get(serviceName);
const runtime = service?.runtime ?? {};
const endpoint = resolveDevStackServiceEndpoint(runner, serviceName);
const isReusedSpacetime =
serviceName === 'spacetime' && runner.state.spacetimeReused;
const runtimeStatus = runtime.status ?? 'idle';
const status =
runtimeStatus === 'idle' && isReusedSpacetime ? 'reused' : runtimeStatus;
const childPid =
service?.child && Number.isInteger(service.child.pid)
? service.child.pid
: null;
return {
status,
pid:
childPid ??
runtime.pid ??
(isReusedSpacetime ? (runner.state.spacetimePid ?? null) : null),
host: runtime.host ?? endpoint.host,
port: runtime.port ?? endpoint.port,
url: runtime.url ?? endpoint.url,
command: runtime.command ?? resolveDevStackServiceCommand(runner, serviceName),
startedAt: runtime.startedAt ?? null,
updatedAt: runtime.updatedAt ?? updatedAt,
exitCode: runtime.exitCode ?? null,
signal: runtime.signal ?? null,
};
}
function resolveDevStackServiceEndpoint(runner, serviceName) {
const {options, state} = runner;
switch (serviceName) {
case 'spacetime':
return {
host: options.spacetimeHost,
port: options.spacetimePort,
url: state.spacetimeServer,
};
case 'api-server':
return {
host: options.apiHost,
port: options.apiPort,
url: state.apiTarget,
};
case 'web':
return {
host: options.webHost,
port: options.webPort,
url: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
};
case 'admin-web':
return {
host: options.adminWebHost,
port: options.adminWebPort,
url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`,
};
default:
return {
host: null,
port: null,
url: null,
};
}
}
function resolveDevStackServiceCommand(runner, serviceName) {
const {options, state} = runner;
switch (serviceName) {
case 'spacetime':
return state.spacetimeReused
? `reuse spacetime standalone ${state.spacetimeServer}`
: [
'spacetime',
'start',
'--data-dir',
options.spacetimeDataDir,
'--listen-addr',
`${options.spacetimeHost}:${options.spacetimePort}`,
'--non-interactive',
].join(' ');
case 'api-server':
return 'cargo run -p api-server --manifest-path server-rs/Cargo.toml';
case 'web':
return [
'node',
relative(repoRoot, viteCliPath),
`--port=${options.webPort}`,
`--host=${options.webHost}`,
'--strictPort',
].join(' ');
case 'admin-web':
return [
'node',
relative(adminWebDir, viteCliPath),
`--host=${options.adminWebHost}`,
`--port=${options.adminWebPort}`,
'--strictPort',
].join(' ');
default:
return null;
}
}
function requireCommand(command) {
const result = spawnSync(command, ['--version'], {
cwd: repoRoot,
@@ -374,14 +505,37 @@ function ensureRequiredFiles(command) {
}
class DevService {
constructor(name, startFn) {
constructor(name, startFn, onStateChange = null) {
this.name = name;
this.startFn = startFn;
this.onStateChange = onStateChange;
this.child = null;
this.children = [];
this.logStream = null;
this.stopping = false;
this.restartTimer = null;
this.runtime = {
status: 'idle',
pid: null,
host: null,
port: null,
url: null,
command: null,
startedAt: null,
updatedAt: null,
exitCode: null,
signal: null,
};
}
updateRuntimeState(patch) {
const updatedAt = new Date().toISOString();
this.runtime = {
...this.runtime,
...patch,
updatedAt,
};
this.onStateChange?.();
}
async start() {
@@ -389,17 +543,36 @@ class DevService {
return;
}
this.updateRuntimeState({status: 'starting', exitCode: null, signal: null});
try {
await this.startFn(this);
} catch (error) {
this.updateRuntimeState({status: 'failed', pid: null});
throw error;
}
}
registerChild(child) {
this.child = child;
this.updateRuntimeState({
status: 'running',
pid: Number.isInteger(child.pid) ? child.pid : null,
exitCode: null,
signal: null,
startedAt: this.runtime.startedAt ?? new Date().toISOString(),
});
child.on('exit', (code, signal) => {
if (this.logStream && !this.logStream.destroyed) {
this.logStream.end();
}
this.child = null;
this.updateRuntimeState({
status: this.stopping ? 'stopped' : code === 0 ? 'stopped' : 'failed',
pid: null,
exitCode: code ?? null,
signal: signal ?? null,
});
if (this.stopping) {
this.stopping = false;
return;
@@ -418,6 +591,9 @@ class DevService {
const processes = [this.child, ...this.children].filter(Boolean);
this.stopping = processes.length > 0;
if (processes.length > 0) {
this.updateRuntimeState({status: 'stopping'});
}
this.child = null;
this.children = [];
@@ -430,6 +606,9 @@ class DevService {
}
this.logStream = null;
this.stopping = false;
if (processes.length > 0) {
this.updateRuntimeState({status: 'stopped', pid: null});
}
}
scheduleRestart(delayMs = 250, restartFn = null, actionLabel = '重启') {
@@ -576,6 +755,7 @@ class DevRunner {
await this.resolvePorts(command);
this.registerServices();
this.printSummary(command);
this.writeDevStackState();
}
shouldValidateSpacetimeToolVersion(command) {
@@ -642,6 +822,9 @@ class DevRunner {
}
this.state.spacetimeServer = candidate;
this.state.spacetimeReused = true;
this.state.spacetimePid = Number.isInteger(pidState.pid)
? pidState.pid
: null;
const pidLabel = Number.isInteger(pidState.pid) ? ` pid=${pidState.pid}` : '';
console.log(`[dev:spacetime] 复用已启动实例${pidLabel}: ${candidate}`);
return;
@@ -727,19 +910,48 @@ class DevRunner {
}
registerServices() {
const onStateChange = () => this.writeDevStackState();
this.services.set(
'spacetime',
new DevService('spacetime', async (service) => this.startSpacetime(service)),
new DevService(
'spacetime',
async (service) => this.startSpacetime(service),
onStateChange,
),
);
this.services.set(
'api-server',
new DevService('api-server', async (service) => this.startApiServer(service)),
new DevService(
'api-server',
async (service) => this.startApiServer(service),
onStateChange,
),
);
this.services.set(
'web',
new DevService('web', async (service) => this.startWeb(service), onStateChange),
);
this.services.set('web', new DevService('web', async (service) => this.startWeb(service)));
this.services.set(
'admin-web',
new DevService('admin-web', async (service) => this.startAdminWeb(service)),
new DevService(
'admin-web',
async (service) => this.startAdminWeb(service),
onStateChange,
),
);
if (this.state.spacetimeReused) {
const spacetimeService = this.services.get('spacetime');
const endpoint = resolveDevStackServiceEndpoint(this, 'spacetime');
spacetimeService?.updateRuntimeState({
status: 'reused',
pid: this.state.spacetimePid ?? null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'spacetime'),
});
}
}
printSummary(command) {
@@ -754,6 +966,16 @@ class DevRunner {
console.log(`[dev] database: ${options.database}`);
}
writeDevStackState() {
try {
ensureParentDir(devStackStatePath);
const snapshot = buildDevStackSnapshot(this);
writeFileSync(devStackStatePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
} catch (error) {
console.warn(`[dev] 写入 ${devStackStatePath} 失败: ${error.message}`);
}
}
async startCommand(command) {
if (command === 'all') {
await this.startSpacetimeForFullStack();
@@ -827,6 +1049,17 @@ class DevRunner {
);
console.log(`[dev:spacetime] log: ${logFile}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: options.spacetimeHost,
port: options.spacetimePort,
url: this.state.spacetimeServer,
command: resolveDevStackServiceCommand(this, 'spacetime'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const env = {
...this.baseEnv,
};
@@ -880,6 +1113,11 @@ class DevRunner {
this.options.spacetimePort = port;
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${port}`;
recordSpacetimeUrl(this.options.spacetimeDataDir, this.state.spacetimeServer);
this.services.get('spacetime')?.updateRuntimeState({
host: this.options.spacetimeHost,
port,
url: this.state.spacetimeServer,
});
console.log(`[dev:spacetime] actual: ${this.state.spacetimeServer}`);
}
@@ -976,6 +1214,17 @@ class DevRunner {
console.log(
`[dev:api-server] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`,
);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: this.options.apiHost,
port: this.options.apiPort,
url: this.state.apiTarget,
command: resolveDevStackServiceCommand(this, 'api-server'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const child = spawn(
'cargo',
@@ -1018,6 +1267,7 @@ class DevRunner {
startWeb(service) {
const apiTarget = this.resolveFrontendApiTarget();
const endpoint = resolveDevStackServiceEndpoint(this, 'web');
const env = {
...this.baseEnv,
RUST_SERVER_TARGET: apiTarget,
@@ -1028,6 +1278,17 @@ class DevRunner {
};
console.log(`[dev:web] api target: ${apiTarget}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'web'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const child = spawn(
'node',
[
@@ -1053,6 +1314,7 @@ class DevRunner {
startAdminWeb(service) {
const apiTarget = this.resolveFrontendApiTarget({admin: true});
const endpoint = resolveDevStackServiceEndpoint(this, 'admin-web');
const env = {
...this.baseEnv,
ADMIN_API_TARGET: apiTarget,
@@ -1061,6 +1323,17 @@ class DevRunner {
};
console.log(`[dev:admin-web] api target: ${apiTarget}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'admin-web'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const child = spawn(
'node',
[
@@ -1731,12 +2004,14 @@ export {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
isSpacetimePublishPermissionError,
parseSpacetimeToolVersion,
parseArgs,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
};

View File

@@ -9,12 +9,14 @@ import {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
isSpacetimePublishPermissionError,
parseSpacetimeToolVersion,
parseArgs,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
} from './dev.mjs';
@@ -105,6 +107,79 @@ describe('dev scheduler api-server env', () => {
});
});
describe('dev scheduler stack state file', () => {
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
join('C:\\repo\\Genarrative', '.app/dev-stack.json'),
);
});
test('状态快照记录服务 pid、端口、URL 和当前命令', () => {
const updatedAt = '2026-05-29T00:00:00.000Z';
const runner = {
command: 'web',
options: {
apiHost: '127.0.0.1',
apiPort: 8090,
webHost: '0.0.0.0',
webPort: 3010,
adminWebHost: '127.0.0.1',
adminWebPort: 3110,
spacetimeHost: '127.0.0.1',
spacetimePort: 3120,
spacetimeDataDir: 'server-rs/.spacetimedb/local/data',
database: 'genarrative-test',
watch: false,
},
state: {
apiTarget: 'http://127.0.0.1:8090',
adminWebTargetHost: '127.0.0.1',
spacetimeServer: 'http://127.0.0.1:3120',
},
services: new Map([
[
'web',
{
child: {pid: 4321},
runtime: {
status: 'running',
pid: 4321,
host: '0.0.0.0',
port: 3010,
url: 'http://127.0.0.1:3010',
command: 'node scripts/vite-cli.mjs --port=3010',
startedAt: updatedAt,
updatedAt,
exitCode: null,
signal: null,
},
},
],
]),
};
const snapshot = buildDevStackSnapshot(runner, updatedAt);
expect(snapshot.schemaVersion).toBe(1);
expect(snapshot.command).toBe('web');
expect(snapshot.database).toBe('genarrative-test');
expect(snapshot.services.web).toMatchObject({
status: 'running',
pid: 4321,
host: '0.0.0.0',
port: 3010,
url: 'http://127.0.0.1:3010',
command: 'node scripts/vite-cli.mjs --port=3010',
});
expect(snapshot.services['api-server']).toMatchObject({
status: 'idle',
pid: null,
port: 8090,
url: 'http://127.0.0.1:8090',
});
});
});
describe('dev scheduler spacetime reuse guard', () => {
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));

View File

@@ -1052,6 +1052,7 @@ mod tests {
external_api_audit_state: None,
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
});
assert_eq!(

View File

@@ -163,7 +163,14 @@ pub(super) async fn compile_match3d_draft_for_session(
.clone()
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
execute_billable_match3d_draft_generation(
let compile_session_id = session_id.clone();
let compile_owner_user_id = owner_user_id.clone();
let compile_profile_id = profile_id.clone();
let compile_initial_game_name = initial_game_name.clone();
let compile_requested_summary = requested_summary.clone();
let compile_initial_tags = initial_tags.clone();
let compile_requested_cover_image_src = requested_cover_image_src.clone();
let result = execute_billable_match3d_draft_generation(
state,
request_context,
owner_user_id.as_str(),
@@ -307,7 +314,108 @@ pub(super) async fn compile_match3d_draft_for_session(
Ok((next_session, generated_item_assets))
},
)
.await;
if let Err(response) = result.as_ref()
&& response.status().is_server_error()
{
let failure_message = match3d_response_failure_message(response);
persist_failed_match3d_draft_generation(
state,
request_context,
authenticated,
compile_session_id,
compile_owner_user_id,
compile_profile_id,
compile_initial_game_name,
compile_requested_summary,
compile_initial_tags,
compile_requested_cover_image_src,
failure_message,
)
.await;
}
result
}
#[allow(clippy::too_many_arguments)]
async fn persist_failed_match3d_draft_generation(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
owner_user_id: String,
profile_id: String,
game_name: String,
summary: Option<String>,
tags: Vec<String>,
cover_image_src: Option<String>,
failure_message: String,
) {
let failure_assets_json = serialize_match3d_failed_generation_assets(failure_message.as_str());
if let Err(persist_error) = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
Some(game_name),
summary.or_else(|| Some(String::new())),
Some(serde_json::to_string(&tags).unwrap_or_default()),
cover_image_src,
None,
failure_assets_json,
)
.await
{
tracing::error!(
provider = MATCH3D_AGENT_PROVIDER,
status = ?persist_error.status(),
"抓大鹅草稿生成失败后的状态回写失败"
);
}
}
fn serialize_match3d_failed_generation_assets(message: &str) -> Option<String> {
let background_asset = Match3DGeneratedBackgroundAsset {
prompt: String::new(),
status: "failed".to_string(),
error: Some(message.trim().to_string()),
..Default::default()
};
let assets = vec![Match3DGeneratedItemAssetJson {
item_id: "match3d-generation-failure".to_string(),
item_name: "生成失败".to_string(),
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
image_src: None,
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: Some(background_asset),
status: "failed".to_string(),
error: Some(message.trim().to_string()),
}];
serde_json::to_string(&assets).ok()
}
fn match3d_response_failure_message(response: &Response) -> String {
response
.extensions()
.get::<String>()
.cloned()
.unwrap_or_else(|| format!("抓大鹅草稿生成失败HTTP {}", response.status()))
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。

View File

@@ -453,6 +453,32 @@ fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -
|| match3d_text_present(asset.container_image_object_key.as_ref())
}
fn match3d_asset_status_is_failure(status: &str) -> bool {
let normalized = status.trim().to_ascii_lowercase().replace(['-', ' '], "_");
matches!(
normalized.as_str(),
"failed" | "failure" | "error" | "partial_failed"
)
}
fn match3d_error_present(value: Option<&String>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn match3d_item_asset_has_failure(asset: &Match3DGeneratedItemAssetJson) -> bool {
match3d_asset_status_is_failure(asset.status.as_str())
|| match3d_error_present(asset.error.as_ref())
|| asset.background_asset.as_ref().is_some_and(|background| {
match3d_asset_status_is_failure(background.status.as_str())
|| match3d_error_present(background.error.as_ref())
})
}
fn match3d_background_asset_has_failure(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_asset_status_is_failure(asset.status.as_str())
|| match3d_error_present(asset.error.as_ref())
}
fn resolve_match3d_work_generation_status(
item: &Match3DWorkProfileRecord,
assets: &[Match3DGeneratedItemAssetJson],
@@ -462,6 +488,21 @@ fn resolve_match3d_work_generation_status(
return Some("ready".to_string());
}
let has_failure = assets.iter().any(match3d_item_asset_has_failure)
|| background_asset.is_some_and(match3d_background_asset_has_failure);
if has_failure {
let has_partial_result = assets.iter().any(match3d_item_asset_has_image)
|| background_asset.is_some_and(match3d_background_asset_has_image);
return Some(
if has_partial_result {
"partial_failed"
} else {
"failed"
}
.to_string(),
);
}
if assets.is_empty()
|| !assets.iter().any(match3d_item_asset_has_image)
|| !background_asset.is_some_and(match3d_background_asset_has_image)

View File

@@ -1842,3 +1842,45 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
assert_eq!(response.generation_status.as_deref(), Some("ready"));
}
#[test]
fn match3d_work_summary_marks_failed_generated_assets_failed() {
let assets = vec![Match3DGeneratedItemAsset {
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "水果厨房背景".to_string(),
status: "failed".to_string(),
error: Some("VectorEngine 请求失败".to_string()),
..Default::default()
}),
status: "failed".to_string(),
error: Some("VectorEngine 请求失败".to_string()),
..test_match3d_generated_item_asset(1, "草莓")
}];
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
work_id: "match3d-profile-1".to_string(),
profile_id: "match3d-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("match3d-session-1".to_string()),
author_display_name: "玩家".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
cover_asset_id: None,
reference_image_src: None,
clear_count: 3,
difficulty: 3,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
published_at: None,
publish_ready: false,
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
});
assert_eq!(
response.generation_status.as_deref(),
Some("partial_failed")
);
}

View File

@@ -555,6 +555,10 @@ impl AppState {
.to_string(),
category_sort_order: 0,
updated_at_micros: 0,
unified_creation_spec:
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(
creation_type_id,
),
},
);
}

View File

@@ -147,26 +147,34 @@ pub async fn execute_wooden_fish_action(
wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let author_display_name = resolve_author_display_name(&state, &authenticated);
maybe_generate_hit_object_asset(
let result = execute_wooden_fish_action_with_generated_assets(
&state,
&request_context,
&session_id,
&owner_user_id,
&author_display_name,
&mut payload,
)
.await;
if result
.as_ref()
.err()
.is_some_and(|response| response.status().is_server_error())
&& matches!(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
)
{
mark_wooden_fish_generation_failed(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
&mut payload,
author_display_name.as_str(),
)
.await?;
maybe_generate_hit_sound_asset(&mut payload);
let response = state
.spacetime_client()
.execute_wooden_fish_action(session_id, owner_user_id, author_display_name, payload)
.await
.map_err(|error| {
wooden_fish_error_response(
&request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})?;
.await;
}
let response = result?;
Ok(json_success_body(Some(&request_context), response))
}
@@ -372,16 +380,24 @@ async fn build_wooden_fish_draft(
payload: &WoodenFishWorkspaceCreateRequest,
state: &AppState,
) -> Result<WoodenFishDraftResponse, Response> {
Ok(WoodenFishDraftResponse {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: resolve_wooden_fish_work_title(
let work_title = resolve_wooden_fish_work_title(
state,
&payload.work_description,
&payload.hit_object_prompt,
)
.await?,
.await?;
Ok(build_wooden_fish_draft_response(payload, work_title))
}
fn build_wooden_fish_draft_response(
payload: &WoodenFishWorkspaceCreateRequest,
work_title: String,
) -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title,
work_description: payload.work_description.trim().to_string(),
theme_tags: normalize_tags(payload.theme_tags.clone()),
hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT),
@@ -401,7 +417,7 @@ async fn build_wooden_fish_draft(
.or_else(|| Some(default_wooden_fish_hit_sound_asset())),
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,
})
}
}
fn validate_workspace_request(
@@ -543,6 +559,62 @@ async fn maybe_generate_hit_object_asset(
Ok(())
}
async fn execute_wooden_fish_action_with_generated_assets(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
payload: &mut WoodenFishActionRequest,
) -> Result<shared_contracts::wooden_fish::WoodenFishActionResponse, Response> {
maybe_generate_hit_object_asset(state, request_context, session_id, owner_user_id, payload)
.await?;
maybe_generate_hit_sound_asset(payload);
state
.spacetime_client()
.execute_wooden_fish_action(
session_id.to_string(),
owner_user_id.to_string(),
author_display_name.to_string(),
payload.clone(),
)
.await
.map_err(|error| {
wooden_fish_error_response(
request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})
}
async fn mark_wooden_fish_generation_failed(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
) {
if let Err(error) = state
.spacetime_client()
.mark_wooden_fish_generation_failed(
session_id.to_string(),
owner_user_id.to_string(),
author_display_name.to_string(),
)
.await
{
tracing::error!(
provider = WOODEN_FISH_CREATION_PROVIDER,
session_id,
owner_user_id,
request_id = request_context.request_id(),
error = %error,
"敲木鱼草稿生成失败后的状态回写失败"
);
}
}
fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(),
@@ -1475,7 +1547,8 @@ mod tests {
floating_words: vec![],
};
let draft = build_wooden_fish_draft(&payload);
let draft =
build_wooden_fish_draft_response(&payload, WOODEN_FISH_TEMPLATE_NAME.to_string());
assert!(draft.hit_sound_prompt.is_none());
let asset = draft

View File

@@ -11,7 +11,7 @@ use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse, build_phase1_unified_creation_spec,
};
pub fn build_creation_entry_config_response(
@@ -39,7 +39,9 @@ pub fn build_creation_entry_config_response(
creation_types: snapshot
.creation_types
.into_iter()
.map(|item| CreationEntryTypeResponse {
.map(|item| {
let unified_creation_spec = build_phase1_unified_creation_spec(item.id.as_str());
CreationEntryTypeResponse {
id: item.id,
title: item.title,
subtitle: item.subtitle,
@@ -52,6 +54,8 @@ pub fn build_creation_entry_config_response(
category_label: item.category_label,
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at_micros,
unified_creation_spec,
}
})
.collect(),
}

View File

@@ -51,4 +51,113 @@ pub struct CreationEntryTypeResponse {
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationSpecResponse {
pub play_id: String,
pub title: String,
pub workspace_stage: String,
pub generation_stage: String,
pub result_stage: String,
pub fields: Vec<UnifiedCreationFieldResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationFieldResponse {
pub id: String,
pub kind: String,
pub label: String,
pub required: bool,
}
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
"puzzle" => (
"puzzle-agent-workspace",
"puzzle-generating",
"puzzle-result",
vec![
unified_creation_field("pictureDescription", "text", "画面描述", true),
unified_creation_field("referenceImage", "image", "拼图画面", false),
unified_creation_field("promptReferenceImages", "image", "参考图", false),
],
),
"match3d" => (
"match3d-agent-workspace",
"match3d-generating",
"match3d-result",
vec![
unified_creation_field("themeText", "text", "题材", true),
unified_creation_field("difficulty", "select", "难度", true),
],
),
"wooden-fish" => (
"wooden-fish-workspace",
"wooden-fish-generating",
"wooden-fish-result",
vec![
unified_creation_field("hitObjectPrompt", "text", "敲什么", false),
unified_creation_field("hitObjectReferenceImage", "image", "参考图", false),
unified_creation_field("hitSoundAsset", "audio", "敲击音效", false),
unified_creation_field("floatingWords", "text", "功德有什么", true),
],
),
_ => return None,
};
Some(UnifiedCreationSpecResponse {
play_id: play_id.to_string(),
title: "想做个什么玩法?".to_string(),
workspace_stage: workspace_stage.to_string(),
generation_stage: generation_stage.to_string(),
result_stage: result_stage.to_string(),
fields,
})
}
fn unified_creation_field(
id: &str,
kind: &str,
label: &str,
required: bool,
) -> UnifiedCreationFieldResponse {
UnifiedCreationFieldResponse {
id: id.to_string(),
kind: kind.to_string(),
label: label.to_string(),
required,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase1_unified_creation_specs_only_cover_three_templates() {
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
assert_eq!(puzzle.fields[0].id, "pictureDescription");
assert_eq!(puzzle.fields[1].kind, "image");
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
assert_eq!(
match3d
.fields
.iter()
.filter(|field| field.kind == "select")
.count(),
1
);
let wooden_fish =
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio"));
assert!(build_phase1_unified_creation_spec("visual-novel").is_none());
assert!(build_phase1_unified_creation_spec("bark-battle").is_none());
}
}

View File

@@ -100,6 +100,7 @@ fn map_wooden_fish_session_snapshot(
fn map_wooden_fish_work_snapshot(
snapshot: WoodenFishWorkSnapshot,
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
let generation_status = parse_generation_status(&snapshot.generation_status);
let draft = WoodenFishDraftResponse {
template_id: "wooden-fish".to_string(),
template_name: "敲木鱼".to_string(),
@@ -116,15 +117,23 @@ fn map_wooden_fish_work_snapshot(
back_button_asset: snapshot.back_button_asset.clone().map(map_image_asset),
hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset),
cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()),
generation_status: parse_generation_status(&snapshot.generation_status),
generation_status: generation_status.clone(),
};
let hit_object_asset = draft
.hit_object_asset
.clone()
.or_else(|| {
matches!(generation_status, WoodenFishGenerationStatus::Failed)
.then(default_failed_hit_object_asset)
})
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?;
let hit_sound_asset = draft
.hit_sound_asset
.clone()
.or_else(|| {
matches!(generation_status, WoodenFishGenerationStatus::Failed)
.then(default_failed_hit_sound_asset)
})
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit sound asset"))?;
Ok(WoodenFishWorkProfileResponse {
summary: WoodenFishWorkSummaryResponse {
@@ -143,7 +152,7 @@ fn map_wooden_fish_work_snapshot(
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
publish_ready: snapshot.publish_ready,
generation_status: parse_generation_status(&snapshot.generation_status),
generation_status,
},
draft,
hit_object_asset,
@@ -154,6 +163,31 @@ fn map_wooden_fish_work_snapshot(
})
}
fn default_failed_hit_object_asset() -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: "wooden-fish-failed-hit-object".to_string(),
image_src: "/wooden-fish/default-hit-object.png".to_string(),
image_object_key: "public/wooden-fish/default-hit-object.png".to_string(),
asset_object_id: "wooden-fish-failed-hit-object".to_string(),
generation_provider: "failed-fallback".to_string(),
prompt: "生成失败占位图".to_string(),
width: 1024,
height: 1024,
}
}
fn default_failed_hit_sound_asset() -> WoodenFishAudioAsset {
WoodenFishAudioAsset {
asset_id: "wooden-fish-failed-hit-sound".to_string(),
audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(),
audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(),
asset_object_id: "wooden-fish-failed-hit-sound".to_string(),
source: "failed-fallback".to_string(),
prompt: Some("生成失败占位音效".to_string()),
duration_ms: Some(3_000),
}
}
fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
template_id: snapshot.template_id,

View File

@@ -122,6 +122,35 @@ impl SpacetimeClient {
})
}
pub async fn mark_wooden_fish_generation_failed(
&self,
session_id: String,
owner_user_id: String,
author_display_name: String,
) -> Result<WoodenFishSessionSnapshotResponse, SpacetimeClientError> {
let current = self
.get_wooden_fish_session(session_id.clone(), owner_user_id.clone())
.await?;
let mut draft = current.draft.clone().unwrap_or_else(default_draft);
let profile_id = resolve_wooden_fish_profile_id(
&draft,
&WoodenFishActionType::CompileDraft,
draft.profile_id.as_deref(),
)?;
draft.profile_id = Some(profile_id.clone());
draft.generation_status = WoodenFishGenerationStatus::Failed;
let now_micros = current_unix_micros();
self.compile_wooden_fish_draft(build_failed_compile_input(
&current,
&owner_user_id,
&author_display_name,
&profile_id,
&draft,
now_micros,
)?)
.await
}
pub async fn compile_wooden_fish_draft(
&self,
procedure_input: WoodenFishDraftCompileInput,
@@ -636,6 +665,52 @@ fn build_compile_input(
})
}
fn build_failed_compile_input(
current: &WoodenFishSessionSnapshotResponse,
owner_user_id: &str,
author_display_name: &str,
profile_id: &str,
draft: &WoodenFishDraftResponse,
now_micros: i64,
) -> Result<WoodenFishDraftCompileInput, SpacetimeClientError> {
Ok(WoodenFishDraftCompileInput {
session_id: current.session_id.clone(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
author_display_name: author_display_name.trim().to_string(),
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
hit_object_prompt: draft.hit_object_prompt.clone(),
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
hit_sound_prompt: draft.hit_sound_prompt.clone(),
hit_object_asset_json: draft
.hit_object_asset
.as_ref()
.map(json_string)
.transpose()?,
background_asset_json: draft
.background_asset
.as_ref()
.map(json_string)
.transpose()?,
hit_sound_asset_json: draft
.hit_sound_asset
.as_ref()
.map(json_string)
.transpose()?,
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
floating_words_json: Some(json_string(&draft.floating_words)?),
cover_image_src: draft.cover_image_src.clone(),
generation_status: Some("failed".to_string()),
compiled_at_micros: now_micros,
})
}
fn build_update_input(
owner_user_id: &str,
profile_id: &str,
@@ -801,6 +876,7 @@ mod tests {
const SESSION_ID: &str = "wooden-fish-session-test";
const OWNER_USER_ID: &str = "user-test";
const AUTHOR_DISPLAY_NAME: &str = "测试玩家";
const PROFILE_ID: &str = "wooden-fish-profile-test";
const NOW_MICROS: i64 = 1_763_456_789_000_000;
@@ -813,8 +889,13 @@ mod tests {
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
let (plan, draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
let (plan, draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("compile-draft should build plan");
let WoodenFishActionProcedure::Compile(input) = plan else {
@@ -862,8 +943,13 @@ mod tests {
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
Err(error) => error,
};
@@ -883,8 +969,13 @@ mod tests {
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not publish without background asset"),
Err(error) => error,
};
@@ -904,8 +995,13 @@ mod tests {
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not publish without back button asset"),
Err(error) => error,
};
@@ -926,8 +1022,13 @@ mod tests {
payload.background_asset = Some(generated_background_asset("generated-background"));
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
let (plan, _draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
let (plan, _draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("regenerate-hit-object should build plan");
let WoodenFishActionProcedure::Compile(input) = plan else {
@@ -987,8 +1088,13 @@ mod tests {
"健康+1".to_string(),
]);
let (plan, draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
let (plan, draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("update-floating-words should build plan");
let WoodenFishActionProcedure::Update(input) = plan else {
@@ -1016,6 +1122,31 @@ mod tests {
assert!(draft.hit_sound_prompt.is_none());
}
#[test]
fn wooden_fish_failed_compile_input_preserves_session_and_marks_failed() {
let session = session_with_draft(draft_without_assets());
let mut draft = session.draft.clone().expect("draft should exist");
draft.profile_id = Some(PROFILE_ID.to_string());
draft.generation_status = WoodenFishGenerationStatus::Failed;
let input = build_failed_compile_input(
&session,
OWNER_USER_ID,
"测试玩家",
PROFILE_ID,
&draft,
NOW_MICROS,
)
.expect("failed compile input should build");
assert_eq!(input.session_id, SESSION_ID);
assert_eq!(input.profile_id, PROFILE_ID);
assert_eq!(input.generation_status.as_deref(), Some("failed"));
assert!(input.hit_object_asset_json.is_none());
assert!(input.background_asset_json.is_none());
assert!(input.back_button_asset_json.is_none());
}
fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest {
WoodenFishActionRequest {
action_type,

View File

@@ -534,6 +534,9 @@ fn publish_wooden_fish_work_tx(
input: WoodenFishWorkPublishInput,
) -> Result<WoodenFishWorkSnapshot, String> {
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
if row.generation_status == WOODEN_FISH_GENERATION_FAILED {
return Err("生成失败的敲木鱼作品需要重新生成后才能发布".to_string());
}
if !is_publish_ready(&row) {
return Err("发布需要完整的敲击物图案、背景、返回按钮、敲击音效和飘字配置".to_string());
}

View File

@@ -12,6 +12,7 @@ pub const WOODEN_FISH_PUBLICATION_PUBLISHED: &str = "Published";
pub const WOODEN_FISH_GENERATION_DRAFT: &str = "draft";
pub const WOODEN_FISH_GENERATION_GENERATING: &str = "generating";
pub const WOODEN_FISH_GENERATION_READY: &str = "ready";
pub const WOODEN_FISH_GENERATION_FAILED: &str = "failed";
pub const WOODEN_FISH_EVENT_RUN_STARTED: &str = "run-started";
pub const WOODEN_FISH_EVENT_RUN_CHECKPOINT: &str = "checkpoint";
pub const WOODEN_FISH_EVENT_RUN_FINISHED: &str = "finish";

View File

@@ -0,0 +1,206 @@
import { Mic, Pause, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
title: string;
defaultLabel: string;
asset: TAsset | null;
buildRecordedFileName: () => string;
onAssetChange: (asset: TAsset | null) => void;
onError: (message: string | null) => void;
readFileAsAsset?: (
file: File,
source: 'uploaded' | 'recorded',
) => Promise<TAsset>;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
defaultLabel,
asset,
buildRecordedFileName,
onAssetChange,
onError,
readFileAsAsset = readCreativeAudioFileAsAsset,
}: CreativeAudioInputPanelProps<TAsset>) {
const [isRecording, setIsRecording] = useState(false);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const startRecording = async () => {
if (disabled || isRecording) {
return;
}
try {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
throw new Error('当前浏览器不支持录音。');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, {
type: recorder.mimeType || 'audio/webm',
});
stream.getTracks().forEach((track) => track.stop());
const file = new File([blob], buildRecordedFileName(), {
type: blob.type,
});
void readFileAsAsset(file, 'recorded')
.then(onAssetChange)
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '录音保存失败。',
);
});
};
recorderRef.current = recorder;
recorder.start();
setIsRecording(true);
onError(null);
} catch (caughtError) {
onError(
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
);
}
};
const stopRecording = () => {
recorderRef.current?.stop();
recorderRef.current = null;
setIsRecording(false);
};
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
</div>
{asset ? (
<button
type="button"
onClick={() => onAssetChange(null)}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
>
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<label
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
>
<Upload className="h-4 w-4" />
<input
type="file"
accept="audio/*"
disabled={disabled}
className="sr-only"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void readFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
/>
</label>
<button
type="button"
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
void startRecording();
}}
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
{isRecording ? '停止' : '录音'}
</button>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : defaultLabel}
</div>
)}
</div>
</section>
);
}
export default CreativeAudioInputPanel;

View File

@@ -215,11 +215,13 @@ import {
buildSquareHoleGenerationAnchorEntries,
buildWoodenFishGenerationAnchorEntries,
createMiniGameDraftGenerationState,
resolveMiniGameDraftGenerationStartedAtMs,
type MiniGameDraftGenerationKind,
type MiniGameDraftGenerationPhase,
type MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { getUnifiedCreationSpec } from '../unified-creation/unifiedCreationSpecs';
import {
buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode,
@@ -2195,9 +2197,7 @@ function rebaseMiniGameDraftGenerationStateForDisplay(
function rebaseMiniGameDraftBackgroundCompileTaskForDisplay<
T extends PuzzleBackgroundCompileTask | Match3DBackgroundCompileTask,
>(
task: T,
): T {
>(task: T): T {
return {
...task,
generationState: rebaseMiniGameDraftGenerationStateForDisplay(
@@ -2216,7 +2216,10 @@ function createPuzzleDraftGenerationStateFromPayload(
: undefined;
return {
...createMiniGameDraftGenerationState('puzzle'),
...createMiniGameDraftGenerationState(
'puzzle',
resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt),
),
metadata: {
puzzleAiRedraw: payload?.aiRedraw ?? true,
puzzleActivePhaseId:
@@ -2269,7 +2272,7 @@ function mergePuzzleSessionProgressIntoGenerationState(
...state.metadata,
puzzleActivePhaseId: nextPhaseId,
puzzleActiveStepStartedAtMs: shouldResetActiveStepStart
? Date.now()
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
: state.metadata?.puzzleActiveStepStartedAtMs,
puzzleProgressPercent: isCompiledGenerationSession
? session.progressPercent
@@ -2734,7 +2737,8 @@ function buildPendingBarkBattleWorks(
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'draft',
generationStatus: state.status === 'generating' ? 'pending_assets' : 'ready',
generationStatus:
state.status === 'generating' ? 'pending_assets' : 'ready',
publishReady: false,
playCount: 0,
updatedAt: state.updatedAt,
@@ -2852,6 +2856,20 @@ const CustomWorldGenerationView = lazy(async () => {
};
});
const UnifiedCreationPage = lazy(async () => {
const module = await import('../unified-creation/UnifiedCreationPage');
return {
default: module.UnifiedCreationPage,
};
});
const UnifiedGenerationPage = lazy(async () => {
const module = await import('../unified-creation/UnifiedGenerationPage');
return {
default: module.UnifiedGenerationPage,
};
});
const RpgCreationResultView = lazy(async () => {
const module = await import('../rpg-creation-result/RpgCreationResultView');
return {
@@ -2983,7 +3001,9 @@ const BarkBattleConfigEditor = lazy(async () => {
});
const BarkBattleGeneratingView = lazy(async () => {
const module = await import('../bark-battle-creation/BarkBattleGeneratingView');
const module = await import(
'../bark-battle-creation/BarkBattleGeneratingView'
);
return {
default: module.BarkBattleGeneratingView,
};
@@ -3276,13 +3296,16 @@ export function PlatformEntryFlowShellImpl({
useState<BarkBattlePublishedConfig | null>(null);
const [barkBattleDraftConfig, setBarkBattleDraftConfig] =
useState<BarkBattleDraftConfig | null>(null);
const [barkBattleRuntimeMode, setBarkBattleRuntimeMode] =
useState<'draft' | 'published'>('draft');
const [barkBattleRuntimeMode, setBarkBattleRuntimeMode] = useState<
'draft' | 'published'
>('draft');
const [barkBattleRuntimeReturnStage, setBarkBattleRuntimeReturnStage] =
useState<BarkBattleRuntimeReturnStage>('platform');
const [barkBattleError, setBarkBattleError] = useState<string | null>(null);
const [barkBattleGenerationPartialFailed, setBarkBattleGenerationPartialFailed] =
useState(false);
const [
barkBattleGenerationPartialFailed,
setBarkBattleGenerationPartialFailed,
] = useState(false);
const [isBarkBattleBusy, setIsBarkBattleBusy] = useState(false);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
@@ -3457,6 +3480,10 @@ export function PlatformEntryFlowShellImpl({
: [],
[creationEntryConfig],
);
const unifiedCreationConfigById = useMemo(() => {
const entries = creationEntryConfig?.creationTypes ?? [];
return new Map(entries.map((entry) => [entry.id, entry]));
}, [creationEntryConfig]);
const isBigFishCreationVisible = isPlatformCreationTypeVisible(
creationEntryTypes,
'big-fish',
@@ -3841,12 +3868,10 @@ export function PlatformEntryFlowShellImpl({
return true;
}
setDraftGenerationPointNotice(
{
setDraftGenerationPointNotice({
title: '泥点不足',
message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
},
);
});
return false;
} catch {
setDraftGenerationPointNotice({
@@ -4157,26 +4182,36 @@ export function PlatformEntryFlowShellImpl({
(updated: BarkBattleWorkSummary) => {
setBarkBattleWorks((current) => {
const deduped = mergeBarkBattleWorksByWorkId(current);
const hasExisting = deduped.some((item) => item.workId === updated.workId);
const hasExisting = deduped.some(
(item) => item.workId === updated.workId,
);
if (hasExisting) {
return deduped.map((item) => mergeBarkBattleWorkSummary(item, updated));
return deduped.map((item) =>
mergeBarkBattleWorkSummary(item, updated),
);
}
return [updated, ...deduped];
});
setBarkBattleGalleryEntries((current) => {
const hasExisting = current.some((item) => item.workId === updated.workId);
const hasExisting = current.some(
(item) => item.workId === updated.workId,
);
if (updated.status !== 'published') {
return hasExisting
? current.map((item) => mergeBarkBattleWorkSummary(item, updated))
: current;
}
if (hasExisting) {
return current.map((item) => mergeBarkBattleWorkSummary(item, updated));
return current.map((item) =>
mergeBarkBattleWorkSummary(item, updated),
);
}
return [updated, ...current];
});
if (updated.status === 'published') {
syncUpdatedPublicWorkDetail(mapBarkBattleWorkToPublicWorkDetail(updated));
syncUpdatedPublicWorkDetail(
mapBarkBattleWorkToPublicWorkDetail(updated),
);
}
},
[syncUpdatedPublicWorkDetail],
@@ -4212,7 +4247,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') {
if (
platformBootstrap.platformTab === 'create' ||
selectionStage === 'platform'
) {
void refreshBarkBattleShelf();
}
}, [
@@ -4228,7 +4266,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') {
if (
platformBootstrap.platformTab === 'create' ||
selectionStage === 'platform'
) {
void refreshJumpHopShelf();
}
}, [
@@ -4509,7 +4550,9 @@ export function PlatformEntryFlowShellImpl({
: []),
...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail),
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
...barkBattleGalleryEntries.map(mapBarkBattleWorkToPlatformGalleryCard),
...barkBattleGalleryEntries.map(
mapBarkBattleWorkToPlatformGalleryCard,
),
...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard),
...(barkBattleGalleryEntries.length === 0
? barkBattleWorks
@@ -5241,7 +5284,12 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftGenerating('match3d', session.sessionId);
selectionStageRef.current = 'match3d-generating';
setSelectionStage('match3d-generating');
setMatch3DGenerationState(createMiniGameDraftGenerationState('match3d'));
setMatch3DGenerationState(
createMiniGameDraftGenerationState(
'match3d',
resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt),
),
);
},
onActionError: async ({ payload, errorMessage, session, setSession }) => {
if (payload.action !== 'match3d_compile_draft') {
@@ -5870,9 +5918,7 @@ export function PlatformEntryFlowShellImpl({
return ensureEnoughDraftGenerationPointsFromServer(
PUZZLE_DRAFT_GENERATION_POINT_COST,
);
}, [
ensureEnoughDraftGenerationPointsFromServer,
]);
}, [ensureEnoughDraftGenerationPointsFromServer]);
const preflightMatch3DDraftGeneration = useCallback(async () => {
setMatch3DError(null);
return ensureEnoughDraftGenerationPointsFromServer(
@@ -6164,7 +6210,8 @@ export function PlatformEntryFlowShellImpl({
puzzleGenerationViewSession?.sessionId ??
puzzleSession?.sessionId,
),
message: puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError,
message:
puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError,
},
{
key: 'puzzle-onboarding',
@@ -6292,9 +6339,10 @@ export function PlatformEntryFlowShellImpl({
completedAtMs: number | null;
})
| null
>(() => pendingPlatformTaskCompletionDialog, [
pendingPlatformTaskCompletionDialog,
]);
>(
() => pendingPlatformTaskCompletionDialog,
[pendingPlatformTaskCompletionDialog],
);
const activePlatformTaskCompletionDialogDismissKey =
buildPlatformTaskCompletionDialogDismissKey(
currentPlatformTaskCompletionDialog,
@@ -6429,7 +6477,10 @@ export function PlatformEntryFlowShellImpl({
isMiniGameDraftGenerating(puzzleGenerationViewState);
useEffect(() => {
if (!shouldPollPuzzleGenerationSession || !activePuzzleGenerationSessionId) {
if (
!shouldPollPuzzleGenerationSession ||
!activePuzzleGenerationSessionId
) {
return undefined;
}
@@ -6973,7 +7024,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
const generationState = createMiniGameDraftGenerationState('match3d');
const generationState = createMiniGameDraftGenerationState(
'match3d',
resolveMiniGameDraftGenerationStartedAtMs(nextSession.updatedAt),
);
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
@@ -7687,7 +7741,10 @@ export function PlatformEntryFlowShellImpl({
writeCreationUrlState(buildBarkBattleCreationUrlState(draft));
setBarkBattlePublishedConfig(null);
markDraftGenerating('bark-battle', [draft.workId, draft.draftId]);
markPendingDraftGenerating('bark-battle', draft.workId ?? draft.draftId);
markPendingDraftGenerating(
'bark-battle',
draft.workId ?? draft.draftId,
);
updateBarkBattleWorkCaches(
buildBarkBattleWorkSummaryFromDraft(
draft,
@@ -7780,9 +7837,11 @@ export function PlatformEntryFlowShellImpl({
const nextDraft = {
...persistedDraft,
playerCharacterImageSrc:
draft.playerCharacterImageSrc ?? persistedDraft.playerCharacterImageSrc,
draft.playerCharacterImageSrc ??
persistedDraft.playerCharacterImageSrc,
opponentCharacterImageSrc:
draft.opponentCharacterImageSrc ?? persistedDraft.opponentCharacterImageSrc,
draft.opponentCharacterImageSrc ??
persistedDraft.opponentCharacterImageSrc,
uiBackgroundImageSrc:
draft.uiBackgroundImageSrc ?? persistedDraft.uiBackgroundImageSrc,
};
@@ -7852,7 +7911,9 @@ export function PlatformEntryFlowShellImpl({
setBarkBattleError(null);
const workId = draft.workId?.trim();
if (!workId) {
setBarkBattleError('这份汪汪声浪草稿缺少作品ID请重新生成草稿后再发布。');
setBarkBattleError(
'这份汪汪声浪草稿缺少作品ID请重新生成草稿后再发布。',
);
return;
}
setIsBarkBattleBusy(true);
@@ -7885,7 +7946,8 @@ export function PlatformEntryFlowShellImpl({
playerCharacterImageSrc:
published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc,
opponentCharacterImageSrc:
published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc,
published.opponentCharacterImageSrc ??
draft.opponentCharacterImageSrc,
uiBackgroundImageSrc:
published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc,
};
@@ -7911,7 +7973,9 @@ export function PlatformEntryFlowShellImpl({
);
selectionStageRef.current = 'work-detail';
setSelectionStage('work-detail');
pushAppHistoryPath(buildPublicWorkStagePath('work-detail', publicWorkCode));
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', publicWorkCode),
);
void Promise.allSettled([
refreshBarkBattleShelf(),
refreshBarkBattleGallery(),
@@ -8021,10 +8085,7 @@ export function PlatformEntryFlowShellImpl({
await ensureBabyObjectMatchGeneratedAssets(draft);
} catch (error) {
setBabyObjectMatchError(
resolvePuzzleErrorMessage(
error,
'重新生成宝贝识物素材失败。',
),
resolvePuzzleErrorMessage(error, '重新生成宝贝识物素材失败。'),
);
} finally {
setIsBabyObjectMatchBusy(false);
@@ -8680,9 +8741,12 @@ export function PlatformEntryFlowShellImpl({
);
setJumpHopGenerationState(readyState);
if (response.work) {
setJumpHopWorks((current) =>
[response.work!.summary, ...current.filter((item) => item.workId !== response.work!.summary.workId)],
);
setJumpHopWorks((current) => [
response.work!.summary,
...current.filter(
(item) => item.workId !== response.work!.summary.workId,
),
]);
markPendingDraftReady('jump-hop', created.session.sessionId, false);
markDraftReady(
'jump-hop',
@@ -8821,9 +8885,12 @@ export function PlatformEntryFlowShellImpl({
try {
const response = await jumpHopClient.publishWork(profileId);
setJumpHopWork(response.item);
setJumpHopWorks((current) =>
[response.item.summary, ...current.filter((item) => item.workId !== response.item.summary.workId)],
);
setJumpHopWorks((current) => [
response.item.summary,
...current.filter(
(item) => item.workId !== response.item.summary.workId,
),
]);
void refreshJumpHopShelf().catch(() => undefined);
openPublishShareModal({
title: response.item.summary.workTitle || '跳一跳',
@@ -8967,7 +9034,12 @@ export function PlatformEntryFlowShellImpl({
created: WoodenFishSessionResponse,
payload?: WoodenFishWorkspaceCreateRequest,
) => {
const generationState = createMiniGameDraftGenerationState('wooden-fish');
const generationState = createMiniGameDraftGenerationState(
'wooden-fish',
resolveMiniGameDraftGenerationStartedAtMs(
created.session.updatedAt ?? created.session.createdAt,
),
);
setWoodenFishError(null);
setWoodenFishSession(created.session);
writeCreationUrlState(
@@ -8994,7 +9066,8 @@ export function PlatformEntryFlowShellImpl({
payload?.workDescription ??
created.session.draft?.workDescription ??
'',
themeTags: payload?.themeTags ?? created.session.draft?.themeTags ?? ['敲木鱼'],
themeTags: payload?.themeTags ??
created.session.draft?.themeTags ?? ['敲木鱼'],
coverImageSrc: created.session.draft?.coverImageSrc ?? null,
publicationStatus: 'draft',
playCount: 0,
@@ -9128,7 +9201,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
const generationState = createMiniGameDraftGenerationState('wooden-fish');
const generationState = createMiniGameDraftGenerationState(
'wooden-fish',
resolveMiniGameDraftGenerationStartedAtMs(woodenFishSession.updatedAt),
);
setWoodenFishError(null);
setWoodenFishGenerationState(generationState);
setIsWoodenFishBusy(true);
@@ -9328,7 +9404,10 @@ export function PlatformEntryFlowShellImpl({
const [detail, runResponse] = await Promise.all([
woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null),
options.embedded
? woodenFishClient.startRun(normalizedProfileId, runtimeGuestOptions)
? woodenFishClient.startRun(
normalizedProfileId,
runtimeGuestOptions,
)
: woodenFishClient.startRun(normalizedProfileId),
]);
if (detail?.item) {
@@ -9576,12 +9655,7 @@ export function PlatformEntryFlowShellImpl({
if (selectionStage === 'big-fish-runtime' && !bigFishRun) {
setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform');
}
}, [
bigFishRun,
bigFishSession,
selectionStage,
setSelectionStage,
]);
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
useEffect(() => {
if (selectionStage === 'match3d-result' && !match3dSession?.draft) {
@@ -9592,12 +9666,7 @@ export function PlatformEntryFlowShellImpl({
if (selectionStage === 'match3d-runtime' && !match3dRun) {
setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform');
}
}, [
match3dRun,
match3dSession,
selectionStage,
setSelectionStage,
]);
}, [match3dRun, match3dSession, selectionStage, setSelectionStage]);
useEffect(() => {
if (selectionStage === 'square-hole-result' && !squareHoleSession?.draft) {
@@ -9610,12 +9679,7 @@ export function PlatformEntryFlowShellImpl({
squareHoleSession?.draft ? 'square-hole-result' : 'platform',
);
}
}, [
selectionStage,
setSelectionStage,
squareHoleRun,
squareHoleSession,
]);
}, [selectionStage, setSelectionStage, squareHoleRun, squareHoleSession]);
useEffect(() => {
if (
@@ -9859,11 +9923,10 @@ export function PlatformEntryFlowShellImpl({
setMatch3DError(null);
try {
let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary =
profile;
let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = profile;
if (
(!hasMatch3DRuntimeAsset(profile.generatedItemAssets) ||
!hasMatch3DRuntimeBackgroundAsset(profile))
!hasMatch3DRuntimeAsset(profile.generatedItemAssets) ||
!hasMatch3DRuntimeBackgroundAsset(profile)
) {
try {
const { item } = await getMatch3DWorkDetail(profile.profileId);
@@ -10319,21 +10382,19 @@ export function PlatformEntryFlowShellImpl({
? puzzleGalleryEntries.length > 0
? puzzleGalleryEntries
: await refreshPuzzleGallery()
: (
puzzleWorks.length > 0
: puzzleWorks.length > 0
? puzzleWorks
: (await listPuzzleWorks().catch(() => ({ items: [] }))).items
);
: (await listPuzzleWorks().catch(() => ({ items: [] }))).items;
const targetItem =
runtimeProfileId || runtimeSessionId || publicWorkCode
? candidateItems.find(
? (candidateItems.find(
(item) =>
item.profileId === runtimeProfileId ||
item.sourceSessionId === runtimeSessionId ||
(publicWorkCode
? isSamePuzzlePublicWorkCode(publicWorkCode, item.profileId)
: false),
) ?? null
) ?? null)
: null;
if (!targetItem) {
@@ -10381,9 +10442,7 @@ export function PlatformEntryFlowShellImpl({
}
setSelectedPuzzleDetail(targetItem);
setPuzzleRun(
startLocalPuzzleRun(targetItem, runtimeLevelId ?? null),
);
setPuzzleRun(startLocalPuzzleRun(targetItem, runtimeLevelId ?? null));
setPuzzleRuntimeAuthMode(isPublishedRuntime ? 'isolated' : 'default');
setPuzzleRuntimeReturnStage(fallbackStage);
openPuzzleRuntimeStage(
@@ -10634,7 +10693,11 @@ export function PlatformEntryFlowShellImpl({
const submitLeaderboardPromise =
puzzleRuntimeAuthMode === 'isolated'
? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) =>
submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeGuestOptions),
submitPuzzleLeaderboard(
puzzleRun.runId,
payload,
runtimeGuestOptions,
),
)
: submitPuzzleLeaderboard(puzzleRun.runId, payload);
@@ -10753,8 +10816,7 @@ export function PlatformEntryFlowShellImpl({
const item = await getPuzzleGalleryDetail(nextProfileId).then(
(response) => response.item,
);
const nextRecommendEntry =
mapPuzzleWorkToPlatformGalleryCard(item);
const nextRecommendEntry = mapPuzzleWorkToPlatformGalleryCard(item);
setPuzzleGalleryEntries((current) => {
const nextEntries = current.filter(
(entry) => entry.profileId !== item.profileId,
@@ -10873,11 +10935,7 @@ export function PlatformEntryFlowShellImpl({
null,
);
returnToCreationFlowSource();
}, [
autosaveCoordinator,
returnToCreationFlowSource,
sessionController,
]);
}, [autosaveCoordinator, returnToCreationFlowSource, sessionController]);
const leaveAgentDraftGeneration = useCallback(() => {
if (sessionController.isActiveGenerationRunning) {
@@ -10897,11 +10955,7 @@ export function PlatformEntryFlowShellImpl({
sessionController.setCustomWorldGenerationViewSource(null);
sessionController.setCustomWorldResultViewSource(null);
returnToCreationFlowSource();
}, [
autosaveCoordinator,
returnToCreationFlowSource,
sessionController,
]);
}, [autosaveCoordinator, returnToCreationFlowSource, sessionController]);
const leaveCustomWorldResult = useCallback(() => {
sessionController.setGeneratedCustomWorldProfile(null);
@@ -11887,7 +11941,12 @@ export function PlatformEntryFlowShellImpl({
setIsJumpHopBusy(false);
}
},
[enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage],
[
enterCreateTab,
markDraftNoticeSeen,
openPublicWorkDetail,
setSelectionStage,
],
);
const openWoodenFishPublicWorkDetail = useCallback(
@@ -12195,16 +12254,14 @@ export function PlatformEntryFlowShellImpl({
item.sourceSessionId,
);
const payload = buildPuzzleFormPayloadFromSession(latestSession);
const generationState = createMiniGameDraftGenerationStateForRestoredDraft(
'puzzle',
{
const generationState =
createMiniGameDraftGenerationStateForRestoredDraft('puzzle', {
puzzleAiRedraw: payload.aiRedraw ?? true,
puzzleProgressPercent:
latestSession.draft && !latestSession.draft.formDraft
? latestSession.progressPercent
: undefined,
},
);
});
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(
@@ -12401,8 +12458,7 @@ export function PlatformEntryFlowShellImpl({
setMatch3DSession(latestSession);
setMatch3DFormDraftPayload(null);
setMatch3DProfile(null);
const generationState =
rebaseMiniGameDraftGenerationStateForDisplay(
const generationState = rebaseMiniGameDraftGenerationStateForDisplay(
createMiniGameDraftGenerationStateForRestoredDraft('match3d'),
);
setMatch3DGenerationState(generationState);
@@ -12603,14 +12659,18 @@ export function PlatformEntryFlowShellImpl({
};
setBarkBattleDraftConfig(nextDraft);
enterCreateTab();
selectionStageRef.current =
isPersistedBarkBattleDraftGenerating(item)
selectionStageRef.current = isPersistedBarkBattleDraftGenerating(item)
? 'bark-battle-generating'
: 'bark-battle-result';
setSelectionStage(selectionStageRef.current);
writeCreationUrlState(buildBarkBattleCreationUrlState(nextDraft));
},
[enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage],
[
enterCreateTab,
markDraftNoticeSeen,
openPublicWorkDetail,
setSelectionStage,
],
);
const openVisualNovelDraft = useCallback(
@@ -12730,15 +12790,17 @@ export function PlatformEntryFlowShellImpl({
const profileId = normalizeCreationUrlValue(
initialCreationUrlState.profileId,
);
const draftId = normalizeCreationUrlValue(initialCreationUrlState.draftId);
const draftId = normalizeCreationUrlValue(
initialCreationUrlState.draftId,
);
const workId = normalizeCreationUrlValue(initialCreationUrlState.workId);
if (path.startsWith('/creation/big-fish')) {
const targetSessionId = sessionId ?? workId?.replace(/^big-fish-work-/u, '');
const targetSessionId =
sessionId ?? workId?.replace(/^big-fish-work-/u, '');
if (targetSessionId) {
const matchedWork =
(
bigFishWorks.length > 0
(bigFishWorks.length > 0
? bigFishWorks
: (await listBigFishWorks().catch(() => ({ items: [] }))).items
).find(
@@ -12757,8 +12819,7 @@ export function PlatformEntryFlowShellImpl({
if (path.startsWith('/creation/match3d')) {
const matchedWork =
(
match3dWorks.length > 0
(match3dWorks.length > 0
? match3dWorks
: mapMatch3DWorksForRuntimeUi(
(await listMatch3DWorks().catch(() => ({ items: [] }))).items,
@@ -12781,8 +12842,7 @@ export function PlatformEntryFlowShellImpl({
if (path.startsWith('/creation/square-hole')) {
const matchedWork =
(
squareHoleWorks.length > 0
(squareHoleWorks.length > 0
? squareHoleWorks
: (await listSquareHoleWorks().catch(() => ({ items: [] }))).items
).find(
@@ -12803,8 +12863,7 @@ export function PlatformEntryFlowShellImpl({
if (path.startsWith('/creation/puzzle')) {
const matchedWork =
(
puzzleWorks.length > 0
(puzzleWorks.length > 0
? puzzleWorks
: (await listPuzzleWorks().catch(() => ({ items: [] }))).items
).find(
@@ -12825,8 +12884,7 @@ export function PlatformEntryFlowShellImpl({
if (path.startsWith('/creation/visual-novel')) {
const matchedWork =
(
visualNovelWorks.length > 0
(visualNovelWorks.length > 0
? visualNovelWorks
: (await listVisualNovelWorks().catch(() => ({ works: [] }))).works
).find((item) => item.profileId === profileId) ?? null;
@@ -12842,8 +12900,7 @@ export function PlatformEntryFlowShellImpl({
if (path.startsWith('/creation/bark-battle')) {
const matchedWork =
(
barkBattleWorks.length > 0
(barkBattleWorks.length > 0
? barkBattleWorks
: (await listBarkBattleWorks().catch(() => ({ items: [] }))).items
).find(
@@ -12857,8 +12914,7 @@ export function PlatformEntryFlowShellImpl({
if (path.startsWith('/creation/baby-object-match')) {
const matchedDraft =
(
babyObjectMatchDrafts.length > 0
(babyObjectMatchDrafts.length > 0
? babyObjectMatchDrafts
: await listLocalBabyObjectMatchDrafts().catch(() => [])
).find(
@@ -12885,7 +12941,9 @@ export function PlatformEntryFlowShellImpl({
}
setJumpHopSession(session);
setJumpHopWork(work);
writeCreationUrlState(buildJumpHopCreationUrlState({ session, work }));
writeCreationUrlState(
buildJumpHopCreationUrlState({ session, work }),
);
enterCreateTab();
setSelectionStage(
path.includes('/generating')
@@ -13019,7 +13077,13 @@ export function PlatformEntryFlowShellImpl({
return false;
}
},
[authUi, bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage],
[
authUi,
bigFishFlow,
resolveBigFishErrorMessage,
setBigFishError,
setSelectionStage,
],
);
const startBarkBattleRunFromWork = useCallback(
@@ -13334,8 +13398,9 @@ export function PlatformEntryFlowShellImpl({
);
} else if (isBarkBattleGalleryEntry(entry)) {
const work =
barkBattleGalleryEntries.find((item) => item.workId === entry.workId) ??
mapBarkBattlePublicDetailToWorkSummary(entry);
barkBattleGalleryEntries.find(
(item) => item.workId === entry.workId,
) ?? mapBarkBattlePublicDetailToWorkSummary(entry);
if (!work) {
setBarkBattleError(
'当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
@@ -13756,7 +13821,10 @@ export function PlatformEntryFlowShellImpl({
);
}
if (activeRecommendRuntimeKind === 'bark-battle' && barkBattlePublishedConfig) {
if (
activeRecommendRuntimeKind === 'bark-battle' &&
barkBattlePublishedConfig
) {
return (
<BarkBattleRuntimeShell
title={barkBattlePublishedConfig.title}
@@ -14147,7 +14215,9 @@ export function PlatformEntryFlowShellImpl({
if (isBarkBattleGalleryEntry(entry)) {
const matchedWork =
barkBattleWorks.find((work) => work.workId === entry.workId) ??
barkBattleGalleryEntries.find((work) => work.workId === entry.workId) ??
barkBattleGalleryEntries.find(
(work) => work.workId === entry.workId,
) ??
mapBarkBattlePublicDetailToWorkSummary(entry);
if (!matchedWork?.draftId?.trim()) {
setPublicWorkDetailError('这份汪汪声浪缺少可编辑草稿。');
@@ -15625,6 +15695,12 @@ export function PlatformEntryFlowShellImpl({
fallback={
<LazyPanelFallback label="正在加载抓大鹅共创工作区..." />
}
>
<UnifiedCreationPage
spec={getUnifiedCreationSpec(
'match3d',
unifiedCreationConfigById.get('match3d'),
)}
>
<Match3DAgentWorkspace
session={match3dSession}
@@ -15641,6 +15717,7 @@ export function PlatformEntryFlowShellImpl({
});
}}
/>
</UnifiedCreationPage>
</Suspense>
</motion.div>
)}
@@ -15656,7 +15733,8 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载抓大鹅生成面板..." />}
>
<CustomWorldGenerationView
<UnifiedGenerationPage
playId="match3d"
settingText={
match3dGenerationViewSession?.lastAssistantReply ??
'正在生成本局抓大鹅物品素材。'
@@ -15676,16 +15754,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('match3d-agent-workspace');
}}
onRetry={retryMatch3DDraftGeneration}
onInterrupt={undefined}
backLabel="返回创作中心"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前抓大鹅信息"
settingDescription={null}
progressTitle="抓大鹅草稿生成进度"
activeBadgeLabel="素材生成中"
pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区"
hideBatchModule
/>
</Suspense>
@@ -16412,6 +16480,12 @@ export function PlatformEntryFlowShellImpl({
>
<Suspense
fallback={<LazyPanelFallback label="正在加载敲木鱼创作..." />}
>
<UnifiedCreationPage
spec={getUnifiedCreationSpec(
'wooden-fish',
unifiedCreationConfigById.get('wooden-fish'),
)}
>
<WoodenFishWorkspace
isBusy={isWoodenFishBusy}
@@ -16421,6 +16495,7 @@ export function PlatformEntryFlowShellImpl({
void compileWoodenFishSession(result, payload);
}}
/>
</UnifiedCreationPage>
</Suspense>
</motion.div>
)}
@@ -16436,7 +16511,8 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载敲木鱼生成面板..." />}
>
<CustomWorldGenerationView
<UnifiedGenerationPage
playId="wooden-fish"
settingText={
woodenFishSession?.draft?.workTitle?.trim() ||
woodenFishSession?.draft?.workDescription?.trim() ||
@@ -16456,16 +16532,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('wooden-fish-workspace');
}}
onRetry={retryWoodenFishDraftGeneration}
onInterrupt={undefined}
backLabel="返回创作中心"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前敲木鱼信息"
settingDescription={null}
progressTitle="敲木鱼草稿生成进度"
activeBadgeLabel="素材生成中"
pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区"
/>
</Suspense>
</motion.div>
@@ -16569,6 +16635,12 @@ export function PlatformEntryFlowShellImpl({
>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
>
<UnifiedCreationPage
spec={getUnifiedCreationSpec(
'puzzle',
unifiedCreationConfigById.get('puzzle'),
)}
>
<PuzzleAgentWorkspace
session={puzzleSession}
@@ -16589,6 +16661,7 @@ export function PlatformEntryFlowShellImpl({
void savePuzzleFormDraft(payload);
}}
/>
</UnifiedCreationPage>
</Suspense>
</motion.div>
)}
@@ -16626,7 +16699,8 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图生成面板..." />}
>
<CustomWorldGenerationView
<UnifiedGenerationPage
playId="puzzle"
settingText={
puzzleGenerationViewSession?.lastAssistantReply ??
'正在整理当前拼图草稿。'
@@ -16646,15 +16720,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('puzzle-agent-workspace');
}}
onRetry={retryPuzzleDraftGeneration}
onInterrupt={undefined}
backLabel="返回创作中心"
settingActionLabel={null}
retryLabel="重新生成图片"
settingTitle="当前拼图信息"
settingDescription={null}
progressTitle="拼图图片生成进度"
activeBadgeLabel="图片生成中"
idleBadgeLabel="等待返回工作区"
hideBatchModule
/>
</Suspense>
@@ -17028,7 +17093,8 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'bark-battle-generating' && barkBattleDraftConfig && (
{selectionStage === 'bark-battle-generating' &&
barkBattleDraftConfig && (
<motion.div
key="bark-battle-generating"
initial={{ opacity: 0, y: 12 }}
@@ -17096,7 +17162,8 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && (
{selectionStage === 'bark-battle-runtime' &&
barkBattlePublishedConfig && (
<motion.div
key="bark-battle-runtime"
initial={{ opacity: 0, y: 12 }}

View File

@@ -0,0 +1,42 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { UnifiedCreationPage } from './UnifiedCreationPage';
import { getUnifiedCreationSpec } from './unifiedCreationSpecs';
describe('UnifiedCreationPage', () => {
test('按后端字段 spec 暴露统一创作页字段契约', () => {
render(
<UnifiedCreationPage spec={getUnifiedCreationSpec('wooden-fish')}>
<div></div>
</UnifiedCreationPage>,
);
const root = screen
.getByText('敲木鱼工作台')
.closest('.unified-creation-page');
expect(root?.getAttribute('data-play-id')).toBe('wooden-fish');
expect(root?.getAttribute('data-field-kinds')).toBe(
'text,image,audio,text',
);
expect(root?.getAttribute('data-workspace-stage')).toBe(
'wooden-fish-workspace',
);
expect(root?.getAttribute('data-generation-stage')).toBe(
'wooden-fish-generating',
);
expect(root?.getAttribute('data-result-stage')).toBe('wooden-fish-result');
const fields = screen.getAllByTestId('unified-creation-field');
expect(fields.map((field) => field.getAttribute('data-field-id'))).toEqual([
'hitObjectPrompt',
'hitObjectReferenceImage',
'hitSoundAsset',
'floatingWords',
]);
expect(fields[2]?.getAttribute('data-field-kind')).toBe('audio');
expect(fields[3]?.getAttribute('data-required')).toBe('true');
});
});

View File

@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
type UnifiedCreationPageProps = {
spec: UnifiedCreationSpec;
children: ReactNode;
};
export function UnifiedCreationPage({
spec,
children,
}: UnifiedCreationPageProps) {
return (
<div
className="unified-creation-page flex h-full min-h-0 flex-col"
data-play-id={spec.playId}
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
data-workspace-stage={spec.workspaceStage}
data-generation-stage={spec.generationStage}
data-result-stage={spec.resultStage}
>
<div className="sr-only" data-testid="unified-creation-spec">
<h1>{spec.title}</h1>
<ul>
{spec.fields.map((field) => (
<li
key={field.id}
data-testid="unified-creation-field"
data-field-id={field.id}
data-field-kind={field.kind}
data-required={field.required ? 'true' : 'false'}
>
{field.label}
</li>
))}
</ul>
</div>
{children}
</div>
);
}
export default UnifiedCreationPage;

View File

@@ -0,0 +1,53 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import { UnifiedGenerationPage } from './UnifiedGenerationPage';
function createProgress(): CustomWorldGenerationProgress {
return {
phaseId: 'puzzle-cover-image',
phaseLabel: '生成拼图首图',
phaseDetail: '正在生成图片。',
batchLabel: '生成拼图首图',
overallProgress: 36,
completedWeight: 36,
totalWeight: 100,
elapsedMs: 12_000,
estimatedRemainingMs: 30_000,
activeStepIndex: 0,
steps: [
{
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '正在生成图片。',
completed: 0.36,
total: 1,
status: 'active',
},
],
};
}
describe('UnifiedGenerationPage', () => {
test('按玩法下发统一生成页文案并透传进度', () => {
render(
<UnifiedGenerationPage
playId="puzzle"
settingText="一只发光的纸船"
progress={createProgress()}
isGenerating
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
/>,
);
expect(document.body.textContent).toContain('拼图图片生成进度');
expect(screen.getByText('图片生成中')).toBeTruthy();
expect(screen.getAllByText('生成拼图首图').length).toBeGreaterThan(0);
expect(screen.getByText('当前拼图信息')).toBeTruthy();
});
});

View File

@@ -0,0 +1,91 @@
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
type UnifiedGenerationPageProps = {
playId: UnifiedCreationPlayId;
settingText: string;
anchorEntries?: CustomWorldStructuredAnchorEntry[];
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error?: string | null;
onBack: () => void;
onEditSetting: () => void;
onRetry: () => void;
hideBatchModule?: boolean;
};
const UNIFIED_GENERATION_COPY = {
puzzle: {
retryLabel: '重新生成图片',
settingTitle: '当前拼图信息',
progressTitle: '拼图图片生成进度',
activeBadgeLabel: '图片生成中',
},
match3d: {
retryLabel: '重新生成草稿',
settingTitle: '当前抓大鹅信息',
progressTitle: '抓大鹅草稿生成进度',
activeBadgeLabel: '素材生成中',
},
'wooden-fish': {
retryLabel: '重新生成草稿',
settingTitle: '当前敲木鱼信息',
progressTitle: '敲木鱼草稿生成进度',
activeBadgeLabel: '素材生成中',
},
} as const satisfies Record<
UnifiedCreationPlayId,
{
retryLabel: string;
settingTitle: string;
progressTitle: string;
activeBadgeLabel: string;
}
>;
export function getUnifiedGenerationCopy(playId: UnifiedCreationPlayId) {
return UNIFIED_GENERATION_COPY[playId];
}
export function UnifiedGenerationPage({
playId,
settingText,
anchorEntries = [],
progress,
isGenerating,
error = null,
onBack,
onEditSetting,
onRetry,
hideBatchModule = false,
}: UnifiedGenerationPageProps) {
const copy = getUnifiedGenerationCopy(playId);
return (
<CustomWorldGenerationView
settingText={settingText}
anchorEntries={anchorEntries}
progress={progress}
isGenerating={isGenerating}
error={error}
onBack={onBack}
onEditSetting={onEditSetting}
onRetry={onRetry}
onInterrupt={undefined}
backLabel="返回创作中心"
settingActionLabel={null}
retryLabel={copy.retryLabel}
settingTitle={copy.settingTitle}
settingDescription={null}
progressTitle={copy.progressTitle}
activeBadgeLabel={copy.activeBadgeLabel}
pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区"
hideBatchModule={hideBatchModule}
/>
);
}
export default UnifiedGenerationPage;

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest';
import {
getUnifiedCreationSpec,
listUnifiedCreationSpecs,
} from './unifiedCreationSpecs';
describe('unified creation specs', () => {
test('一期只接拼图、抓大鹅和敲木鱼', () => {
expect(listUnifiedCreationSpecs().map((spec) => spec.playId).sort()).toEqual(
['match3d', 'puzzle', 'wooden-fish'],
);
});
test('字段模型只包含首期公共能力', () => {
const fieldKinds = new Set(
listUnifiedCreationSpecs().flatMap((spec) =>
spec.fields.map((field) => field.kind),
),
);
expect([...fieldKinds].sort()).toEqual(['audio', 'image', 'select', 'text']);
});
test('三条链路都映射到统一创作、生成、结果阶段', () => {
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
});
expect(getUnifiedCreationSpec('match3d')).toMatchObject({
workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating',
resultStage: 'match3d-result',
});
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result',
});
});
});

View File

@@ -0,0 +1,107 @@
import type {
CreationEntryTypeConfig,
UnifiedCreationSpec,
} from '../../services/creationEntryConfigService';
export type UnifiedCreationPlayId = UnifiedCreationSpec['playId'];
export type { UnifiedCreationSpec };
const FALLBACK_UNIFIED_CREATION_SPECS: Record<
UnifiedCreationPlayId,
UnifiedCreationSpec
> = {
puzzle: {
playId: 'puzzle',
title: '想做个什么玩法?',
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
fields: [
{
id: 'pictureDescription',
kind: 'text',
label: '画面描述',
required: true,
},
{
id: 'referenceImage',
kind: 'image',
label: '拼图画面',
required: false,
},
{
id: 'promptReferenceImages',
kind: 'image',
label: '参考图',
required: false,
},
],
},
match3d: {
playId: 'match3d',
title: '想做个什么玩法?',
workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating',
resultStage: 'match3d-result',
fields: [
{
id: 'themeText',
kind: 'text',
label: '题材',
required: true,
},
{
id: 'difficulty',
kind: 'select',
label: '难度',
required: true,
},
],
},
'wooden-fish': {
playId: 'wooden-fish',
title: '想做个什么玩法?',
workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result',
fields: [
{
id: 'hitObjectPrompt',
kind: 'text',
label: '敲什么',
required: false,
},
{
id: 'hitObjectReferenceImage',
kind: 'image',
label: '参考图',
required: false,
},
{
id: 'hitSoundAsset',
kind: 'audio',
label: '敲击音效',
required: false,
},
{
id: 'floatingWords',
kind: 'text',
label: '功德有什么',
required: true,
},
],
},
};
export function getUnifiedCreationSpec(
playId: UnifiedCreationPlayId,
configType?: CreationEntryTypeConfig | null,
) {
return (
configType?.unifiedCreationSpec ?? FALLBACK_UNIFIED_CREATION_SPECS[playId]
);
}
export function listUnifiedCreationSpecs() {
return Object.values(FALLBACK_UNIFIED_CREATION_SPECS);
}

View File

@@ -1,14 +1,11 @@
import {
ArrowLeft,
Loader2,
Mic,
Pause,
Plus,
Send,
X,
Upload,
} from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import type {
WoodenFishAudioAsset,
@@ -21,6 +18,7 @@ import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../services/wooden-fish/woodenFishDefaults';
import { CreativeAudioInputPanel } from '../common/CreativeAudioInputPanel';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
type WoodenFishWorkspaceProps = {
@@ -68,182 +66,6 @@ function normalizeFloatingWords(words: string[]) {
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
}
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
return new Promise<WoodenFishAudioAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
});
};
reader.readAsDataURL(file);
});
}
function WoodenFishAudioInputPanel({
disabled,
asset,
onAssetChange,
onError,
}: {
disabled: boolean;
asset: WoodenFishAudioAsset | null;
onAssetChange: (asset: WoodenFishAudioAsset | null) => void;
onError: (message: string | null) => void;
}) {
const [isRecording, setIsRecording] = useState(false);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const startRecording = async () => {
if (disabled || isRecording) {
return;
}
try {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
throw new Error('当前浏览器不支持录音。');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, {
type: recorder.mimeType || 'audio/webm',
});
stream.getTracks().forEach((track) => track.stop());
const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, {
type: blob.type,
});
void readAudioFileAsAsset(file, 'recorded')
.then(onAssetChange)
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '录音保存失败。',
);
});
};
recorderRef.current = recorder;
recorder.start();
setIsRecording(true);
onError(null);
} catch (caughtError) {
onError(
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
);
}
};
const stopRecording = () => {
recorderRef.current?.stop();
recorderRef.current = null;
setIsRecording(false);
};
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
{asset ? (
<button
type="button"
onClick={() => onAssetChange(null)}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
>
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<label
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
>
<Upload className="h-4 w-4" />
<input
type="file"
accept="audio/*"
disabled={disabled}
className="sr-only"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void readAudioFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
/>
</label>
<button
type="button"
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
void startRecording();
}}
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
{isRecording ? '停止' : '录音'}
</button>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : '默认木鱼音'}
</div>
)}
</div>
</section>
);
}
export function WoodenFishWorkspace({
isBusy = false,
error = null,
@@ -410,9 +232,12 @@ export function WoodenFishWorkspace({
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<WoodenFishAudioInputPanel
<CreativeAudioInputPanel<WoodenFishAudioAsset>
disabled={isBusy || isSubmitting}
title="敲击音效"
defaultLabel="默认木鱼音"
asset={formState.hitSoundAsset}
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
onAssetChange={(asset) =>
setFormState((current) => ({
...current,

View File

@@ -13,6 +13,29 @@ export type CreationEntryTypeConfig = {
categoryLabel: string;
categorySortOrder: number;
updatedAtMicros: number;
unifiedCreationSpec?: UnifiedCreationSpec | null;
};
export type UnifiedCreationField = {
id: string;
kind: 'text' | 'select' | 'image' | 'audio';
label: string;
required: boolean;
};
export type UnifiedCreationSpec = {
playId: 'puzzle' | 'match3d' | 'wooden-fish';
title: string;
workspaceStage:
| 'puzzle-agent-workspace'
| 'match3d-agent-workspace'
| 'wooden-fish-workspace';
generationStage:
| 'puzzle-generating'
| 'match3d-generating'
| 'wooden-fish-generating';
resultStage: 'puzzle-result' | 'match3d-result' | 'wooden-fish-result';
fields: UnifiedCreationField[];
};
export type CreationEntryConfig = {

View File

@@ -467,6 +467,24 @@ function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
export function resolveMiniGameDraftGenerationStartedAtMs(
startedAt: string | number | null | undefined,
fallbackMs = Date.now(),
) {
if (typeof startedAt === 'number' && Number.isFinite(startedAt)) {
return startedAt;
}
if (typeof startedAt === 'string') {
const parsed = Date.parse(startedAt);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallbackMs;
}
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'puzzle') {
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
@@ -542,6 +560,7 @@ function buildMiniGameProgressSteps(
export function createMiniGameDraftGenerationState(
kind: MiniGameDraftGenerationKind,
startedAtMs = Date.now(),
): MiniGameDraftGenerationState {
return {
kind,
@@ -559,7 +578,7 @@ export function createMiniGameDraftGenerationState(
: kind === 'wooden-fish'
? 'wooden-fish-draft'
: 'compile',
startedAtMs: Date.now(),
startedAtMs,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,