Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal file
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
588
src/components/match3d-result/Match3DResultView.tsx
Normal file
588
src/components/match3d-result/Match3DResultView.tsx
Normal 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;
|
||||
1
src/components/match3d-result/index.ts
Normal file
1
src/components/match3d-result/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Match3DResultView } from './Match3DResultView';
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export type SelectionStage =
|
||||
| 'big-fish-runtime'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'match3d-result'
|
||||
| 'match3d-runtime'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-result'
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -6,3 +6,12 @@ export {
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
export {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
getMatch3DRun,
|
||||
match3dRuntimeClient,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from './match3dRuntimeClient';
|
||||
|
||||
162
src/services/match3d-runtime/match3dRuntimeClient.ts
Normal file
162
src/services/match3d-runtime/match3dRuntimeClient.ts
Normal 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,
|
||||
};
|
||||
9
src/services/match3d-works/index.ts
Normal file
9
src/services/match3d-works/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
match3dWorksClient,
|
||||
publishMatch3DWork,
|
||||
updateMatch3DWork,
|
||||
} from './match3dWorksClient';
|
||||
113
src/services/match3d-works/match3dWorksClient.ts
Normal file
113
src/services/match3d-works/match3dWorksClient.ts
Normal 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,
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user