1
This commit is contained in:
@@ -586,4 +586,46 @@ describe('apiClient', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses api error details.reason when details.message is absent', async () => {
|
||||
setStoredAccessToken('details-reason-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 503,
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '服务暂不可用',
|
||||
details: {
|
||||
provider: 'vector-engine',
|
||||
reason: 'VECTOR_ENGINE_API_KEY 未配置',
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestJson(
|
||||
'/api/creation/match3d/sessions/test/actions',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'执行抓大鹅共创操作失败',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'VECTOR_ENGINE_API_KEY 未配置',
|
||||
status: 503,
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
details: {
|
||||
provider: 'vector-engine',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
fetchWithApiAuth: vi.fn(),
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
import { createCreationAgentClient } from './creationAgentClientFactory';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ session: { sessionId: 'session-1' } });
|
||||
});
|
||||
|
||||
test('creation agent action requests are not auto-retried by default', async () => {
|
||||
const client = createCreationAgentClient<
|
||||
Record<string, never>,
|
||||
{ session: { sessionId: string } },
|
||||
{ session: { sessionId: string } },
|
||||
{ sessionId: string },
|
||||
{ text: string },
|
||||
{ session: { sessionId: string } },
|
||||
{ action: string },
|
||||
{ session: { sessionId: string } }
|
||||
>({
|
||||
apiBase: '/api/runtime/puzzle/agent/sessions',
|
||||
messages: {
|
||||
createSession: '创建失败',
|
||||
getSession: '读取失败',
|
||||
sendMessage: '发送失败',
|
||||
streamIncomplete: '流式结果不完整',
|
||||
executeAction: '执行失败',
|
||||
},
|
||||
});
|
||||
|
||||
await client.executeAction('session-1', { action: 'compile_puzzle_draft' });
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/agent/sessions/session-1/actions',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'执行失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 0 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ type CreationAgentClientOptions = {
|
||||
executeActionTimeoutMs?: number;
|
||||
readRetry?: ApiRetryOptions;
|
||||
writeRetry?: ApiRetryOptions;
|
||||
executeActionRetry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -37,6 +38,10 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 0,
|
||||
};
|
||||
|
||||
function buildJsonPostInit(payload: unknown): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
@@ -88,6 +93,7 @@ export function createCreationAgentClient<
|
||||
executeActionTimeoutMs,
|
||||
readRetry = DEFAULT_CREATION_AGENT_READ_RETRY,
|
||||
writeRetry = DEFAULT_CREATION_AGENT_WRITE_RETRY,
|
||||
executeActionRetry = DEFAULT_CREATION_AGENT_ACTION_RETRY,
|
||||
}: CreationAgentClientOptions) {
|
||||
const createSession = (
|
||||
payload: TCreateSessionPayload,
|
||||
@@ -153,7 +159,7 @@ export function createCreationAgentClient<
|
||||
buildJsonPostInit(payload),
|
||||
messages.executeAction,
|
||||
{
|
||||
retry: writeRetry,
|
||||
retry: executeActionRetry,
|
||||
timeoutMs: executeActionTimeoutMs,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
clearMatch3DGeneratedModelBytesCache,
|
||||
getMatch3DGeneratedImageViewSources,
|
||||
getMatch3DGeneratedImageAssetSources,
|
||||
getMatch3DGeneratedModelAssetSources,
|
||||
hasMatch3DGeneratedImageAsset,
|
||||
preloadMatch3DGeneratedImageAssets,
|
||||
preloadMatch3DGeneratedModelAssets,
|
||||
readMatch3DGeneratedModelBytes,
|
||||
} from './match3dGeneratedModelCache';
|
||||
@@ -145,4 +148,119 @@ describe('match3dGeneratedModelCache', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态图片素材判断只认物品图片,不把背景或音频当物品素材', () => {
|
||||
expect(
|
||||
hasMatch3DGeneratedImageAsset([
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
backgroundMusic: {
|
||||
taskId: 'music-task-1',
|
||||
provider: 'vector-engine-suno',
|
||||
assetObjectId: 'asset-music-1',
|
||||
assetKind: 'match3d_background_music',
|
||||
audioSrc:
|
||||
'/generated-match3d-assets/session/profile/audio/background.mp3',
|
||||
prompt: '',
|
||||
title: '果园轻舞',
|
||||
updatedAt: '2026-05-13T10:00:00.000Z',
|
||||
},
|
||||
backgroundAsset: {
|
||||
prompt: '果园背景',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
imageObjectKey: null,
|
||||
containerPrompt: '果园浅盘',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
containerImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
hasMatch3DGeneratedImageAsset([
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-01',
|
||||
viewIndex: 1,
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
|
||||
imageObjectKey: null,
|
||||
},
|
||||
],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('运行态预加载使用 2D 图片源而不是旧模型源', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/view-01.png',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
const assets = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-01',
|
||||
viewIndex: 1,
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
|
||||
},
|
||||
],
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/model/model.glb',
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
];
|
||||
|
||||
expect(getMatch3DGeneratedImageAssetSources(assets)).toEqual([
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
|
||||
]);
|
||||
await preloadMatch3DGeneratedImageAssets(assets, { expireSeconds: 300 });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'views%2Fview-01.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
|
||||
import { readAssetBytes } from './assetReadUrlService';
|
||||
import { readAssetBytes, resolveAssetReadUrl } from './assetReadUrlService';
|
||||
|
||||
type CachedMatch3DModelBytes = {
|
||||
accessedAt: number;
|
||||
@@ -117,6 +117,14 @@ export function getMatch3DGeneratedImageAssetSources(
|
||||
];
|
||||
}
|
||||
|
||||
export function hasMatch3DGeneratedImageAsset(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
assets?.some((asset) => getMatch3DGeneratedImageViewSources(asset).length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedModelAssetSources(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
@@ -198,6 +206,28 @@ export function preloadMatch3DGeneratedModelAssets(
|
||||
);
|
||||
}
|
||||
|
||||
export async function preloadMatch3DGeneratedImageAssets(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
const sources = getMatch3DGeneratedImageAssetSources(assets);
|
||||
await Promise.allSettled(
|
||||
sources.map((source) =>
|
||||
resolveAssetReadUrl(source, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function preloadMatch3DGeneratedRuntimeAssets(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
// 中文注释:新抓大鹅运行态以 2D 图片为主;3D 模型只作为历史草稿预览兼容。
|
||||
await preloadMatch3DGeneratedImageAssets(assets, options);
|
||||
}
|
||||
|
||||
export function clearMatch3DGeneratedModelBytesCache() {
|
||||
match3dModelBytesCache.clear();
|
||||
}
|
||||
|
||||
@@ -20,23 +20,26 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 2500);
|
||||
|
||||
expect(progress?.steps.map((step) => step.label)).toEqual([
|
||||
'编译首关草稿',
|
||||
'生成关卡名称',
|
||||
'生成首关画面',
|
||||
'生成背景音乐',
|
||||
'生成UI背景',
|
||||
'写入正式草稿',
|
||||
]);
|
||||
expect(progress?.phaseLabel).toBe('编译首关草稿');
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'理解画面描述,生成首关名称与可编辑草稿。',
|
||||
'读取画面描述,建立可编辑草稿与首关结构。',
|
||||
);
|
||||
expect(progress?.estimatedRemainingMs).toBe(59_500);
|
||||
expect(progress?.estimatedRemainingMs).toBe(178_500);
|
||||
expect(progress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('puzzle draft generation advances steps across the 60 second estimate', () => {
|
||||
test('puzzle draft generation advances steps across the current asset pipeline', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
@@ -46,18 +49,23 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const imageProgress = buildMiniGameDraftGenerationProgress(state, 16_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 56_000);
|
||||
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
|
||||
const musicProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
|
||||
const uiProgress = buildMiniGameDraftGenerationProgress(state, 146_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 176_000);
|
||||
|
||||
expect(imageProgress?.phaseId).toBe('puzzle-images');
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(45_000);
|
||||
expect(imageProgress?.steps[0]?.status).toBe('completed');
|
||||
expect(imageProgress?.steps[1]?.status).toBe('active');
|
||||
expect(imageProgress?.steps[1]?.completed).toBeGreaterThan(0);
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(155_000);
|
||||
expect(imageProgress?.steps[1]?.status).toBe('completed');
|
||||
expect(imageProgress?.steps[2]?.status).toBe('active');
|
||||
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
|
||||
expect(musicProgress?.phaseId).toBe('puzzle-background-music');
|
||||
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
|
||||
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
|
||||
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
|
||||
expect(writeBackProgress?.steps[1]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[2]?.status).toBe('active');
|
||||
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[5]?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('puzzle draft generation keeps moving without claiming completion before response', () => {
|
||||
@@ -70,12 +78,12 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 80_000);
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 200_000);
|
||||
|
||||
expect(progress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(progress?.overallProgress).toBe(98);
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps[2]?.completed).toBe(1);
|
||||
expect(progress?.steps[5]?.completed).toBe(1);
|
||||
});
|
||||
|
||||
test('puzzle ready copy points to result page work info completion', () => {
|
||||
@@ -158,20 +166,24 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 17_000,
|
||||
state.startedAtMs + 30_000,
|
||||
);
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'match3d-work-title',
|
||||
'match3d-item-names',
|
||||
'match3d-background-prompt',
|
||||
'match3d-material-sheet',
|
||||
'match3d-slice-images',
|
||||
'match3d-upload-images',
|
||||
'match3d-generate-views',
|
||||
'match3d-background-music',
|
||||
'match3d-background-image',
|
||||
'match3d-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('match3d-material-sheet');
|
||||
expect(progress?.phaseLabel).toBe('生成素材图');
|
||||
expect(progress?.estimatedRemainingMs).toBe(583_000);
|
||||
expect(progress?.phaseLabel).toBe('分批生成素材图');
|
||||
expect(progress?.estimatedRemainingMs).toBe(570_000);
|
||||
});
|
||||
|
||||
test('match3d draft generation starts from title generation', () => {
|
||||
@@ -183,8 +195,10 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-work-title');
|
||||
expect(progress?.phaseLabel).toBe('生成游戏名称');
|
||||
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
|
||||
expect(progress?.phaseLabel).toBe('建立草稿存档');
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'创建可恢复作品草稿,锁定本次题材和难度。',
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d draft generation keeps backend observed asset phase', () => {
|
||||
@@ -201,9 +215,33 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-generate-views');
|
||||
expect(progress?.steps.at(-1)?.detail).toContain('点击音效');
|
||||
expect(progress?.steps.at(-1)?.completed).toBe(1);
|
||||
expect(progress?.steps.at(-1)?.total).toBe(3);
|
||||
expect(progress?.steps[6]?.detail).toContain('音效提示词');
|
||||
expect(progress?.steps[6]?.completed).toBe(1);
|
||||
expect(progress?.steps[6]?.total).toBe(3);
|
||||
});
|
||||
|
||||
test('match3d draft generation reaches music, background image and writeback phases', () => {
|
||||
const state = createMiniGameDraftGenerationState('match3d');
|
||||
|
||||
const musicProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 400_000,
|
||||
);
|
||||
const backgroundProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 500_000,
|
||||
);
|
||||
const writeProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 550_000,
|
||||
);
|
||||
|
||||
expect(musicProgress?.phaseId).toBe('match3d-background-music');
|
||||
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
|
||||
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
|
||||
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
|
||||
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
|
||||
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
|
||||
});
|
||||
|
||||
test('match3d generation anchors show theme and difficulty item count', () => {
|
||||
@@ -223,7 +261,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
{
|
||||
id: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: '21 件',
|
||||
value: '25 件',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export type MiniGameDraftGenerationKind =
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'big-fish-draft'
|
||||
| 'big-fish-levels'
|
||||
| 'big-fish-runtime'
|
||||
@@ -37,15 +38,21 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'square-hole-ready'
|
||||
| 'match3d-work-title'
|
||||
| 'match3d-item-names'
|
||||
| 'match3d-background-prompt'
|
||||
| 'match3d-material-sheet'
|
||||
| 'match3d-slice-images'
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-generate-views'
|
||||
| 'match3d-background-music'
|
||||
| 'match3d-background-image'
|
||||
| 'match3d-write-draft'
|
||||
| 'match3d-ready'
|
||||
| 'baby-object-draft'
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-background-music'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
@@ -76,35 +83,61 @@ const PUZZLE_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译首关草稿',
|
||||
detail: '理解画面描述,生成首关名称与可编辑草稿。',
|
||||
weight: 20,
|
||||
detail: '读取画面描述,建立可编辑草稿与首关结构。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-level-name',
|
||||
label: '生成关卡名称',
|
||||
detail: '根据画面描述和图像语义整理首关题目。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-images',
|
||||
label: '生成首关画面',
|
||||
detail: '调用图片模型生成适合切块的正方形首图。',
|
||||
weight: 70,
|
||||
weight: 42,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-background-music',
|
||||
label: '生成背景音乐',
|
||||
detail: '用作品题目生成纯音乐并转存音频资产。',
|
||||
weight: 18,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-ui-background',
|
||||
label: '生成UI背景',
|
||||
detail: '生成不含槽位和控件的 9:16 纯背景。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '写入正式草稿',
|
||||
detail: '确认首图并同步关卡数据,准备进入结果页。',
|
||||
weight: 10,
|
||||
detail: '写入首图、音乐、UI背景和首关数据。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const PUZZLE_ESTIMATED_WAIT_MS = 60_000;
|
||||
const PUZZLE_ESTIMATED_WAIT_MS = 180_000;
|
||||
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
|
||||
const PUZZLE_PHASE_TIMELINE: Array<{
|
||||
phase: Extract<
|
||||
MiniGameDraftGenerationPhase,
|
||||
'compile' | 'puzzle-images' | 'puzzle-select-image'
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-background-music'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
>;
|
||||
durationMs: number;
|
||||
}> = [
|
||||
{ phase: 'compile', durationMs: 12_000 },
|
||||
{ phase: 'puzzle-images', durationMs: 42_000 },
|
||||
{ phase: 'puzzle-select-image', durationMs: 6_000 },
|
||||
{ phase: 'puzzle-level-name', durationMs: 8_000 },
|
||||
{ phase: 'puzzle-images', durationMs: 70_000 },
|
||||
{ phase: 'puzzle-background-music', durationMs: 48_000 },
|
||||
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
|
||||
{ phase: 'puzzle-select-image', durationMs: 10_000 },
|
||||
];
|
||||
|
||||
const BIG_FISH_STEPS = [
|
||||
@@ -152,39 +185,63 @@ const SQUARE_HOLE_STEPS = [
|
||||
const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-work-title',
|
||||
label: '生成游戏名称',
|
||||
detail: '根据题材设定生成作品名称与标签。',
|
||||
label: '建立草稿存档',
|
||||
detail: '创建可恢复作品草稿,锁定本次题材和难度。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'match3d-item-names',
|
||||
label: '生成物品名称',
|
||||
detail: '根据难度生成本局物品名称。',
|
||||
weight: 8,
|
||||
label: '生成作品计划',
|
||||
detail: '生成游戏名称、物品名称、音乐名称与标签。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-prompt',
|
||||
label: '生成背景提示词',
|
||||
detail: '整理纯背景图与容器 UI 图提示词。',
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'match3d-material-sheet',
|
||||
label: '生成素材图',
|
||||
label: '分批生成素材图',
|
||||
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
|
||||
weight: 18,
|
||||
weight: 22,
|
||||
},
|
||||
{
|
||||
id: 'match3d-slice-images',
|
||||
label: '切割独立图片',
|
||||
detail: '把素材图切成每个物品的五个视角。',
|
||||
weight: 8,
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'match3d-upload-images',
|
||||
label: '上传图片资产',
|
||||
detail: '写入独立 2D 视角素材。',
|
||||
weight: 8,
|
||||
detail: '上传每个物品的 2D 五视角素材。',
|
||||
weight: 12,
|
||||
},
|
||||
{
|
||||
id: 'match3d-generate-views',
|
||||
label: '整理素材',
|
||||
detail: '校验多视角素材并按需并行生成点击音效。',
|
||||
weight: 50,
|
||||
label: '校验素材结构',
|
||||
detail: '确认物品顺序、五视角图片和音效提示词。',
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-music',
|
||||
label: '生成背景音乐',
|
||||
detail: '用音乐名称生成纯音乐并转存音频资产。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-image',
|
||||
label: '生成UI背景',
|
||||
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
|
||||
weight: 16,
|
||||
},
|
||||
{
|
||||
id: 'match3d-write-draft',
|
||||
label: '写入草稿页',
|
||||
detail: '保存素材、音乐、背景、容器和作品草稿。',
|
||||
weight: 2,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
@@ -193,10 +250,14 @@ const MATCH3D_PHASE_ORDER: Partial<
|
||||
> = {
|
||||
'match3d-work-title': 0,
|
||||
'match3d-item-names': 1,
|
||||
'match3d-material-sheet': 2,
|
||||
'match3d-slice-images': 3,
|
||||
'match3d-upload-images': 4,
|
||||
'match3d-generate-views': 5,
|
||||
'match3d-background-prompt': 2,
|
||||
'match3d-material-sheet': 3,
|
||||
'match3d-slice-images': 4,
|
||||
'match3d-upload-images': 5,
|
||||
'match3d-generate-views': 6,
|
||||
'match3d-background-music': 7,
|
||||
'match3d-background-image': 8,
|
||||
'match3d-write-draft': 9,
|
||||
};
|
||||
|
||||
const BABY_OBJECT_MATCH_STEPS = [
|
||||
@@ -331,17 +392,25 @@ function resolveMatch3DPhaseByElapsedMs(
|
||||
currentPhase: MiniGameDraftGenerationPhase,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
const elapsedPhase =
|
||||
elapsedMs >= 92_000
|
||||
? 'match3d-generate-views'
|
||||
: elapsedMs >= 72_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 58_000
|
||||
? 'match3d-slice-images'
|
||||
: elapsedMs >= 16_000
|
||||
? 'match3d-material-sheet'
|
||||
: elapsedMs >= 4_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
elapsedMs >= 540_000
|
||||
? 'match3d-write-draft'
|
||||
: elapsedMs >= 460_000
|
||||
? 'match3d-background-image'
|
||||
: elapsedMs >= 370_000
|
||||
? 'match3d-background-music'
|
||||
: elapsedMs >= 340_000
|
||||
? 'match3d-generate-views'
|
||||
: elapsedMs >= 260_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 210_000
|
||||
? 'match3d-slice-images'
|
||||
: elapsedMs >= 28_000
|
||||
? 'match3d-material-sheet'
|
||||
: elapsedMs >= 12_000
|
||||
? 'match3d-background-prompt'
|
||||
: elapsedMs >= 4_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
|
||||
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
|
||||
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
|
||||
@@ -645,18 +714,19 @@ function resolveMatch3DGeneratedItemCount(
|
||||
clearCount: number | null | undefined,
|
||||
difficulty: number | null | undefined,
|
||||
) {
|
||||
if (clearCount === 8) return 3;
|
||||
if (clearCount === 12) return 9;
|
||||
if (clearCount === 16) return 15;
|
||||
if (clearCount === 20 || clearCount === 21) return 21;
|
||||
const roundToSheet = (count: number) => Math.ceil(count / 5) * 5;
|
||||
if (clearCount === 8) return roundToSheet(3);
|
||||
if (clearCount === 12) return roundToSheet(9);
|
||||
if (clearCount === 16) return roundToSheet(15);
|
||||
if (clearCount === 20 || clearCount === 21) return roundToSheet(21);
|
||||
const normalizedDifficulty =
|
||||
typeof difficulty === 'number' && Number.isFinite(difficulty)
|
||||
? Math.max(1, Math.min(10, Math.round(difficulty)))
|
||||
: 4;
|
||||
if (normalizedDifficulty <= 2) return 3;
|
||||
if (normalizedDifficulty <= 4) return 9;
|
||||
if (normalizedDifficulty <= 6) return 15;
|
||||
return 21;
|
||||
if (normalizedDifficulty <= 2) return roundToSheet(3);
|
||||
if (normalizedDifficulty <= 4) return roundToSheet(9);
|
||||
if (normalizedDifficulty <= 6) return roundToSheet(15);
|
||||
return roundToSheet(21);
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchGenerationAnchorEntries(
|
||||
|
||||
@@ -535,6 +535,45 @@ describe('puzzleLocalRuntime', () => {
|
||||
).toBe('explicit-level');
|
||||
});
|
||||
|
||||
test('本地试玩继承关卡 UI 背景和背景音乐资源', () => {
|
||||
const workWithRuntimeAssets: PuzzleWorkSummary = {
|
||||
...baseWork,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '第一关',
|
||||
pictureDescription: '第一关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-1.png',
|
||||
coverAssetId: null,
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
backgroundMusic: {
|
||||
taskId: 'audio-task-1',
|
||||
provider: 'vector-engine',
|
||||
assetObjectId: 'asset-audio-1',
|
||||
assetKind: 'puzzle_background_music',
|
||||
audioSrc: '/generated-puzzle-assets/session/audio.mp3',
|
||||
prompt: '雨夜猫街音乐',
|
||||
title: '雨夜猫街',
|
||||
updatedAt: '2026-05-12T00:00:00.000Z',
|
||||
},
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const run = startLocalPuzzleRun(workWithRuntimeAssets);
|
||||
|
||||
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(run.currentLevel?.backgroundMusic?.audioSrc).toBe(
|
||||
'/generated-puzzle-assets/session/audio.mp3',
|
||||
);
|
||||
});
|
||||
|
||||
test('暂停和冻结时间不会消耗本地倒计时', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const pausedRun = setLocalPuzzlePaused(
|
||||
|
||||
@@ -802,6 +802,10 @@ function buildFallbackLocalLevel(
|
||||
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
|
||||
const nextCoverImageSrc =
|
||||
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
|
||||
const nextUiBackgroundImageSrc =
|
||||
nextLevel?.uiBackgroundImageSrc ?? currentLevel.uiBackgroundImageSrc;
|
||||
const nextBackgroundMusic =
|
||||
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
|
||||
|
||||
const nextRun: PuzzleRunSnapshot = {
|
||||
...run,
|
||||
@@ -830,6 +834,8 @@ function buildFallbackLocalLevel(
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
coverImageSrc: nextCoverImageSrc,
|
||||
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
|
||||
backgroundMusic: nextBackgroundMusic,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
@@ -854,6 +860,8 @@ export function startLocalPuzzleRun(
|
||||
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
|
||||
const firstLevelName = firstLevel?.levelName || item.levelName;
|
||||
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
||||
const firstUiBackgroundImageSrc = firstLevel?.uiBackgroundImageSrc ?? null;
|
||||
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
|
||||
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
|
||||
return {
|
||||
runId,
|
||||
@@ -873,6 +881,8 @@ export function startLocalPuzzleRun(
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: firstCoverImageSrc,
|
||||
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
|
||||
backgroundMusic: firstBackgroundMusic,
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
|
||||
Reference in New Issue
Block a user