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

This commit is contained in:
2026-05-01 16:26:19 +08:00
30 changed files with 2104 additions and 436 deletions

View File

@@ -10,6 +10,8 @@ import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
@@ -30,6 +32,20 @@ import {
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
clickMatch3DItem,
finishMatch3DTimeUp,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import {
createPuzzleAgentSession,
getPuzzleAgentSession,
@@ -242,10 +258,34 @@ vi.mock('../../services/big-fish-gallery', () => ({
vi.mock('../../services/big-fish-runtime', () => ({
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
recordBigFishPlay: vi.fn(),
recordBigFishPlay: vi.fn(() => Promise.resolve()),
startLocalBigFishRuntimeRun: vi.fn(),
}));
vi.mock('../../services/match3d-creation', () => ({
match3dCreationClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getSession: vi.fn(),
streamMessage: vi.fn(),
},
}));
vi.mock('../../services/match3d-works', () => ({
deleteMatch3DWork: vi.fn(),
getMatch3DWorkDetail: vi.fn(),
listMatch3DGallery: vi.fn(),
listMatch3DWorks: vi.fn(),
}));
vi.mock('../../services/match3d-runtime', () => ({
clickMatch3DItem: vi.fn(),
finishMatch3DTimeUp: vi.fn(),
restartMatch3DRun: vi.fn(),
startMatch3DRun: vi.fn(),
stopMatch3DRun: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
const actual = await vi.importActual<
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
@@ -399,6 +439,23 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
),
}));
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
Match3DRuntimeShell: ({
run,
onBack,
}: {
run: Match3DRunSnapshot | null;
onBack: () => void;
}) => (
<div className="match3d-runtime-shell-mock">
<div>{run?.runId ?? 'missing-run'}</div>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
@@ -599,6 +656,26 @@ function buildClearedPuzzleRun(params: {
};
}
function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
return {
runId: `match3d-run-${profileId}`,
profileId,
ownerUserId: 'user-2',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1_000,
durationLimitMs: 600_000,
serverNowMs: 1_000,
remainingMs: 600_000,
clearCount: 4,
totalItemCount: 12,
clearedItemCount: 0,
items: [],
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
failureReason: null,
};
}
function buildMockRpgGalleryDetail(
entry: CustomWorldGalleryCard,
): CustomWorldLibraryEntry<CustomWorldProfile> {
@@ -1056,6 +1133,7 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
async (ownerUserId, profileId) => ({
ownerUserId,
@@ -1500,8 +1578,43 @@ beforeEach(() => {
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(recordBigFishPlay).mockResolvedValue({
session: {} as never,
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: null,
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: null,
});
vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue(null);
vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({
session: null,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [],
});
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [],
});
vi.mocked(getMatch3DWorkDetail).mockRejectedValue(
new Error('未找到抓大鹅作品'),
);
vi.mocked(deleteMatch3DWork).mockResolvedValue({
items: [],
});
vi.mocked(startMatch3DRun).mockRejectedValue(
new Error('未启动抓大鹅运行态'),
);
vi.mocked(clickMatch3DItem).mockRejectedValue(
new Error('未执行抓大鹅点击'),
);
vi.mocked(restartMatch3DRun).mockRejectedValue(
new Error('未重新开始抓大鹅运行态'),
);
vi.mocked(finishMatch3DTimeUp).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-time-up'),
});
vi.mocked(stopMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
runId: 'big-fish-run-1',
@@ -2057,7 +2170,7 @@ test('logged out public detail gates big fish start before local runtime', async
);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2518,7 +2631,6 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
const button = screen.getByRole('button', { name: /.*/u });
await user.click(button);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(
@@ -2527,9 +2639,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
).length,
).toBeGreaterThan(0);
});
expect(
screen.getByRole('button', { name: '生成草稿' }) as HTMLButtonElement,
).toHaveProperty('disabled', false);
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
expect(screen.queryByText(//u)).toBeNull();
});
@@ -2734,7 +2844,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2807,7 +2917,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2850,7 +2960,7 @@ test('public code search opens a published big fish work by BF code', async () =
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2872,6 +2982,56 @@ test('public code search opens a published big fish work by BF code', async () =
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens a published Match3D work by M3 code and starts runtime', async () => {
const user = userEvent.setup();
const match3dWork: Match3DWorkSummary = {
workId: 'match3d-work-public-1',
profileId: 'match3d-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-public-1',
gameName: '水果抓大鹅',
themeText: '水果消除',
summary: '把圆形空间里的水果全部消除。',
tags: ['水果', '消除'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 4,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dWork],
});
vi.mocked(startMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun(match3dWork.profileId),
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'M3-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
});
expect(
await screen.findByText('抓大鹅运行态match3d-run-match3d-profile-public-1'),
).toBeTruthy();
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();

View File

@@ -620,7 +620,7 @@ test('mobile home search submits public work code', async () => {
);
const searchInput = screen.getByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
'输入 SY / CW / BF / M3 / PZ 编号',
);
await user.type(searchInput, 'PZ-PROFILE1{enter}');

View File

@@ -70,6 +70,7 @@ import {
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
isBigFishGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
@@ -303,7 +304,7 @@ function PublicCodeSearchBar({
onSubmit();
}
}}
placeholder="输入 SY / CW / BF / PZ 编号"
placeholder="输入 SY / CW / BF / M3 / PZ 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
@@ -1020,7 +1021,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
: isMatch3DGalleryEntry(entry)
? 'match3d'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -1029,7 +1032,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode);
: isMatch3DGalleryEntry(entry)
? '抓鹅'
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind);
}

View File

@@ -1,4 +1,5 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
@@ -9,6 +10,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
@@ -20,6 +22,7 @@ export type PlatformWorldCardLike =
| CustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile>
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformPuzzleGalleryCard;
export type PlatformPuzzleGalleryCard = {
@@ -71,9 +74,31 @@ export type PlatformBigFishGalleryCard = {
updatedAt: string;
};
export type PlatformMatch3DGalleryCard = {
sourceType: 'match3d';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformPuzzleGalleryCard;
export function isLibraryWorldEntry(
@@ -94,6 +119,12 @@ export function isBigFishGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'big-fish';
}
export function isMatch3DGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformMatch3DGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'match3d';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -120,6 +151,31 @@ export function mapPuzzleWorkToPlatformGalleryCard(
};
}
export function mapMatch3DWorkToPlatformGalleryCard(
work: Match3DWorkSummary,
): PlatformMatch3DGalleryCard {
return {
sourceType: 'match3d',
workId: work.workId,
profileId: work.profileId,
publicWorkCode: buildMatch3DPublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: '玩家',
worldName: work.gameName,
subtitle: '经典消除玩法',
summaryText: work.summary,
coverImageSrc: work.coverImageSrc ?? null,
themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '抓大鹅'],
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: work.publishedAt ?? null,
updatedAt: work.updatedAt,
};
}
export function mapBigFishWorkToPlatformGalleryCard(
work: BigFishWorkSummary,
): PlatformBigFishGalleryCard {
@@ -307,6 +363,10 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
}
if (isMatch3DGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅'];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
@@ -381,6 +441,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isMatch3DGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}