Update Match3D/image-generation docs & code

Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
2026-05-14 20:34:45 +08:00
parent d33c937ebc
commit 548db78ca7
103 changed files with 6687 additions and 3270 deletions

View File

@@ -18,7 +18,9 @@ export function RpgEntryBrandLogo({
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : '陶泥儿 GENARRATIVE'}
>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__title">
<span className="platform-brand-logo__title-suffix"></span>
</span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
);

View File

@@ -94,6 +94,7 @@ import {
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import {
@@ -289,10 +290,10 @@ const testCreationEntryConfig = {
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '可创建',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: true,
visible: false,
open: false,
sortOrder: 60,
updatedAtMicros: 1,
},
@@ -542,6 +543,10 @@ vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
return {
...actual,
dragLocalPuzzlePiece: vi.fn(actual.dragLocalPuzzlePiece),
startLocalPuzzleRun: vi.fn(
(...args: Parameters<typeof actual.startLocalPuzzleRun>) =>
actual.startLocalPuzzleRun(...args),
),
swapLocalPuzzlePieces: vi.fn(actual.swapLocalPuzzlePieces),
};
});
@@ -810,10 +815,12 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
Match3DRuntimeShell: ({
run,
generatedItemAssets = [],
generatedBackgroundAsset = null,
onBack,
}: {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DWorkSummary['generatedItemAssets'];
generatedBackgroundAsset?: Match3DWorkSummary['generatedBackgroundAsset'];
onBack: () => void;
}) => (
<div className="match3d-runtime-shell-mock">
@@ -873,6 +880,22 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
).length
}
</div>
<div data-testid="match3d-runtime-top-level-background-count">
{
generatedBackgroundAsset?.imageSrc?.trim() ||
generatedBackgroundAsset?.imageObjectKey?.trim()
? 1
: 0
}
</div>
<div data-testid="match3d-runtime-top-level-container-ui-count">
{
generatedBackgroundAsset?.containerImageSrc?.trim() ||
generatedBackgroundAsset?.containerImageObjectKey?.trim()
? 1
: 0
}
</div>
<button type="button" onClick={onBack}>
</button>
@@ -2672,6 +2695,30 @@ beforeEach(() => {
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(startLocalPuzzleRun).mockImplementation((item, levelId) => {
const runId = `local-puzzle-run-${item.profileId}`;
const firstLevel = item.levels?.[0] ?? null;
return {
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName),
runId,
entryProfileId: item.profileId,
currentLevel: {
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName)
.currentLevel!,
runId,
levelId: levelId ?? firstLevel?.levelId ?? null,
coverImageSrc: firstLevel?.coverImageSrc ?? item.coverImageSrc,
uiBackgroundImageSrc:
firstLevel?.uiBackgroundImageSrc ??
(firstLevel?.uiBackgroundImageObjectKey
? `/${firstLevel.uiBackgroundImageObjectKey.replace(/^\/+/u, '')}`
: null),
uiBackgroundImageObjectKey:
firstLevel?.uiBackgroundImageObjectKey ?? null,
backgroundMusic: firstLevel?.backgroundMusic ?? null,
},
};
});
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
async (runId, payload) => ({
run: {
@@ -2795,9 +2842,6 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
).toContain('/creation-type-references/visual-novel.webp');
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
@@ -2814,6 +2858,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull();
expect(screen.queryByRole('tab', { name: '视觉小说' })).toBeNull();
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
@@ -3875,27 +3920,46 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
await openCreateTemplateHub(user);
await user.click(screen.getByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('雨夜猫街')).toBeTruthy();
expect(updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-auto-1',
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
await waitFor(() => {
expect(updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-auto-1',
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
}),
}),
}),
],
],
}),
);
});
await waitFor(() => {
expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1);
});
const runtimeWork = vi.mocked(startLocalPuzzleRun).mock.calls[0]?.[0];
expect(runtimeWork?.levels?.[0]).toEqual(
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
}),
);
const runtimeSnapshot = vi.mocked(startLocalPuzzleRun).mock.results[0]?.value;
expect(runtimeSnapshot?.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
);
expect(screen.queryByText('拼图结果页')).toBeNull();
await user.click(screen.getByRole('button', { name: '返回上一页' }));
await user.click(
await screen.findByRole('button', { name: '返回上一页' }),
);
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
@@ -4950,6 +5014,80 @@ test('home recommendation Match3D runtime keeps image, music and UI assets witho
);
});
test('home recommendation Match3D runtime passes top-level UI background assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-top-level-ui',
profileId: 'match3d-profile-card-top-level-ui',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-top-level-ui',
gameName: '果园抓大鹅',
themeText: '果园',
summary: '消除果园素材。',
tags: ['果园', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
backgroundImageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
});
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-top-level-ui',
);
});
test('home recommendation Match3D runtime reloads detail when card only has UI assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-ui-only',

View File

@@ -2,6 +2,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
@@ -117,6 +118,7 @@ export type PlatformMatch3DGalleryCard = {
backgroundPrompt?: string | null;
backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null;
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
};
@@ -298,6 +300,7 @@ export function mapMatch3DWorkToPlatformGalleryCard(
backgroundPrompt: work.backgroundPrompt ?? null,
backgroundImageSrc: work.backgroundImageSrc ?? null,
backgroundImageObjectKey: work.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: work.generatedBackgroundAsset ?? null,
generatedItemAssets: work.generatedItemAssets ?? [],
};
}