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

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
@@ -44,6 +45,9 @@ type CustomWorldCreationHubProps = {
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
match3dItems?: Match3DWorkSummary[];
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
@@ -130,6 +134,9 @@ export function CustomWorldCreationHub({
bigFishItems = [],
onOpenBigFishDetail,
onDeleteBigFish = null,
match3dItems = [],
onOpenMatch3DDetail,
onDeleteMatch3D = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
@@ -144,15 +151,19 @@ export function CustomWorldCreationHub({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
puzzleItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeletePuzzle: Boolean(onDeletePuzzle),
}),
[
bigFishItems,
items,
match3dItems,
onDeleteBigFish,
onDeleteMatch3D,
onDeletePublished,
onDeletePuzzle,
puzzleItems,
@@ -187,6 +198,9 @@ export function CustomWorldCreationHub({
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
@@ -217,6 +231,12 @@ export function CustomWorldCreationHub({
onDeleteBigFish?.(sourceItem);
};
}
case 'match3d': {
const sourceItem = item.source.item;
return () => {
onDeleteMatch3D?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {

View File

@@ -1,15 +1,17 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'match3d' | 'puzzle';
export type CreationWorkShelfStatus = 'draft' | 'published';
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
@@ -50,6 +52,10 @@ export type CreationWorkShelfSource =
kind: 'big-fish';
item: BigFishWorkSummary;
}
| {
kind: 'match3d';
item: Match3DWorkSummary;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
@@ -80,18 +86,22 @@ export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems: BigFishWorkSummary[];
match3dItems?: Match3DWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
canDeleteMatch3D?: boolean;
canDeletePuzzle?: boolean;
}) {
const {
rpgItems,
rpgLibraryEntries = [],
bigFishItems,
match3dItems = [],
puzzleItems,
canDeleteRpg = false,
canDeleteBigFish = false,
canDeleteMatch3D = false,
canDeletePuzzle = false,
} = params;
@@ -102,6 +112,9 @@ export function buildCreationWorkShelfItems(params: {
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
),
...match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
),
@@ -203,6 +216,48 @@ function mapBigFishWorkToShelfItem(
};
}
function mapMatch3DWorkToShelfItem(
item: Match3DWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildMatch3DPublicWorkCode(item.profileId) : null;
return {
id: item.workId,
kind: 'match3d',
status,
title: item.gameName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '抓鹅', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
source: { kind: 'match3d', item },
};
}
function mapPuzzleWorkToShelfItem(
item: PuzzleWorkSummary,
canDelete: boolean,

View File

@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as match3dWorksService from '../../services/match3d-works';
import { Match3DResultView } from './Match3DResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/match3d-works', () => ({
publishMatch3DWork: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
function createProfile(
overrides: Partial<Match3DWorkProfile> = {},
): Match3DWorkProfile {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '水果主题的经典消除玩法。',
tags: ['水果'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 4,
difficulty: 3,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
describe('Match3DResultView', () => {
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
const profile = createProfile();
const onStartTestRun = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
clearCount: 4,
difficulty: 3,
gameName: '水果抓大鹅',
}),
);
});
expect(onStartTestRun).toHaveBeenCalledWith(profile);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
test('发布仍要求封面和标签数量满足门槛', () => {
render(
<Match3DResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
const publishButton = screen.getByRole('button', { name: '发布' });
expect(publishButton).toHaveProperty('disabled', true);
fireEvent.click(publishButton);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,588 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
Send,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
import type {
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
publishMatch3DWork,
updateMatch3DWork,
} from '../../services/match3d-works';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type Match3DResultViewProps = {
profile: Match3DWorkProfile;
draft?: Match3DResultDraft | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSaved?: (profile: Match3DWorkProfile) => void;
onPublished?: (profile: Match3DWorkProfile) => void;
onStartTestRun: (profile: Match3DWorkProfile) => void;
};
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type Match3DResultEditState = {
gameName: string;
summary: string;
tagsText: string;
coverImageSrc: string;
themeText: string;
clearCountText: string;
difficultyText: string;
};
const MATCH3D_MIN_TAG_COUNT = 3;
const MATCH3D_MAX_TAG_COUNT = 6;
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
function normalizeTags(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
function normalizePositiveInteger(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function normalizeDifficulty(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10
? parsed
: null;
}
function createEditState(profile: Match3DWorkProfile): Match3DResultEditState {
return {
gameName: profile.gameName,
summary: profile.summary,
tagsText: profile.tags.join(''),
coverImageSrc:
profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '',
themeText: profile.themeText,
clearCountText: String(profile.clearCount),
difficultyText: String(profile.difficulty),
};
}
function buildSavePayload(
editState: Match3DResultEditState,
): PutMatch3DWorkRequest | null {
const clearCount = normalizePositiveInteger(editState.clearCountText);
const difficulty = normalizeDifficulty(editState.difficultyText);
const gameName = editState.gameName.trim();
const themeText = editState.themeText.trim();
const summary = editState.summary.trim();
const tags = normalizeTags(editState.tagsText);
if (!gameName || !themeText || !summary || !clearCount || !difficulty) {
return null;
}
return {
gameName,
themeText,
summary,
tags,
coverImageSrc: editState.coverImageSrc.trim() || null,
clearCount,
difficulty,
};
}
function buildPublishBlockers(editState: Match3DResultEditState) {
const tags = normalizeTags(editState.tagsText);
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']),
...(tags.length >= MATCH3D_MIN_TAG_COUNT && tags.length <= MATCH3D_MAX_TAG_COUNT
? []
: [`标签数量需要在 ${MATCH3D_MIN_TAG_COUNT}${MATCH3D_MAX_TAG_COUNT} 个之间。`]),
...(normalizePositiveInteger(editState.clearCountText)
? []
: ['需要消除次数必须为正整数。']),
...(normalizeDifficulty(editState.difficultyText)
? []
: ['难度必须为 1 到 10。']),
];
return [...new Set(blockers)];
}
function buildTestRunBlockers(editState: Match3DResultEditState) {
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(normalizePositiveInteger(editState.clearCountText)
? []
: ['需要消除次数必须为正整数。']),
...(normalizeDifficulty(editState.difficultyText)
? []
: ['难度必须为 1 到 10。']),
];
return [...new Set(blockers)];
}
function readImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件。'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('封面图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function buildPlayableProfile(
profile: Match3DWorkProfile,
editState: Match3DResultEditState,
) {
const payload = buildSavePayload(editState);
if (!payload) {
return profile;
}
return {
...profile,
gameName: payload.gameName,
themeText: payload.themeText ?? profile.themeText,
summary: payload.summary,
tags: payload.tags,
coverImageSrc: payload.coverImageSrc,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
};
}
function Match3DResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: Match3DAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const badge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
{badge}
</div>
);
}
export function Match3DResultView({
profile,
draft = null,
isBusy = false,
error = null,
onBack,
onSaved,
onPublished,
onStartTestRun,
}: Match3DResultViewProps) {
const [editState, setEditState] = useState(() => createEditState(profile));
const [autoSaveState, setAutoSaveState] =
useState<Match3DAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
const blockers = useMemo(() => buildPublishBlockers(editState), [editState]);
const testRunBlockers = useMemo(
() => buildTestRunBlockers(editState),
[editState],
);
const canStartTestRun = testRunBlockers.length === 0;
const canSubmit = blockers.length === 0;
const totalItemCount =
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
3;
useEffect(() => {
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
const payload = buildSavePayload(editState);
if (!payload) {
return undefined;
}
const currentTags = normalizeTags(profile.tags.join(''));
const nextTags = payload.tags;
const changed =
payload.gameName !== profile.gameName ||
payload.themeText !== profile.themeText ||
payload.summary !== profile.summary ||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
payload.clearCount !== profile.clearCount ||
payload.difficulty !== profile.difficulty ||
nextTags.length !== currentTags.length ||
nextTags.some((tag, index) => tag !== currentTags[index]);
if (!changed) {
return undefined;
}
setAutoSaveState('saving');
setLocalError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
void updateMatch3DWork(profile.profileId, payload)
.then(({ item }) => {
if (cancelled) {
return;
}
setAutoSaveState('saved');
onSaved?.(item);
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, MATCH3D_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
if (!payload) {
setLocalError(testRunBlockers[0] ?? '请补全作品信息。');
return null;
}
setAutoSaveState('saving');
setLocalError(null);
const { item } = await updateMatch3DWork(profile.profileId, payload);
setAutoSaveState('saved');
onSaved?.(item);
return item;
};
const handleCoverImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageAsDataUrl(file);
setEditState((current) => ({
...current,
coverImageSrc: dataUrl,
}));
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canStartTestRun || isStartingTestRun) {
setLocalError(testRunBlockers[0] ?? null);
return;
}
setIsStartingTestRun(true);
try {
const savedProfile = await saveNow();
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。',
);
} finally {
setIsStartingTestRun(false);
}
};
const handlePublish = async () => {
if (!canSubmit || isPublishing) {
setLocalError(blockers[0] ?? null);
return;
}
setIsPublishing(true);
try {
const savedProfile = await saveNow();
const { item } = await publishMatch3DWork(
savedProfile?.profileId ?? profile.profileId,
);
onPublished?.(item);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '发布抓大鹅作品失败。',
);
} finally {
setIsPublishing(false);
}
};
const busy = isBusy || isPublishing || isStartingTestRun;
const displayError = error ?? localError;
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
<Match3DResultHeader
autoSaveState={autoSaveState}
isBusy={busy}
onBack={onBack}
/>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 lg:grid-cols-[minmax(17rem,0.72fr)_minmax(0,1fr)]">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="aspect-[4/3] overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))]">
{editState.coverImageSrc ? (
<ResolvedAssetImage
src={editState.coverImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-emerald-700">
<ImagePlus className="h-10 w-10" />
</div>
)}
</div>
<label className="platform-button platform-button--ghost mt-3 flex min-h-10 cursor-pointer items-center justify-center gap-2 px-3 py-2 text-sm">
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={busy}
onChange={handleCoverImageChange}
/>
</label>
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs font-bold text-[var(--platform-text-base)]">
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{totalItemCount}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.clearCountText || '-'}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.difficultyText || '-'}
</div>
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.gameName}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, gameName: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.tagsText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, tagsText: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={editState.summary}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, summary: event.target.value })
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.themeText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, themeText: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.clearCountText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
clearCountText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.difficultyText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
difficultyText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
</div>
{draft?.referenceImageSrc || profile.referenceImageSrc ? (
<div className="mt-4 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2">
<ResolvedAssetImage
src={draft?.referenceImageSrc ?? profile.referenceImageSrc ?? ''}
alt=""
aria-hidden="true"
className="h-24 w-full rounded-[0.8rem] object-cover"
/>
</div>
) : null}
</section>
</div>
</div>
{displayError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{displayError}
</div>
) : null}
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
<button
type="button"
onClick={handleStartTestRun}
disabled={!canStartTestRun || busy}
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={handlePublish}
disabled={!canSubmit || busy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : profile.publicationStatus === 'published' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
</button>
</div>
</div>
);
}
export default Match3DResultView;

View File

@@ -0,0 +1 @@
export { Match3DResultView } from './Match3DResultView';

View File

@@ -70,6 +70,22 @@ function buildClientEventId(itemInstanceId: string) {
)}`;
}
function isRunState(
status: Match3DRunSnapshot['status'],
expected: 'running' | 'won' | 'failed' | 'stopped',
) {
return String(status).toLowerCase() === expected;
}
function isItemState(
state: Match3DItemSnapshot['state'],
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
) {
return String(state)
.replace(/([a-z])([A-Z])/gu, '$1_$2')
.toLowerCase() === expected;
}
function isPointInsideCircle(
pointX: number,
pointY: number,
@@ -86,7 +102,7 @@ function findHitItem(
return run.items
.filter(
(item) =>
item.state === 'InBoard' &&
isItemState(item.state, 'in_board') &&
item.clickable &&
isPointInsideCircle(pointX, pointY, item),
)
@@ -137,13 +153,13 @@ function Match3DToken({
const visualSeed = resolveVisualSeed(item.visualKey);
const size = `${item.radius * 200}%`;
const itemStateClass =
item.state === 'Flying'
isItemState(item.state, 'flying')
? 'scale-75 opacity-0'
: item.clickable
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
: 'opacity-48';
if (item.state !== 'InBoard' && item.state !== 'Flying') {
if (!isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying')) {
return null;
}
@@ -160,7 +176,7 @@ function Match3DToken({
}}
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
data-testid={`match3d-item-${item.itemInstanceId}`}
disabled={disabled || !item.clickable || item.state !== 'InBoard'}
disabled={disabled || !item.clickable || !isItemState(item.state, 'in_board')}
onClick={() => onClick(item)}
>
<span className="relative z-10">{visualSeed.label}</span>
@@ -193,11 +209,11 @@ function Match3DSettlement({
onBack: () => void;
onRestart: () => void;
}) {
if (run.status === 'Running') {
if (isRunState(run.status, 'running')) {
return null;
}
const won = run.status === 'Won';
const stopped = run.status === 'Stopped';
const won = isRunState(run.status, 'won');
const stopped = isRunState(run.status, 'stopped');
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
const description = won
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
@@ -265,7 +281,7 @@ export function Match3DRuntimeShell({
}, [run?.remainingMs, run?.snapshotVersion]);
useEffect(() => {
if (!run || run.status !== 'Running') {
if (!run || !isRunState(run.status, 'running')) {
return undefined;
}
const timer = window.setInterval(() => {
@@ -296,7 +312,7 @@ export function Match3DRuntimeShell({
}, [run]);
const handleItemClick = async (item: Match3DItemSnapshot) => {
if (!run || run.status !== 'Running' || pendingClick) {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
return;
}
const optimisticRun = buildOptimisticRun(run, item);
@@ -337,7 +353,7 @@ export function Match3DRuntimeShell({
};
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!run || run.status !== 'Running' || pendingClick) {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
return;
}
const rect = stageRef.current?.getBoundingClientRect();

View File

@@ -27,6 +27,11 @@ import type {
Match3DSessionResponse,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DWorkProfile,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type {
PuzzleAgentActionRequest,
PuzzleAgentOperationRecord,
@@ -85,6 +90,19 @@ import {
shouldRestoreCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
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 {
buildBigFishGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
@@ -95,8 +113,10 @@ import {
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import {
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
isSameBigFishPublicWorkCode,
isSameMatch3DPublicWorkCode,
isSamePuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import {
@@ -153,8 +173,10 @@ import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import {
isBigFishGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
mapBigFishWorkToPlatformGalleryCard,
mapMatch3DWorkToPlatformGalleryCard,
mapPuzzleWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
@@ -239,7 +261,9 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
: isMatch3DGalleryEntry(entry)
? 'match3d'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -282,12 +306,76 @@ function mapPuzzleWorkToPublicWorkDetail(
return mapPuzzleWorkToPlatformGalleryCard(item);
}
function mapMatch3DWorkToPublicWorkDetail(
item: Match3DWorkSummary,
): PlatformPublicGalleryCard {
return mapMatch3DWorkToPlatformGalleryCard(item);
}
function mapBigFishWorkToPublicWorkDetail(
item: BigFishWorkSummary,
): PlatformPublicGalleryCard {
return mapBigFishWorkToPlatformGalleryCard(item);
}
function mapPublicWorkDetailToMatch3DWork(
entry: PlatformPublicGalleryCard,
): Match3DWorkSummary | null {
if (!isMatch3DGalleryEntry(entry)) {
return null;
}
return {
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
sourceSessionId: null,
gameName: entry.worldName,
themeText: entry.themeTags[0] ?? '经典消除',
summary: entry.summaryText,
tags: entry.themeTags,
coverImageSrc: entry.coverImageSrc,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: entry.playCount ?? 0,
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
publishReady: true,
};
}
function buildMatch3DProfileFromSession(
session: Match3DAgentSessionSnapshot | null,
): Match3DWorkProfile | null {
const draft = session?.draft;
if (!session || !draft?.profileId) {
return null;
}
const now = session.updatedAt || new Date().toISOString();
return {
workId: draft.profileId,
profileId: draft.profileId,
ownerUserId: 'current-user',
sourceSessionId: session.sessionId,
gameName: draft.gameName,
themeText: draft.themeText,
summary: draft.summary ?? draft.summaryText ?? '',
tags: draft.tags,
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
referenceImageSrc: draft.referenceImageSrc ?? null,
clearCount: draft.clearCount,
difficulty: draft.difficulty,
publicationStatus: 'draft',
playCount: 0,
updatedAt: now,
publishedAt: null,
publishReady: Boolean(draft.publishReady),
};
}
function mapPublicWorkDetailToPuzzleWork(
entry: PlatformPublicGalleryCard,
): PuzzleWorkSummary | null {
@@ -686,10 +774,17 @@ const Match3DAgentWorkspace = lazy(async () => {
};
});
const Match3DDraftReadyView = lazy(async () => {
const module = await import('../match3d-creation/Match3DDraftReadyView');
const Match3DResultView = lazy(async () => {
const module = await import('../match3d-result/Match3DResultView');
return {
default: module.Match3DDraftReadyView,
default: module.Match3DResultView,
};
});
const Match3DRuntimeShell = lazy(async () => {
const module = await import('../match3d-runtime/Match3DRuntimeShell');
return {
default: module.Match3DRuntimeShell,
};
});
@@ -823,6 +918,16 @@ export function PlatformEntryFlowShellImpl({
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
BigFishWorkSummary[]
>([]);
const [match3dWorks, setMatch3DWorks] = useState<Match3DWorkSummary[]>([]);
const [match3dGalleryEntries, setMatch3DGalleryEntries] = useState<
Match3DWorkSummary[]
>([]);
const [match3dProfile, setMatch3DProfile] =
useState<Match3DWorkProfile | null>(null);
const [match3dRun, setMatch3DRun] = useState<Match3DRunSnapshot | null>(null);
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] =
useState<'match3d-result' | 'work-detail'>('match3d-result');
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
@@ -955,6 +1060,34 @@ export function PlatformEntryFlowShellImpl({
}
}, [resolveBigFishErrorMessage]);
const refreshMatch3DShelf = useCallback(async () => {
setIsMatch3DLoadingLibrary(true);
try {
const worksResponse = await listMatch3DWorks();
setMatch3DWorks(worksResponse.items);
setMatch3DError(null);
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '读取抓大鹅作品列表失败。'),
);
} finally {
setIsMatch3DLoadingLibrary(false);
}
}, [resolveMatch3DErrorMessage]);
const refreshMatch3DGallery = useCallback(async () => {
try {
const galleryResponse = await listMatch3DGallery();
setMatch3DGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
setMatch3DGalleryEntries([]);
setMatch3DError(resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'));
return [];
}
}, [resolveMatch3DErrorMessage]);
const refreshPuzzleShelf = useCallback(async () => {
setIsPuzzleLoadingLibrary(true);
@@ -1136,16 +1269,20 @@ export function PlatformEntryFlowShellImpl({
const bigFishPublicEntries = isBigFishCreationVisible
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
: [];
const match3dPublicEntries = match3dGalleryEntries.map(
mapMatch3DWorkToPlatformGalleryCard,
);
const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard,
);
return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
[...bigFishPublicEntries, ...puzzlePublicEntries],
[...bigFishPublicEntries, ...match3dPublicEntries, ...puzzlePublicEntries],
).slice(0, 6);
}, [
isBigFishCreationVisible,
bigFishGalleryEntries,
match3dGalleryEntries,
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries,
]);
@@ -1157,12 +1294,14 @@ export function PlatformEntryFlowShellImpl({
...(isBigFishCreationVisible
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
: []),
...match3dGalleryEntries.map(mapMatch3DWorkToPlatformGalleryCard),
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
],
),
[
isBigFishCreationVisible,
bigFishGalleryEntries,
match3dGalleryEntries,
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries,
],
@@ -1336,8 +1475,25 @@ export function PlatformEntryFlowShellImpl({
onSessionOpened: () => {
setShowCreationTypeModal(false);
},
onActionComplete: ({ response, setSession }) => {
onActionComplete: async ({ payload, response, setSession }) => {
setSession(response.session);
if (payload.action !== 'match3d_compile_draft') {
return;
}
const profileId = response.session.draft?.profileId;
if (!profileId) {
setMatch3DProfile(null);
return;
}
try {
const { item } = await getMatch3DWorkDetail(profileId);
setMatch3DProfile(item);
await refreshMatch3DShelf().catch(() => undefined);
} catch {
setMatch3DProfile(buildMatch3DProfileFromSession(response.session));
}
},
});
@@ -1495,6 +1651,8 @@ export function PlatformEntryFlowShellImpl({
const openMatch3DAgentWorkspace = useCallback(async () => {
setMatch3DSession(null);
setMatch3DProfile(null);
setMatch3DRun(null);
setMatch3DError(null);
setStreamingMatch3DReplyText('');
setIsStreamingMatch3DReply(false);
@@ -1503,6 +1661,8 @@ export function PlatformEntryFlowShellImpl({
match3dFlow,
setIsStreamingMatch3DReply,
setMatch3DError,
setMatch3DProfile,
setMatch3DRun,
setMatch3DSession,
setStreamingMatch3DReplyText,
]);
@@ -1595,6 +1755,11 @@ export function PlatformEntryFlowShellImpl({
setBigFishGenerationState(null);
setBigFishError(null);
setMatch3DSession(null);
setMatch3DProfile(null);
setMatch3DWorks([]);
setMatch3DGalleryEntries([]);
setMatch3DRun(null);
setMatch3DRuntimeReturnStage('match3d-result');
setMatch3DError(null);
setStreamingMatch3DReplyText('');
setIsStreamingMatch3DReply(false);
@@ -1689,6 +1854,8 @@ export function PlatformEntryFlowShellImpl({
}, [bigFishFlow]);
const leaveMatch3DFlow = useCallback(() => {
setMatch3DRun(null);
setMatch3DRuntimeReturnStage('match3d-result');
match3dFlow.leaveFlow();
}, [match3dFlow]);
@@ -1758,7 +1925,10 @@ export function PlatformEntryFlowShellImpl({
match3dSession ? 'match3d-agent-workspace' : 'platform',
);
}
}, [match3dSession, selectionStage, setSelectionStage]);
if (selectionStage === 'match3d-runtime' && !match3dRun) {
setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform');
}
}, [match3dRun, match3dSession, selectionStage, setSelectionStage]);
const startBigFishRun = useCallback(() => {
if (!bigFishSession) {
@@ -1875,6 +2045,54 @@ export function PlatformEntryFlowShellImpl({
],
);
const startMatch3DRunFromProfile = useCallback(
async (
profile: Match3DWorkProfile | Match3DWorkSummary,
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
mirrorErrorToPublicDetail = false,
) => {
if (isMatch3DBusy) {
return;
}
match3dFlow.setIsBusy(true);
setMatch3DError(null);
try {
const { run } = await startMatch3DRun(profile.profileId);
setMatch3DRun(run);
setMatch3DRuntimeReturnStage(returnStage);
setSelectionStage('match3d-runtime');
if (profile.publicationStatus === 'published') {
pushAppHistoryPath(
buildPublicWorkStagePath(
'work-detail',
buildMatch3DPublicWorkCode(profile.profileId),
),
);
}
} catch (error) {
const message = resolveMatch3DErrorMessage(
error,
'启动抓大鹅玩法失败。',
);
setMatch3DError(message);
if (mirrorErrorToPublicDetail) {
setPublicWorkDetailError(message);
}
} finally {
match3dFlow.setIsBusy(false);
}
},
[
isMatch3DBusy,
match3dFlow,
resolveMatch3DErrorMessage,
setMatch3DError,
setSelectionStage,
],
);
const buildPuzzleTestWork = useCallback(
(draft: PuzzleResultDraft) => {
const profileId =
@@ -2595,6 +2813,47 @@ export function PlatformEntryFlowShellImpl({
],
);
const handleDeleteMatch3DWork = useCallback(
(work: Match3DWorkSummary) => {
if (deletingCreationWorkId) {
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${work.gameName}》吗?删除后会从你的作品列表中移除。`,
);
if (!confirmed) {
return;
}
setDeletingCreationWorkId(work.workId);
setMatch3DError(null);
void deleteMatch3DWork(work.profileId)
.then((response) => {
setMatch3DWorks(response.items);
void refreshMatch3DGallery();
})
.catch((error) => {
setMatch3DError(
resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
});
},
[
deletingCreationWorkId,
refreshMatch3DGallery,
resolveMatch3DErrorMessage,
runProtectedAction,
setMatch3DError,
],
);
const clearSelectedPublicWorkAuthor = useCallback(() => {
publicWorkAuthorRequestKeyRef.current += 1;
setSelectedPublicWorkAuthor(null);
@@ -2911,6 +3170,45 @@ export function PlatformEntryFlowShellImpl({
],
);
const openMatch3DPublicWorkDetail = useCallback(
async (profileId: string) => {
setIsPublicWorkDetailBusy(true);
setMatch3DError(null);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
try {
const entries =
match3dGalleryEntries.length > 0
? match3dGalleryEntries
: await refreshMatch3DGallery();
const matchedEntry = entries.find(
(entry) => entry.profileId === profileId,
);
if (!matchedEntry) {
throw new Error('未找到抓大鹅作品。');
}
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(matchedEntry));
} catch (error) {
setPublicWorkDetailError(
resolveMatch3DErrorMessage(error, '读取抓大鹅详情失败。'),
);
} finally {
setIsPublicWorkDetailBusy(false);
}
},
[
match3dGalleryEntries,
openPublicWorkDetail,
refreshMatch3DGallery,
resolveMatch3DErrorMessage,
setMatch3DError,
setSelectionStage,
],
);
const openPuzzleDetail = useCallback(
async (
profileId: string,
@@ -2986,6 +3284,47 @@ export function PlatformEntryFlowShellImpl({
],
);
const openMatch3DDraft = useCallback(
async (item: Match3DWorkSummary) => {
setMatch3DRun(null);
setMatch3DError(null);
setMatch3DProfile(null);
if (item.publicationStatus === 'published') {
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item));
return;
}
if (!item.sourceSessionId?.trim()) {
setMatch3DError('这份抓大鹅草稿缺少会话信息,请重新开始创作。');
return;
}
const restoredSession = await match3dFlow.restoreDraft(item.sourceSessionId);
if (!restoredSession) {
await refreshMatch3DShelf().catch(() => undefined);
return;
}
try {
const { item: profile } = await getMatch3DWorkDetail(item.profileId);
setMatch3DProfile(profile);
} catch (error) {
setMatch3DProfile(buildMatch3DProfileFromSession(restoredSession));
setMatch3DError(
resolveMatch3DErrorMessage(error, '读取抓大鹅作品详情失败。'),
);
}
},
[
match3dFlow,
openPublicWorkDetail,
refreshMatch3DShelf,
resolveMatch3DErrorMessage,
setMatch3DError,
],
);
const startBigFishRunFromWork = useCallback(
(
item: BigFishWorkSummary,
@@ -3053,6 +3392,17 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前抓大鹅作品信息不完整,暂时无法进入玩法。');
return;
}
setPublicWorkDetailError(null);
void startMatch3DRunFromProfile(work, 'work-detail', true);
return;
}
const launchEntry =
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
? selectedDetailEntry
@@ -3090,6 +3440,7 @@ export function PlatformEntryFlowShellImpl({
selectedDetailEntry,
selectedPublicWorkDetail,
startBigFishRunFromWork,
startMatch3DRunFromProfile,
startPuzzleRunFromProfile,
]);
@@ -3140,6 +3491,12 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isMatch3DGalleryEntry(entry)) {
setPublicWorkDetailError('抓大鹅作品改造将在后续版本开放。');
setIsPublicWorkDetailBusy(false);
return;
}
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
.then((response) => {
const nextEntry = response.entry;
@@ -3199,10 +3556,12 @@ export function PlatformEntryFlowShellImpl({
normalizedKeyword,
);
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
const shouldSearchMatch3DFirst = upperKeyword.startsWith('M3');
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
const shouldSearchWorkFirst =
!shouldSearchUserIdFirst &&
!shouldSearchBigFishFirst &&
!shouldSearchMatch3DFirst &&
!shouldSearchPuzzleFirst &&
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
const shouldSearchUserFirst =
@@ -3210,6 +3569,7 @@ export function PlatformEntryFlowShellImpl({
upperKeyword.startsWith('SY') ||
(!shouldSearchWorkFirst &&
!shouldSearchBigFishFirst &&
!shouldSearchMatch3DFirst &&
!shouldSearchPuzzleFirst);
const tryOpenGalleryEntry = async () => {
@@ -3270,6 +3630,21 @@ export function PlatformEntryFlowShellImpl({
openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry));
};
const tryOpenMatch3DGalleryEntry = async () => {
const entries =
match3dGalleryEntries.length > 0
? match3dGalleryEntries
: await refreshMatch3DGallery();
const matchedEntry = entries.find((entry) =>
isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId),
);
if (!matchedEntry) {
throw new Error('未找到抓大鹅作品。');
}
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(matchedEntry));
};
try {
if (shouldSearchUserIdFirst) {
@@ -3288,6 +3663,11 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (shouldSearchMatch3DFirst) {
await tryOpenMatch3DGalleryEntry();
return;
}
if (shouldSearchWorkFirst) {
try {
await tryOpenGalleryEntry();
@@ -3324,6 +3704,8 @@ export function PlatformEntryFlowShellImpl({
},
[
bigFishGalleryEntries,
match3dGalleryEntries,
refreshMatch3DGallery,
openPuzzlePublicWorkDetail,
openPublicWorkDetail,
platformBootstrap.platformTab,
@@ -3365,6 +3747,19 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (
worldType === 'match3d' ||
worldType === 'match_3d' ||
work.worldKey.startsWith('match3d:')
) {
const profileId =
work.profileId ?? work.worldKey.replace(/^match3d:/u, '');
if (profileId) {
void openMatch3DPublicWorkDetail(profileId);
}
return;
}
if (
worldType === 'big_fish' ||
worldType === 'big-fish' ||
@@ -3442,6 +3837,7 @@ export function PlatformEntryFlowShellImpl({
});
},
[
openMatch3DPublicWorkDetail,
openPuzzlePublicWorkDetail,
openPublicWorkDetail,
openRpgPublicWorkDetail,
@@ -3481,11 +3877,13 @@ export function PlatformEntryFlowShellImpl({
if (isBigFishCreationVisible) {
void refreshBigFishGallery();
}
void refreshMatch3DGallery();
void refreshPuzzleGallery();
}
}, [
isBigFishCreationVisible,
refreshBigFishGallery,
refreshMatch3DGallery,
refreshPuzzleGallery,
selectionStage,
]);
@@ -3497,10 +3895,12 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.canReadProtectedData
) {
void refreshPuzzleShelf();
void refreshMatch3DShelf();
}
}, [
platformBootstrap.canReadProtectedData,
platformBootstrap.platformTab,
refreshMatch3DShelf,
refreshPuzzleShelf,
selectionStage,
]);
@@ -3529,11 +3929,13 @@ export function PlatformEntryFlowShellImpl({
loading={
platformBootstrap.isLoadingPlatform ||
isBigFishLoadingLibrary ||
isMatch3DLoadingLibrary ||
isPuzzleLoadingLibrary
}
error={
platformBootstrap.isLoadingPlatform ||
isBigFishLoadingLibrary ||
isMatch3DLoadingLibrary ||
isPuzzleLoadingLibrary
? null
: (platformBootstrap.platformError ??
@@ -3555,6 +3957,7 @@ export function PlatformEntryFlowShellImpl({
if (isBigFishCreationVisible) {
void refreshBigFishShelf();
}
void refreshMatch3DShelf();
void refreshPuzzleShelf();
}}
createError={
@@ -3608,6 +4011,15 @@ export function PlatformEntryFlowShellImpl({
}
: null
}
match3dItems={match3dWorks}
onOpenMatch3DDetail={(item) => {
runProtectedAction(() => {
void openMatch3DDraft(item);
});
}}
onDeleteMatch3D={(item) => {
handleDeleteMatch3DWork(item);
}}
puzzleItems={puzzleWorks}
onOpenPuzzleDetail={(item) => {
runProtectedAction(() => {
@@ -3689,6 +4101,11 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isMatch3DGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
void openRpgPublicWorkDetail(entry);
}}
onOpenLibraryDetail={(entry) => {
@@ -4081,13 +4498,106 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载抓大鹅结果..." />}
>
<Match3DDraftReadyView
session={match3dSession}
<Match3DResultView
profile={
match3dProfile ?? buildMatch3DProfileFromSession(match3dSession)!
}
draft={match3dSession.draft}
isBusy={isMatch3DBusy}
error={match3dError}
onBack={() => {
setSelectionStage('match3d-agent-workspace');
}}
onSaved={(profile) => {
setMatch3DProfile(profile);
}}
onPublished={(profile) => {
setMatch3DProfile(profile);
void Promise.allSettled([
refreshMatch3DShelf(),
refreshMatch3DGallery(),
]);
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(profile));
}}
onStartTestRun={(profile) => {
setMatch3DProfile(profile);
void startMatch3DRunFromProfile(profile, 'match3d-result');
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'match3d-runtime' && (
<motion.div
key="match3d-runtime"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载抓大鹅玩法..." />}
>
<Match3DRuntimeShell
run={match3dRun}
isBusy={isMatch3DBusy}
error={match3dError}
onBack={() => {
if (match3dRun?.runId && match3dRun.status === 'running') {
void stopMatch3DRun(match3dRun.runId).catch(() => undefined);
}
setSelectionStage(match3dRuntimeReturnStage);
}}
onRestart={() => {
if (!match3dRun?.runId || isMatch3DBusy) {
return;
}
match3dFlow.setIsBusy(true);
setMatch3DError(null);
void restartMatch3DRun(match3dRun.runId)
.then(({ run }) => {
setMatch3DRun(run);
})
.catch((error) => {
setMatch3DError(
resolveMatch3DErrorMessage(
error,
'重新开始抓大鹅玩法失败。',
),
);
})
.finally(() => {
match3dFlow.setIsBusy(false);
});
}}
onOptimisticRunChange={setMatch3DRun}
onClickItem={(payload) => {
const runId = payload.runId ?? match3dRun?.runId;
if (!runId) {
return Promise.reject(new Error('抓大鹅运行态缺少 runId。'));
}
return clickMatch3DItem(runId, payload);
}}
onTimeExpired={() => {
if (!match3dRun?.runId) {
return;
}
void finishMatch3DTimeUp(match3dRun.runId)
.then(({ run }) => {
setMatch3DRun(run);
})
.catch((error) => {
setMatch3DError(
resolveMatch3DErrorMessage(
error,
'同步抓大鹅倒计时失败。',
),
);
});
}}
/>
</Suspense>
</motion.div>

View File

@@ -53,6 +53,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
if ('sourceType' in entry && entry.sourceType === 'match3d') {
return '抓大鹅';
}
return 'RPG';
}

View File

@@ -24,6 +24,7 @@ export type SelectionStage =
| 'big-fish-runtime'
| 'match3d-agent-workspace'
| 'match3d-result'
| 'match3d-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-result'

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;
}

View File

@@ -27,6 +27,9 @@ describe('appPageRoutes', () => {
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
'big-fish-result',
);
expect(resolveSelectionStageFromPath('/creation/match3d/result')).toBe(
'match3d-result',
);
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
'puzzle-gallery-detail',
);
@@ -68,5 +71,8 @@ describe('appPageRoutes', () => {
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
'/runtime/big-fish?work=BF-00000003',
);
expect(buildPublicWorkStagePath('match3d-runtime', 'M3-00000004')).toBe(
'/runtime/match3d?work=M3-00000004',
);
});
});

View File

@@ -14,6 +14,9 @@ const STAGE_ROUTE_ENTRIES = [
['big-fish-agent-workspace', '/creation/big-fish/agent'],
['big-fish-result', '/creation/big-fish/result'],
['big-fish-runtime', '/runtime/big-fish'],
['match3d-agent-workspace', '/creation/match3d/agent'],
['match3d-result', '/creation/match3d/result'],
['match3d-runtime', '/runtime/match3d'],
['puzzle-agent-workspace', '/creation/puzzle/agent'],
['puzzle-result', '/creation/puzzle/result'],
['puzzle-gallery-detail', '/gallery/puzzle/detail'],

View File

@@ -2,360 +2,87 @@ import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
Match3DActionResponse,
Match3DAgentMessageResponse,
Match3DAgentSessionSnapshot,
Match3DAnchorItemResponse,
Match3DCreatorConfig,
Match3DSessionResponse,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { TextStreamOptions } from '../aiTypes';
import { createCreationAgentClient } from '../creation-agent';
const MOCK_RESPONSE_DELAY_MS = 180;
const MATCH3D_SESSION_PREFIX = 'match3d-session';
const MATCH3D_AGENT_API_BASE = '/api/creation/match3d/sessions';
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
themeText: '缤纷玩具',
clearCount: 12,
difficulty: 4,
};
const match3dAgentHttpClient = createCreationAgentClient<
CreateMatch3DSessionRequest,
Match3DSessionResponse,
Match3DSessionResponse,
Match3DAgentSessionSnapshot,
SendMatch3DMessageRequest,
Match3DSessionResponse,
ExecuteMatch3DActionRequest,
Match3DActionResponse
>({
apiBase: MATCH3D_AGENT_API_BASE,
messages: {
createSession: '创建抓大鹅共创会话失败',
getSession: '读取抓大鹅共创会话失败',
sendMessage: '发送抓大鹅共创消息失败',
streamIncomplete: '抓大鹅共创消息流式结果不完整',
executeAction: '执行抓大鹅共创操作失败',
},
});
let match3dSessionCounter = 0;
const mockSessions = new Map<string, Match3DAgentSessionSnapshot>();
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, ms));
}
function nowIso() {
return new Date().toISOString();
}
function createMessage(
sessionId: string,
role: Match3DAgentMessageResponse['role'],
text: string,
kind: Match3DAgentMessageResponse['kind'] = 'chat',
): Match3DAgentMessageResponse {
return {
id: `${sessionId}-message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
role,
kind,
text,
createdAt: nowIso(),
};
}
function buildAnchor(
key: string,
label: string,
value: string,
): Match3DAnchorItemResponse {
return {
key,
label,
value,
status: value.trim() ? 'confirmed' : 'missing',
};
}
function buildAnchorPack(config: Partial<Match3DCreatorConfig>) {
return {
theme: buildAnchor('theme', '题材主题', config.themeText ?? ''),
clearCount: buildAnchor(
'clearCount',
'需要消除次数',
typeof config.clearCount === 'number' ? String(config.clearCount) : '',
),
difficulty: buildAnchor(
'difficulty',
'难度',
typeof config.difficulty === 'number' ? String(config.difficulty) : '',
),
};
}
function normalizePositiveInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const normalized = Math.floor(value);
return normalized > 0 ? normalized : null;
}
function normalizeDifficulty(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
return Math.max(1, Math.min(10, Math.round(value)));
}
function buildConfigFromPartial(
partial: Partial<Match3DCreatorConfig>,
): Match3DCreatorConfig | null {
const themeText = partial.themeText?.trim();
const clearCount = normalizePositiveInteger(partial.clearCount);
const difficulty = normalizeDifficulty(partial.difficulty);
if (!themeText || !clearCount || !difficulty) {
return null;
}
return {
themeText,
referenceImageSrc: partial.referenceImageSrc ?? null,
clearCount,
difficulty,
};
}
function parseConfigFromText(
text: string,
current: Partial<Match3DCreatorConfig>,
): Partial<Match3DCreatorConfig> {
const next = { ...current };
const trimmedText = text.trim();
const themeMatch =
trimmedText.match(/(?:|)[:\s]*([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})/u) ??
trimmedText.match(/(?:|||使)([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})(?:|)/u);
const clearCountMatch =
trimmedText.match(/(?:|)[:\s]*(\d+)/u) ??
trimmedText.match(/(\d+)\s*(?:|)/u);
const difficultyMatch =
trimmedText.match(/(?:)[:\s]*(10|[1-9])/u) ??
trimmedText.match(/(?:|)/u);
if (themeMatch?.[1]) {
next.themeText = themeMatch[1].trim();
}
if (clearCountMatch?.[1]) {
next.clearCount = Number(clearCountMatch[1]);
}
if (difficultyMatch?.[1]) {
next.difficulty = Number(difficultyMatch[1]);
} else if (difficultyMatch?.[0]) {
next.difficulty = 7;
}
if (!next.themeText && trimmedText.length >= 2 && trimmedText.length <= 24) {
next.themeText = trimmedText;
}
return next;
}
function resolveSessionProgress(config: Partial<Match3DCreatorConfig>) {
const completed = [
Boolean(config.themeText?.trim()),
Boolean(normalizePositiveInteger(config.clearCount)),
Boolean(normalizeDifficulty(config.difficulty)),
].filter(Boolean).length;
return Math.round((completed / 3) * 100);
}
function buildAssistantReply(config: Partial<Match3DCreatorConfig>) {
const missing: string[] = [];
if (!config.themeText?.trim()) {
missing.push('题材主题');
}
if (!normalizePositiveInteger(config.clearCount)) {
missing.push('需要消除次数');
}
if (!normalizeDifficulty(config.difficulty)) {
missing.push('难度');
}
if (missing.length === 0) {
const readyConfig = buildConfigFromPartial(config) ?? DEFAULT_MATCH3D_CONFIG;
return `已确认:${readyConfig.themeText}题材,消除 ${readyConfig.clearCount} 次,共 ${readyConfig.clearCount * 3} 件物品,难度 ${readyConfig.difficulty}。可以生成结果页。`;
}
return `还需要确认:${missing.join('、')}`;
}
function updateSessionConfig(
session: Match3DAgentSessionSnapshot,
partialConfig: Partial<Match3DCreatorConfig>,
) {
const progressPercent = resolveSessionProgress(partialConfig);
const config = buildConfigFromPartial(partialConfig);
return {
...session,
progressPercent,
stage: 'collecting_config',
anchorPack: buildAnchorPack(partialConfig),
config,
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
}
function ensureMockSession(sessionId: string) {
const session = mockSessions.get(sessionId);
if (!session) {
throw new Error('抓大鹅创作会话不存在,请重新开始创作。');
}
return session;
}
function buildDraft(config: Match3DCreatorConfig) {
return {
gameName: `${config.themeText}抓大鹅`,
themeText: config.themeText,
summaryText: `${config.themeText}题材的经典三消收纳关卡。`,
tags: [config.themeText, '抓大鹅', '消除'].slice(0, 3),
coverImageSrc: config.referenceImageSrc ?? null,
clearCount: config.clearCount,
difficulty: config.difficulty,
totalItemCount: config.clearCount * 3,
};
}
export async function createMatch3DCreationSession(
/**
* 创建抓大鹅 Agent 共创会话。
* Q1 起前端只走 Axum facade避免本地 mock 成为创作真相源。
*/
export function createMatch3DCreationSession(
payload: CreateMatch3DSessionRequest = {},
): Promise<Match3DSessionResponse> {
await delay();
match3dSessionCounter += 1;
const sessionId = `${MATCH3D_SESSION_PREFIX}-${match3dSessionCounter}`;
const partialConfig: Partial<Match3DCreatorConfig> = {
themeText: payload.themeText ?? payload.seedText,
referenceImageSrc: payload.referenceImageSrc ?? null,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
};
const now = nowIso();
const session: Match3DAgentSessionSnapshot = updateSessionConfig(
{
sessionId,
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_config',
anchorPack: buildAnchorPack(partialConfig),
config: null,
draft: null,
messages: [
createMessage(
sessionId,
'assistant',
'先确认题材、需要消除次数和难度。也可以直接说“自动配置”。',
),
],
lastAssistantReply: null,
updatedAt: now,
},
partialConfig,
);
mockSessions.set(sessionId, session);
return { session };
) {
return match3dAgentHttpClient.createSession(payload);
}
export async function getMatch3DCreationSession(sessionId: string) {
await delay(80);
return { session: ensureMockSession(sessionId) };
/**
* 读取抓大鹅 Agent 会话快照。
*/
export function getMatch3DCreationSession(sessionId: string) {
return match3dAgentHttpClient.getSession(sessionId);
}
export async function streamMatch3DCreationMessage(
/**
* 非流式发送抓大鹅 Agent 消息,保留为 SSE 降级入口。
*/
export function sendMatch3DCreationMessage(
sessionId: string,
payload: SendMatch3DMessageRequest,
) {
return match3dAgentHttpClient.sendMessage(sessionId, payload);
}
/**
* 流式发送抓大鹅 Agent 消息。
*/
export function streamMatch3DCreationMessage(
sessionId: string,
payload: SendMatch3DMessageRequest,
options: TextStreamOptions = {},
): Promise<Match3DAgentSessionSnapshot> {
await delay(120);
const session = ensureMockSession(sessionId);
const text = payload.text.trim();
const currentConfig: Partial<Match3DCreatorConfig> = session.config ?? {
themeText: session.anchorPack.theme.value,
clearCount: Number(session.anchorPack.clearCount.value) || undefined,
difficulty: Number(session.anchorPack.difficulty.value) || undefined,
};
const nextConfig =
payload.quickFillRequested || //u.test(text)
? {
...DEFAULT_MATCH3D_CONFIG,
themeText: currentConfig.themeText || DEFAULT_MATCH3D_CONFIG.themeText,
}
: parseConfigFromText(text, currentConfig);
const userMessage = {
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text,
createdAt: nowIso(),
} satisfies Match3DAgentMessageResponse;
const assistantReply = buildAssistantReply(nextConfig);
options.onUpdate?.(assistantReply.slice(0, Math.ceil(assistantReply.length / 2)));
await delay(80);
options.onUpdate?.(assistantReply);
await delay(80);
const nextSession = updateSessionConfig(
{
...session,
currentTurn: session.currentTurn + 1,
messages: [
...session.messages,
userMessage,
createMessage(sessionId, 'assistant', assistantReply),
],
lastAssistantReply: assistantReply,
},
{
...nextConfig,
referenceImageSrc:
payload.referenceImageSrc ?? currentConfig.referenceImageSrc ?? null,
},
);
mockSessions.set(sessionId, nextSession);
return nextSession;
) {
return match3dAgentHttpClient.streamMessage(sessionId, payload, options);
}
export async function executeMatch3DCreationAction(
/**
* 执行抓大鹅创作操作,例如生成草稿作品。
*/
export function executeMatch3DCreationAction(
sessionId: string,
payload: ExecuteMatch3DActionRequest,
): Promise<Match3DActionResponse> {
await delay(220);
const session = ensureMockSession(sessionId);
if (payload.action !== 'match3d_compile_draft') {
throw new Error('未知抓大鹅创作操作。');
}
const config = session.config ?? buildConfigFromPartial(DEFAULT_MATCH3D_CONFIG);
if (!config) {
throw new Error('请先确认题材、需要消除次数和难度。');
}
const nextSession = {
...session,
stage: 'draft_ready',
progressPercent: 100,
config,
draft: buildDraft(config),
lastAssistantReply: '抓大鹅草稿已准备完成。',
messages: [
...session.messages,
createMessage(sessionId, 'assistant', '抓大鹅草稿已准备完成。', 'summary'),
],
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
mockSessions.set(sessionId, nextSession);
return { session: nextSession };
) {
return match3dAgentHttpClient.executeAction(sessionId, payload);
}
export const match3dCreationClient = {
createSession: createMatch3DCreationSession,
getSession: getMatch3DCreationSession,
sendMessage: sendMatch3DCreationMessage,
streamMessage: streamMatch3DCreationMessage,
executeAction: executeMatch3DCreationAction,
};

View File

@@ -6,3 +6,12 @@ export {
startLocalMatch3DRun,
stopLocalMatch3DRun,
} from './match3dLocalRuntime';
export {
clickMatch3DItem,
finishMatch3DTimeUp,
getMatch3DRun,
match3dRuntimeClient,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from './match3dRuntimeClient';

View File

@@ -0,0 +1,162 @@
import type {
Match3DClickConfirmation,
Match3DClickItemRequest,
Match3DClickItemResult,
Match3DClickRejectReason,
Match3DClickResponse,
Match3DRunResponse,
StopMatch3DRunRequest,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
switch (reason) {
case 'snapshot_version_mismatch':
return 'VersionConflict';
case 'tray_full':
return 'RejectedTrayFull';
case 'run_not_active':
return 'RunFinished';
case 'item_not_found':
case 'item_not_in_board':
return 'RejectedAlreadyMoved';
case 'item_not_clickable':
default:
return 'RejectedNotClickable';
}
}
function mapClickConfirmation(
request: Match3DClickItemRequest,
confirmation: Match3DClickConfirmation,
): Match3DClickItemResult {
return {
status: confirmation.accepted
? 'Accepted'
: normalizeRejectStatus(confirmation.rejectReason),
run: confirmation.run,
acceptedItemInstanceId: confirmation.accepted
? request.itemInstanceId
: undefined,
clearedItemInstanceIds: confirmation.clearedItemInstanceIds,
failureReason: confirmation.run.failureReason,
};
}
/**
* 基于作品启动一局抓大鹅正式 run。
*/
export function startMatch3DRun(profileId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId }),
},
'启动抓大鹅玩法失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
/**
* 读取抓大鹅运行态快照。
*/
export function getMatch3DRun(runId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' },
'读取抓大鹅运行快照失败',
{ retry: MATCH3D_RUNTIME_READ_RETRY },
);
}
/**
* 提交一次点击,由后端做权威确认;返回值适配运行壳已实现的即时反馈语义。
*/
export async function clickMatch3DItem(
runId: string,
payload: Match3DClickItemRequest,
) {
const response = await requestJson<Match3DClickResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
runId: payload.runId ?? runId,
}),
},
'确认抓大鹅点击失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
return mapClickConfirmation(payload, response.confirmation);
}
/**
* 停止当前抓大鹅运行态。
*/
export function stopMatch3DRun(
runId: string,
payload: StopMatch3DRunRequest = {
clientActionId: `match3d-stop-${Date.now()}`,
},
) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'停止抓大鹅玩法失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
/**
* 基于当前 run 重开一局。
*/
export function restartMatch3DRun(runId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`,
{ method: 'POST' },
'重新开始抓大鹅玩法失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
/**
* 前端倒计时归零后通知后端确认失败状态。
*/
export function finishMatch3DTimeUp(runId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`,
{ method: 'POST' },
'同步抓大鹅倒计时失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
export const match3dRuntimeClient = {
clickItem: clickMatch3DItem,
finishTimeUp: finishMatch3DTimeUp,
getRun: getMatch3DRun,
restartRun: restartMatch3DRun,
startRun: startMatch3DRun,
stopRun: stopMatch3DRun,
};

View File

@@ -0,0 +1,9 @@
export {
deleteMatch3DWork,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
match3dWorksClient,
publishMatch3DWork,
updateMatch3DWork,
} from './match3dWorksClient';

View File

@@ -0,0 +1,113 @@
import type {
Match3DWorkDetailResponse,
Match3DWorkMutationResponse,
Match3DWorksResponse,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const MATCH3D_WORKS_API_BASE = '/api/creation/match3d/works';
const MATCH3D_GALLERY_API_BASE = '/api/runtime/match3d/gallery';
const MATCH3D_WORKS_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const MATCH3D_WORKS_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 读取当前用户的抓大鹅作品列表。
*/
export function listMatch3DWorks() {
return requestJson<Match3DWorksResponse>(
MATCH3D_WORKS_API_BASE,
{ method: 'GET' },
'读取抓大鹅作品列表失败',
{ retry: MATCH3D_WORKS_READ_RETRY },
);
}
/**
* 读取公开抓大鹅作品列表。
*/
export function listMatch3DGallery() {
return requestJson<Match3DWorksResponse>(
MATCH3D_GALLERY_API_BASE,
{ method: 'GET' },
'读取抓大鹅广场失败',
{
retry: MATCH3D_WORKS_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
/**
* 读取抓大鹅作品详情。
*/
export function getMatch3DWorkDetail(profileId: string) {
return requestJson<Match3DWorkDetailResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取抓大鹅作品详情失败',
{ retry: MATCH3D_WORKS_READ_RETRY },
);
}
/**
* 保存结果页可编辑字段。
*/
export function updateMatch3DWork(
profileId: string,
payload: PutMatch3DWorkRequest,
) {
return requestJson<Match3DWorkMutationResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'更新抓大鹅作品失败',
{ retry: MATCH3D_WORKS_WRITE_RETRY },
);
}
/**
* 发布抓大鹅作品。发布门槛由后端最终确认。
*/
export function publishMatch3DWork(profileId: string) {
return requestJson<Match3DWorkMutationResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
{ method: 'POST' },
'发布抓大鹅作品失败',
{ retry: MATCH3D_WORKS_WRITE_RETRY },
);
}
/**
* 删除当前用户的抓大鹅作品,并返回删除后的列表。
*/
export function deleteMatch3DWork(profileId: string) {
return requestJson<Match3DWorksResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'DELETE' },
'删除抓大鹅作品失败',
{ retry: MATCH3D_WORKS_WRITE_RETRY },
);
}
export const match3dWorksClient = {
delete: deleteMatch3DWork,
getDetail: getMatch3DWorkDetail,
listGallery: listMatch3DGallery,
list: listMatch3DWorks,
publish: publishMatch3DWork,
update: updateMatch3DWork,
};

View File

@@ -21,6 +21,14 @@ export function buildBigFishPublicWorkCode(sessionId: string) {
return `BF-${suffix}`;
}
export function buildMatch3DPublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `M3-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -43,3 +51,13 @@ export function isSameBigFishPublicWorkCode(
normalizedKeyword === normalizePublicCodeText(sessionId)
);
}
export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildMatch3DPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}