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:
@@ -208,11 +208,16 @@ export default function BigFishPlaygroundApp() {
|
||||
setRun(buildInitialRun());
|
||||
}, []);
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
window.location.assign('/');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BigFishRuntimeShell
|
||||
run={run}
|
||||
assetSlots={assetSlots}
|
||||
onBack={handleRestart}
|
||||
onBack={handleExit}
|
||||
onRestart={handleRestart}
|
||||
onSubmitInput={handleSubmitInput}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -103,3 +103,31 @@ test('big fish workspace hides keyword fill before two turns', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
|
||||
});
|
||||
|
||||
test('big fish workspace does not render progress action messages as chat bubbles', () => {
|
||||
render(
|
||||
<BigFishAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
messages: [
|
||||
...baseSession.messages,
|
||||
{
|
||||
id: 'message-action-result-1',
|
||||
role: 'assistant',
|
||||
kind: 'action_result',
|
||||
text: '本级主图已正式生成,可在结果页继续预览。',
|
||||
createdAt: '2026-04-24T10:01:00.000Z',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('爽点和生态已经清楚,继续补剩余关键词。')).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('本级主图已正式生成,可在结果页继续预览。'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -52,6 +52,14 @@ function mapBigFishAnchor(
|
||||
function mapBigFishSession(
|
||||
session: BigFishSessionSnapshotResponse,
|
||||
): CreationAgentSessionView {
|
||||
// 中文注释:生成进度与资产完成记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。
|
||||
const chatMessages = session.messages.filter(
|
||||
(message) =>
|
||||
message.kind === 'chat' ||
|
||||
message.kind === 'summary' ||
|
||||
message.kind === 'warning',
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
|
||||
@@ -65,7 +73,7 @@ function mapBigFishSession(
|
||||
session.anchorPack.growthLadder,
|
||||
session.anchorPack.riskTempo,
|
||||
].map(mapBigFishAnchor),
|
||||
messages: session.messages,
|
||||
messages: chatMessages,
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,4 +170,35 @@ describe('BigFishResultView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows published state and prevents duplicate publish clicks', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<BigFishResultView
|
||||
session={{
|
||||
...createSession(),
|
||||
stage: 'published',
|
||||
publishReady: true,
|
||||
assetCoverage: {
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 2,
|
||||
backgroundReady: true,
|
||||
requiredLevelCount: 1,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
},
|
||||
}}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const publishedButton = screen.getByRole('button', { name: '已发布' });
|
||||
expect((publishedButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getAllByText('已发布').length).toBeGreaterThan(0);
|
||||
fireEvent.click(publishedButton);
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Sparkles,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BigFishAssetSlotResponse,
|
||||
@@ -338,6 +338,7 @@ export function BigFishResultView({
|
||||
}: BigFishResultViewProps) {
|
||||
const [studioTarget, setStudioTarget] =
|
||||
useState<BigFishAssetStudioTarget | null>(null);
|
||||
const [isPublishSubmitting, setIsPublishSubmitting] = useState(false);
|
||||
const draft = session.draft;
|
||||
const backgroundSlot = findAssetSlot(session.assetSlots, 'stage_background');
|
||||
const backgroundPreviewUrl = buildLevelAssetPreview(backgroundSlot);
|
||||
@@ -345,6 +346,8 @@ export function BigFishResultView({
|
||||
() => session.assetCoverage.blockers.filter(Boolean),
|
||||
[session.assetCoverage.blockers],
|
||||
);
|
||||
const isPublished = session.stage === 'published';
|
||||
const canClickPublish = !isPublished && !isBusy;
|
||||
const studioPreviewUrl = useMemo(() => {
|
||||
if (!studioTarget) {
|
||||
return null;
|
||||
@@ -352,6 +355,12 @@ export function BigFishResultView({
|
||||
return buildStudioAssetPreview(session.assetSlots, studioTarget);
|
||||
}, [session.assetSlots, studioTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBusy || isPublished || error) {
|
||||
setIsPublishSubmitting(false);
|
||||
}
|
||||
}, [error, isBusy, isPublished]);
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -388,14 +397,23 @@ export function BigFishResultView({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
disabled={!canClickPublish}
|
||||
onClick={() => {
|
||||
setIsPublishSubmitting(true);
|
||||
onExecuteAction({ action: 'big_fish_publish_game' });
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
发布
|
||||
{isPublishSubmitting && isBusy && !isPublished ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
{isPublished
|
||||
? '已发布'
|
||||
: isPublishSubmitting && isBusy
|
||||
? '发布中'
|
||||
: '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,7 +505,11 @@ export function BigFishResultView({
|
||||
背景 {session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
|
||||
</div>
|
||||
</div>
|
||||
{blockers.length > 0 ? (
|
||||
{isPublished ? (
|
||||
<div className="mt-3 text-sm font-semibold text-emerald-600">
|
||||
已发布
|
||||
</div>
|
||||
) : blockers.length > 0 ? (
|
||||
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700">
|
||||
{blockers.slice(0, 4).map((blocker) => (
|
||||
<div key={blocker}>{blocker}</div>
|
||||
|
||||
99
src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx
Normal file
99
src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
function createRun(
|
||||
status: BigFishRuntimeSnapshotResponse['status'],
|
||||
): BigFishRuntimeSnapshotResponse {
|
||||
return {
|
||||
runId: 'big-fish-run-1',
|
||||
sessionId: 'big-fish-session-1',
|
||||
status,
|
||||
tick: 18,
|
||||
playerLevel: 2,
|
||||
winLevel: 5,
|
||||
leaderEntityId: null,
|
||||
ownedEntities: [],
|
||||
wildEntities: [],
|
||||
cameraCenter: { x: 0, y: 0 },
|
||||
lastInput: { x: 0, y: 0 },
|
||||
eventLog: ['己方鱼群已经耗尽'],
|
||||
updatedAt: '2026-04-26T12:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('BigFishRuntimeShell', () => {
|
||||
test('renders restart and exit actions after a failed run', () => {
|
||||
const onBack = vi.fn();
|
||||
const onRestart = vi.fn();
|
||||
|
||||
render(
|
||||
<BigFishRuntimeShell
|
||||
run={createRun('failed')}
|
||||
onBack={onBack}
|
||||
onRestart={onRestart}
|
||||
onSubmitInput={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重来' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出' }));
|
||||
|
||||
expect(screen.getByText('本轮失败')).toBeTruthy();
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps an exit action after a won run', () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<BigFishRuntimeShell
|
||||
run={createRun('won')}
|
||||
onBack={onBack}
|
||||
onSubmitInput={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('通关完成')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '重来' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出' }));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('opens and closes the runtime rule modal', () => {
|
||||
render(
|
||||
<BigFishRuntimeShell
|
||||
run={createRun('running')}
|
||||
onBack={() => {}}
|
||||
onSubmitInput={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看规则' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '玩法规则' })).toBeTruthy();
|
||||
expect(screen.getByText('低级或同级野生实体会被收编。')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type PointerEvent } from 'react';
|
||||
import { ArrowLeft, CircleHelp, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BigFishAssetSlotResponse,
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
BigFishRuntimeSnapshotResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type TouchOrigin = {
|
||||
@@ -21,6 +22,7 @@ type BigFishRuntimeShellProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onRestart?: () => void;
|
||||
onSubmitInput: (payload: SubmitBigFishInputRequest) => void;
|
||||
};
|
||||
|
||||
@@ -126,6 +128,38 @@ function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function BigFishRuleModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="玩法规则"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
zIndexClassName="z-[140]"
|
||||
panelClassName="rounded-[1.25rem]"
|
||||
bodyClassName="px-4 py-3 sm:px-5 sm:py-4"
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
<div className="rounded-2xl bg-cyan-50 px-4 py-3 text-cyan-950">
|
||||
拖动屏幕控制方向,角色会按固定速度移动。
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div>低级或同级野生实体会被收编。</div>
|
||||
<div>更高级野生实体会吃掉碰到的己方实体。</div>
|
||||
<div>3 个同级己方实体会自动合成更高一级。</div>
|
||||
<div>拥有最高等级实体后通关,己方实体归零后失败。</div>
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
function BigFishEntityDot({
|
||||
entity,
|
||||
run,
|
||||
@@ -188,10 +222,12 @@ export function BigFishRuntimeShell({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onRestart,
|
||||
onSubmitInput,
|
||||
}: BigFishRuntimeShellProps) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(null);
|
||||
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
|
||||
const [stick, setStick] = useState({ x: 0, y: 0 });
|
||||
const stickRef = useRef(stick);
|
||||
|
||||
@@ -200,6 +236,10 @@ export function BigFishRuntimeShell({
|
||||
}, [stick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (run?.status !== 'running') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const current = stickRef.current;
|
||||
// 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。
|
||||
@@ -209,7 +249,7 @@ export function BigFishRuntimeShell({
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [onSubmitInput]);
|
||||
}, [onSubmitInput, run?.status]);
|
||||
|
||||
const submitDirection = (direction: SubmitBigFishInputRequest) => {
|
||||
setStick(direction);
|
||||
@@ -291,8 +331,19 @@ export function BigFishRuntimeShell({
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="rounded-full bg-black/28 px-4 py-2 text-xs font-bold backdrop-blur">
|
||||
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看规则"
|
||||
title="查看规则"
|
||||
onClick={() => setIsRuleModalOpen(true)}
|
||||
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
|
||||
>
|
||||
<CircleHelp className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="rounded-full bg-black/28 px-4 py-2 text-xs font-bold backdrop-blur">
|
||||
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -318,16 +369,39 @@ export function BigFishRuntimeShell({
|
||||
</div>
|
||||
|
||||
{settlementCopy ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-5">
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center px-5">
|
||||
<div
|
||||
className={`w-full max-w-[20rem] rounded-[2rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
|
||||
className={`w-full max-w-[20rem] rounded-[1.5rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
|
||||
>
|
||||
<div className="text-3xl font-black tracking-[0.22em] text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
|
||||
<div className="text-3xl font-black text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
|
||||
{settlementCopy.title}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold leading-6 text-white/82">
|
||||
{settlementCopy.message}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-2">
|
||||
{run.status === 'failed' && onRestart ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onRestart}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-4 text-sm font-bold text-slate-950 shadow-lg shadow-slate-950/20 disabled:opacity-45"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重来
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={`inline-flex h-11 items-center justify-center gap-2 rounded-full border border-white/30 bg-black/24 px-4 text-sm font-bold text-white backdrop-blur ${
|
||||
run.status === 'failed' && onRestart ? '' : 'col-span-2'
|
||||
}`}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -341,6 +415,10 @@ export function BigFishRuntimeShell({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<BigFishRuleModal
|
||||
open={isRuleModalOpen}
|
||||
onClose={() => setIsRuleModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,11 +49,15 @@ import {
|
||||
startBigFishRuntimeRun,
|
||||
submitBigFishRuntimeInput,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import {
|
||||
deleteBigFishWork,
|
||||
listBigFishWorks,
|
||||
} from '../../services/big-fish-works';
|
||||
import { readCustomWorldAgentUiState } from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
readCustomWorldAgentUiState,
|
||||
shouldRestoreCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
buildBigFishGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
@@ -62,6 +66,10 @@ import {
|
||||
type MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import {
|
||||
isSameBigFishPublicWorkCode,
|
||||
isSamePuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
@@ -79,7 +87,6 @@ import {
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import {
|
||||
@@ -88,15 +95,17 @@ import {
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
isBigFishGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
|
||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import {
|
||||
isPuzzleGalleryEntry,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
@@ -110,8 +119,8 @@ import {
|
||||
} from './platformEntryShared';
|
||||
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
|
||||
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
|
||||
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
|
||||
|
||||
@@ -146,7 +155,12 @@ function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
||||
}
|
||||
|
||||
function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
||||
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function mergePlatformPublicGalleryEntries(
|
||||
@@ -393,6 +407,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
|
||||
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
|
||||
BigFishWorkSummary[]
|
||||
>([]);
|
||||
const [bigFishRun, setBigFishRun] =
|
||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||
@@ -428,7 +445,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
>(null);
|
||||
const hadReadableProtectedDataRef = useRef(false);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
readCustomWorldAgentUiState().activeSessionId,
|
||||
readCustomWorldAgentUiState().activeSessionId &&
|
||||
shouldRestoreCustomWorldAgentUiState(),
|
||||
);
|
||||
|
||||
const platformBootstrap = usePlatformEntryBootstrap({
|
||||
@@ -461,6 +479,64 @@ export function PlatformEntryFlowShellImpl({
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshBigFishShelf = useCallback(async () => {
|
||||
setIsBigFishLoadingLibrary(true);
|
||||
|
||||
try {
|
||||
const worksResponse = await listBigFishWorks();
|
||||
setBigFishWorks(worksResponse.items);
|
||||
setBigFishError(null);
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsBigFishLoadingLibrary(false);
|
||||
}
|
||||
}, [resolveBigFishErrorMessage]);
|
||||
|
||||
const refreshBigFishGallery = useCallback(async () => {
|
||||
try {
|
||||
const galleryResponse = await listBigFishGallery();
|
||||
setBigFishGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setBigFishGalleryEntries([]);
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼广场失败。'),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}, [resolveBigFishErrorMessage]);
|
||||
|
||||
const refreshPuzzleShelf = useCallback(async () => {
|
||||
setIsPuzzleLoadingLibrary(true);
|
||||
|
||||
try {
|
||||
const worksResponse = await listPuzzleWorks();
|
||||
setPuzzleWorks(worksResponse.items);
|
||||
setPuzzleError(null);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleLoadingLibrary(false);
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const refreshPuzzleGallery = useCallback(async () => {
|
||||
try {
|
||||
const galleryResponse = await listPuzzleGallery();
|
||||
setPuzzleGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setPuzzleGalleryEntries([]);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
|
||||
return [];
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const sessionController = useRpgCreationSessionController({
|
||||
userId: authUi?.user?.id,
|
||||
openLoginModal: authUi?.openLoginModal,
|
||||
@@ -553,6 +629,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
await Promise.allSettled([
|
||||
platformBootstrap.refreshPublishedGallery(),
|
||||
platformBootstrap.refreshCustomWorldWorks(),
|
||||
refreshBigFishGallery(),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
return latestSession;
|
||||
@@ -607,21 +684,35 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [agentResultPreview]);
|
||||
|
||||
const featuredGalleryEntries = useMemo(() => {
|
||||
const bigFishPublicEntries = bigFishGalleryEntries.map(
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
);
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzlePublicEntries,
|
||||
[...bigFishPublicEntries, ...puzzlePublicEntries],
|
||||
).slice(0, 6);
|
||||
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
|
||||
}, [
|
||||
bigFishGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
]);
|
||||
const latestGalleryEntries = useMemo(
|
||||
() =>
|
||||
mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
[
|
||||
...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard),
|
||||
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
],
|
||||
),
|
||||
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
|
||||
[
|
||||
bigFishGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
],
|
||||
);
|
||||
|
||||
const creationHubItems =
|
||||
@@ -681,50 +772,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setShowCreationTypeModal(true);
|
||||
}, [prepareCreationLaunch]);
|
||||
|
||||
const refreshBigFishShelf = useCallback(async () => {
|
||||
setIsBigFishLoadingLibrary(true);
|
||||
|
||||
try {
|
||||
const worksResponse = await listBigFishWorks();
|
||||
setBigFishWorks(worksResponse.items);
|
||||
setBigFishError(null);
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsBigFishLoadingLibrary(false);
|
||||
}
|
||||
}, [resolveBigFishErrorMessage]);
|
||||
|
||||
const refreshPuzzleShelf = useCallback(async () => {
|
||||
setIsPuzzleLoadingLibrary(true);
|
||||
|
||||
try {
|
||||
const worksResponse = await listPuzzleWorks();
|
||||
setPuzzleWorks(worksResponse.items);
|
||||
setPuzzleError(null);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleLoadingLibrary(false);
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const refreshPuzzleGallery = useCallback(async () => {
|
||||
try {
|
||||
const galleryResponse = await listPuzzleGallery();
|
||||
setPuzzleGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setPuzzleGalleryEntries([]);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
|
||||
return [];
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const bigFishFlow = usePlatformCreationAgentFlowController<
|
||||
BigFishSessionSnapshotResponse,
|
||||
Record<string, never>,
|
||||
@@ -760,6 +807,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
onActionComplete: ({ payload, response, setSession }) => {
|
||||
setSession(response.session);
|
||||
if (payload.action === 'big_fish_publish_game') {
|
||||
void refreshBigFishShelf();
|
||||
void refreshBigFishGallery();
|
||||
}
|
||||
if (payload.action !== 'big_fish_compile_draft') {
|
||||
return;
|
||||
}
|
||||
@@ -1080,6 +1131,34 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const restartBigFishRun = useCallback(async () => {
|
||||
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
|
||||
if (!sessionId || isBigFishBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBigFishBusy(true);
|
||||
setBigFishError(null);
|
||||
|
||||
try {
|
||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
||||
setBigFishRun(run);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '重新开始大鱼吃小鱼玩法失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsBigFishBusy(false);
|
||||
}
|
||||
}, [
|
||||
bigFishRun?.sessionId,
|
||||
bigFishSession?.sessionId,
|
||||
isBigFishBusy,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const startPuzzleRunFromProfile = useCallback(
|
||||
async (profileId: string) => {
|
||||
if (isPuzzleBusy) {
|
||||
@@ -1157,7 +1236,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const submitBigFishInput = useCallback(
|
||||
(payload: SubmitBigFishInputRequest) => {
|
||||
if (!bigFishRun || bigFishInputInFlightRef.current) {
|
||||
if (
|
||||
!bigFishRun ||
|
||||
bigFishRun.status !== 'running' ||
|
||||
bigFishInputInFlightRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1437,8 +1520,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishError(null);
|
||||
|
||||
void deleteBigFishWork(work.sourceSessionId)
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
setBigFishWorks(response.items);
|
||||
await refreshBigFishGallery().catch(() => []);
|
||||
})
|
||||
.catch((error) => {
|
||||
setBigFishError(
|
||||
@@ -1450,7 +1534,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
});
|
||||
},
|
||||
[deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction],
|
||||
[
|
||||
deletingCreationWorkId,
|
||||
refreshBigFishGallery,
|
||||
resolveBigFishErrorMessage,
|
||||
runProtectedAction,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeletePuzzleWork = useCallback(
|
||||
@@ -1546,6 +1635,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
|
||||
);
|
||||
|
||||
const startBigFishRunFromWork = useCallback(
|
||||
async (item: BigFishWorkSummary) => {
|
||||
const sessionId = item.sourceSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBigFishBusy(true);
|
||||
setBigFishError(null);
|
||||
|
||||
try {
|
||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
||||
bigFishFlow.setSession(null);
|
||||
setBigFishRun(run);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsBigFishBusy(false);
|
||||
}
|
||||
},
|
||||
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
@@ -1561,15 +1677,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
|
||||
normalizedKeyword,
|
||||
);
|
||||
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
|
||||
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
|
||||
const shouldSearchWorkFirst =
|
||||
!shouldSearchUserIdFirst &&
|
||||
!shouldSearchBigFishFirst &&
|
||||
!shouldSearchPuzzleFirst &&
|
||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||
const shouldSearchUserFirst =
|
||||
shouldSearchUserIdFirst ||
|
||||
upperKeyword.startsWith('SY') ||
|
||||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst);
|
||||
(!shouldSearchWorkFirst &&
|
||||
!shouldSearchBigFishFirst &&
|
||||
!shouldSearchPuzzleFirst);
|
||||
|
||||
const tryOpenGalleryEntry = async () => {
|
||||
const entry =
|
||||
@@ -1609,6 +1729,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
tab: platformBootstrap.platformTab,
|
||||
});
|
||||
};
|
||||
const tryOpenBigFishGalleryEntry = async () => {
|
||||
const entries =
|
||||
bigFishGalleryEntries.length > 0
|
||||
? bigFishGalleryEntries
|
||||
: await refreshBigFishGallery();
|
||||
const matchedEntry = entries.find((entry) =>
|
||||
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId),
|
||||
);
|
||||
|
||||
if (!matchedEntry) {
|
||||
throw new Error('未找到大鱼吃小鱼作品。');
|
||||
}
|
||||
|
||||
await startBigFishRunFromWork(matchedEntry);
|
||||
};
|
||||
|
||||
try {
|
||||
if (shouldSearchUserIdFirst) {
|
||||
@@ -1622,11 +1757,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchBigFishFirst) {
|
||||
await tryOpenBigFishGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchWorkFirst) {
|
||||
try {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
} catch {}
|
||||
} catch {
|
||||
// 作品号优先时允许继续回退到用户号搜索。
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSearchUserFirst) {
|
||||
@@ -1634,7 +1776,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
} catch {}
|
||||
} catch {
|
||||
// 用户号优先时允许继续回退到作品号搜索。
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSearchWorkFirst) {
|
||||
@@ -1654,10 +1798,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
detailNavigation,
|
||||
bigFishGalleryEntries,
|
||||
openPuzzleDetail,
|
||||
platformBootstrap.platformTab,
|
||||
puzzleGalleryEntries,
|
||||
refreshBigFishGallery,
|
||||
refreshPuzzleGallery,
|
||||
startBigFishRunFromWork,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1674,39 +1821,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
[bigFishFlow, refreshBigFishShelf],
|
||||
);
|
||||
|
||||
const startBigFishRunFromWork = useCallback(
|
||||
async (item: BigFishWorkSummary) => {
|
||||
const sessionId = item.sourceSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBigFishBusy(true);
|
||||
setBigFishError(null);
|
||||
|
||||
try {
|
||||
const { session } = await getBigFishCreationSession(sessionId);
|
||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
||||
bigFishFlow.setSession(session);
|
||||
setBigFishRun(run);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsBigFishBusy(false);
|
||||
}
|
||||
},
|
||||
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'platform') {
|
||||
void refreshBigFishGallery();
|
||||
void refreshPuzzleGallery();
|
||||
}
|
||||
}, [refreshPuzzleGallery, selectionStage]);
|
||||
}, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -1879,6 +1999,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
runProtectedAction(() => {
|
||||
void startBigFishRunFromWork({
|
||||
workId: entry.workId,
|
||||
sourceSessionId: entry.profileId,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
title: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summary: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
status: 'published',
|
||||
updatedAt: entry.updatedAt,
|
||||
publishReady: true,
|
||||
levelCount: Number.parseInt(
|
||||
entry.themeTags
|
||||
.find((tag) => /^\d+级$/u.test(tag))
|
||||
?.replace('级', '') ?? '0',
|
||||
10,
|
||||
),
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: Boolean(entry.coverImageSrc),
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
void openPuzzleDetail(entry.profileId, {
|
||||
tab: platformBootstrap.platformTab,
|
||||
@@ -2152,7 +2299,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={isBigFishBusy}
|
||||
error={bigFishError}
|
||||
onBack={() => {
|
||||
setSelectionStage('big-fish-result');
|
||||
setSelectionStage(
|
||||
bigFishSession ? 'big-fish-result' : 'platform',
|
||||
);
|
||||
}}
|
||||
onRestart={() => {
|
||||
void restartBigFishRun();
|
||||
}}
|
||||
onSubmitInput={submitBigFishInput}
|
||||
/>
|
||||
@@ -2168,7 +2320,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
|
||||
>
|
||||
<PuzzleAgentWorkspace
|
||||
session={puzzleSession}
|
||||
activeOperation={puzzleOperation}
|
||||
@@ -2241,7 +2395,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图结果..." />}
|
||||
>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
isBusy={isPuzzleBusy}
|
||||
@@ -2266,7 +2422,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图详情..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图详情..." />}
|
||||
>
|
||||
<PuzzleGalleryDetailView
|
||||
item={selectedPuzzleDetail}
|
||||
isBusy={isPuzzleBusy}
|
||||
@@ -2289,7 +2447,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
: null
|
||||
}
|
||||
onStartGame={() => {
|
||||
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
||||
void startPuzzleRunFromProfile(
|
||||
selectedPuzzleDetail.profileId,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -2304,7 +2464,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100]"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
|
||||
>
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
||||
|
||||
@@ -102,3 +102,29 @@ test('puzzle workspace hides keyword fill before two turns', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle workspace does not render progress action messages as chat bubbles', () => {
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
messages: [
|
||||
...baseSession.messages,
|
||||
{
|
||||
id: 'message-action-result-1',
|
||||
role: 'assistant',
|
||||
kind: 'action_result',
|
||||
text: '拼图结果页草稿已生成。',
|
||||
createdAt: '2026-04-24T10:01:00.000Z',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('画面主体已经清楚,继续收束剩余关键词。')).toBeTruthy();
|
||||
expect(screen.queryByText('拼图结果页草稿已生成。')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -44,6 +44,14 @@ const PUZZLE_AGENT_THEME: CreationAgentTheme = {
|
||||
function mapPuzzleSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreationAgentSessionView {
|
||||
// 中文注释:生成进度与草稿写回记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。
|
||||
const chatMessages = session.messages.filter(
|
||||
(message) =>
|
||||
message.kind === 'chat' ||
|
||||
message.kind === 'summary' ||
|
||||
message.kind === 'warning',
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
|
||||
@@ -58,7 +66,7 @@ function mapPuzzleSession(
|
||||
session.anchorPack.compositionHooks,
|
||||
session.anchorPack.tagsAndForbidden,
|
||||
],
|
||||
messages: session.messages,
|
||||
messages: chatMessages,
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,36 +6,18 @@ import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationOperation,
|
||||
getRpgCreationSession,
|
||||
listRpgCreationWorks,
|
||||
streamRpgCreationMessage,
|
||||
upsertRpgWorldProfile,
|
||||
} from '../../services/rpg-creation';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
getRpgProfileDashboard as getProfileDashboard,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
listRpgProfileBrowseHistory as listProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives as listProfileSaveArchives,
|
||||
publishRpgEntryWorldProfile,
|
||||
resumeRpgProfileSaveArchive as resumeProfileSaveArchive,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
|
||||
} from '../../services/rpg-entry';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createBigFishCreationSession,
|
||||
executeBigFishCreationAction,
|
||||
getBigFishCreationSession,
|
||||
} from '../../services/big-fish-creation';
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import { startBigFishRuntimeRun } from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
@@ -46,6 +28,26 @@ import {
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationOperation,
|
||||
getRpgCreationSession,
|
||||
listRpgCreationWorks,
|
||||
streamRpgCreationMessage,
|
||||
upsertRpgWorldProfile,
|
||||
} from '../../services/rpg-creation';
|
||||
import {
|
||||
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
getRpgProfileDashboard as getProfileDashboard,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
listRpgProfileBrowseHistory as listProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives as listProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive as resumeProfileSaveArchive,
|
||||
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
|
||||
} from '../../services/rpg-entry';
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetailByCode,
|
||||
@@ -146,6 +148,15 @@ vi.mock('../../services/big-fish-works', () => ({
|
||||
listBigFishWorks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-gallery', () => ({
|
||||
listBigFishGallery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-runtime', () => ({
|
||||
startBigFishRuntimeRun: vi.fn(),
|
||||
submitBigFishRuntimeInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-agent', () => ({
|
||||
createPuzzleAgentSession: vi.fn(),
|
||||
executePuzzleAgentAction: vi.fn(),
|
||||
@@ -153,6 +164,69 @@ vi.mock('../../services/puzzle-agent', () => ({
|
||||
streamPuzzleAgentMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
|
||||
PuzzleAgentWorkspace: ({
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="puzzle-agent-workspace-mock">
|
||||
<div>拼图工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
PuzzleResultView: ({
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
session: { draft?: { levelName: string } | null };
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="puzzle-result-view-mock">
|
||||
<div>拼图结果页</div>
|
||||
<label>
|
||||
关卡名
|
||||
<input readOnly value={session.draft?.levelName ?? ''} />
|
||||
</label>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({
|
||||
PuzzleGalleryDetailView: ({
|
||||
item,
|
||||
onBack,
|
||||
onStartGame,
|
||||
}: {
|
||||
item: { levelName: string };
|
||||
onBack: () => void;
|
||||
onStartGame: () => void;
|
||||
}) => (
|
||||
<div className="puzzle-gallery-detail-view-mock">
|
||||
<div>{item.levelName}</div>
|
||||
<button type="button" onClick={onStartGame}>
|
||||
进入第 1 关
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({
|
||||
BigFishAgentWorkspace: ({
|
||||
session,
|
||||
@@ -172,13 +246,23 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
BigFishResultView: ({
|
||||
session,
|
||||
onBack,
|
||||
onExecuteAction,
|
||||
}: {
|
||||
session: { draft?: { title: string } | null };
|
||||
onBack: () => void;
|
||||
onExecuteAction: (payload: { action: string }) => void;
|
||||
}) => (
|
||||
<div className="big-fish-result-view-mock">
|
||||
<div>大鱼吃小鱼结果页</div>
|
||||
<div>{session.draft?.title ?? '缺少草稿标题'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onExecuteAction({ action: 'big_fish_publish_game' });
|
||||
}}
|
||||
>
|
||||
发布
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
@@ -223,8 +307,7 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
|
||||
'玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
|
||||
coreConflict:
|
||||
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
|
||||
keyRelationships:
|
||||
'玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
|
||||
keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
|
||||
hiddenLines:
|
||||
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
|
||||
iconicElements:
|
||||
@@ -947,6 +1030,34 @@ beforeEach(() => {
|
||||
vi.mocked(listBigFishWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(startBigFishRuntimeRun).mockResolvedValue({
|
||||
run: {
|
||||
runId: 'big-fish-run-1',
|
||||
sessionId: 'big-fish-session-public-1',
|
||||
status: 'running',
|
||||
tick: 0,
|
||||
playerLevel: 1,
|
||||
winLevel: 8,
|
||||
leaderEntityId: 'owned-1',
|
||||
ownedEntities: [
|
||||
{
|
||||
entityId: 'owned-1',
|
||||
level: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
radius: 12,
|
||||
offscreenSeconds: 0,
|
||||
},
|
||||
],
|
||||
wildEntities: [],
|
||||
cameraCenter: { x: 0, y: 0 },
|
||||
lastInput: { x: 0, y: 0 },
|
||||
eventLog: ['机械鱼群开始巡游。'],
|
||||
updatedAt: '2026-04-25T12:12:00.000Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
@@ -1320,6 +1431,7 @@ test('creation hub clears all private work shelves immediately after logout stat
|
||||
{
|
||||
workId: 'big-fish-logout-cache-1',
|
||||
sourceSessionId: 'big-fish-logout-cache-session',
|
||||
ownerUserId: 'user-1',
|
||||
title: '大鱼退出缓存作品',
|
||||
subtitle: '登出后不应继续可见',
|
||||
summary: '这条大鱼私有作品只能在登录态展示。',
|
||||
@@ -1418,6 +1530,48 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('published big fish works appear on home and category public shelves', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedBigFishWork: BigFishWorkSummary = {
|
||||
workId: 'big-fish-work-public-1',
|
||||
sourceSessionId: 'big-fish-session-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化',
|
||||
summary: '从微光孢子一路吞并成长到深海巨鲲。',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
};
|
||||
|
||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||
items: [publishedBigFishWork],
|
||||
});
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('机械深海 大鱼吃小鱼').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(
|
||||
within(categoryPanel).getAllByText('机械深海 大鱼吃小鱼').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
within(categoryPanel).getAllByRole('button', { name: /大鱼/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('published puzzle detail returns to the source platform tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
@@ -1547,6 +1701,47 @@ test('restoring an agent workspace ignores a stored session owned by another use
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => {
|
||||
window.sessionStorage.setItem(
|
||||
'genarrative.custom-world-agent-ui.v1',
|
||||
JSON.stringify({
|
||||
activeSessionId: 'custom-world-agent-session-1',
|
||||
activeOperationId: null,
|
||||
ownerUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
test('refreshing RPG agent path restores stored agent workspace pointer', async () => {
|
||||
window.history.replaceState(null, '', '/creation/rpg/agent');
|
||||
window.sessionStorage.setItem(
|
||||
'genarrative.custom-world-agent-ui.v1',
|
||||
JSON.stringify({
|
||||
activeSessionId: 'custom-world-agent-session-1',
|
||||
activeOperationId: null,
|
||||
ownerUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getRpgCreationSession).toHaveBeenCalledWith(
|
||||
'custom-world-agent-session-1',
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1747,8 +1942,9 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -1762,6 +1958,49 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('public code search opens a published big fish work by BF code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bigFishWork: BigFishWorkSummary = {
|
||||
workId: 'big-fish-work-public-1',
|
||||
sourceSessionId: 'big-fish-session-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化',
|
||||
summary: '从微光孢子一路吞并成长到深海巨鲲。',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
};
|
||||
|
||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||
items: [bigFishWork],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startBigFishRuntimeRun).toHaveBeenCalledWith(
|
||||
'big-fish-session-public-1',
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
|
||||
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(
|
||||
'big-fish-session-public-1',
|
||||
);
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('big fish draft card restores the bound agent session and opens the result view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1770,6 +2009,7 @@ test('big fish draft card restores the bound agent session and opens the result
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
@@ -1815,6 +2055,109 @@ test('big fish draft card restores the bound agent session and opens the result
|
||||
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('big fish result publish action refreshes creation works', async () => {
|
||||
const user = userEvent.setup();
|
||||
const baseBigFishSession = (
|
||||
await getBigFishCreationSession('big-fish-session-1')
|
||||
).session;
|
||||
vi.mocked(getBigFishCreationSession).mockClear();
|
||||
vi.mocked(listBigFishWorks).mockClear();
|
||||
vi.mocked(listBigFishGallery).mockClear();
|
||||
const publishedBigFishSession = {
|
||||
...baseBigFishSession,
|
||||
stage: 'published',
|
||||
publishReady: true,
|
||||
assetCoverage: {
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
requiredLevelCount: 8,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
},
|
||||
updatedAt: '2026-04-22T12:20:00.000Z',
|
||||
};
|
||||
vi.mocked(executeBigFishCreationAction).mockResolvedValue({
|
||||
session: publishedBigFishSession,
|
||||
});
|
||||
vi.mocked(listBigFishWorks)
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-04-22T12:10:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-22T12:20:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const title = await screen.findByText('机械深海 大鱼吃小鱼');
|
||||
const card = title.closest('.platform-surface');
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
throw new Error('Missing big fish draft card');
|
||||
}
|
||||
|
||||
await user.click(card);
|
||||
await waitFor(() => {
|
||||
expect(getBigFishCreationSession).toHaveBeenCalledWith(
|
||||
'big-fish-session-1',
|
||||
);
|
||||
});
|
||||
vi.mocked(listBigFishWorks).mockClear();
|
||||
|
||||
expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: '发布' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(executeBigFishCreationAction).toHaveBeenCalledWith(
|
||||
'big-fish-session-1',
|
||||
{
|
||||
action: 'big_fish_publish_game',
|
||||
},
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listBigFishWorks).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listBigFishGallery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
} from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
@@ -343,7 +346,9 @@ test('mobile home search submits public work code', async () => {
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
@@ -359,8 +364,9 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /查看作品/u }));
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
isBigFishGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
@@ -223,7 +224,7 @@ function PublicCodeSearchBar({
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="输入 SY / CW / PZ 编号"
|
||||
placeholder="输入 SY / CW / BF / 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
|
||||
@@ -665,9 +666,11 @@ function DesktopTrendingItem({
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode)}
|
||||
{isBigFishGalleryEntry(entry)
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -714,13 +717,20 @@ function buildPublicCategoryGroups(
|
||||
}
|
||||
|
||||
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
return isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return isBigFishGalleryEntry(entry)
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string | null | undefined) {
|
||||
|
||||
@@ -2,15 +2,20 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
@@ -30,8 +35,26 @@ export type PlatformPuzzleGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformBigFishGalleryCard = {
|
||||
sourceType: 'big-fish';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
@@ -46,6 +69,12 @@ export function isPuzzleGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'puzzle';
|
||||
}
|
||||
|
||||
export function isBigFishGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformBigFishGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'big-fish';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -67,6 +96,27 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBigFishWorkToPlatformGalleryCard(
|
||||
work: BigFishWorkSummary,
|
||||
): PlatformBigFishGalleryCard {
|
||||
return {
|
||||
sourceType: 'big-fish',
|
||||
workId: work.workId,
|
||||
profileId: work.sourceSessionId,
|
||||
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: '大鱼创作者',
|
||||
worldName: work.title,
|
||||
subtitle: work.subtitle || '大鱼吃小鱼',
|
||||
summaryText: work.summary,
|
||||
coverImageSrc: work.coverImageSrc,
|
||||
themeTags: ['大鱼', `${work.levelCount}级`],
|
||||
visibility: 'published',
|
||||
publishedAt: work.updatedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
|
||||
if (entry.coverImageSrc) {
|
||||
return entry.coverImageSrc;
|
||||
@@ -88,6 +138,10 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
|
||||
}
|
||||
@@ -128,6 +182,10 @@ export function formatPlatformWorldTime(value: string | null) {
|
||||
export function resolvePlatformPublicWorkCode(
|
||||
entry: PlatformWorldCardLike,
|
||||
): string | null {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../services/customWorldAgentGenerationProgress';
|
||||
import {
|
||||
readCustomWorldAgentUiState,
|
||||
shouldRestoreCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
@@ -66,12 +67,16 @@ export function useRpgCreationSessionController(
|
||||
onSessionOpened,
|
||||
} = params;
|
||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||
const shouldRestoreInitialAgentUiStateRef = useRef(
|
||||
shouldRestoreCustomWorldAgentUiState(),
|
||||
);
|
||||
const isInitialAgentUiStateOwnedByCurrentUser =
|
||||
!initialAgentUiStateRef.current.ownerUserId ||
|
||||
initialAgentUiStateRef.current.ownerUserId === userId;
|
||||
const isHydratingInitialAgentWorkspaceRef = useRef(
|
||||
Boolean(
|
||||
initialAgentUiStateRef.current.activeSessionId &&
|
||||
shouldRestoreInitialAgentUiStateRef.current &&
|
||||
isInitialAgentUiStateOwnedByCurrentUser,
|
||||
),
|
||||
);
|
||||
@@ -88,6 +93,7 @@ export function useRpgCreationSessionController(
|
||||
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
|
||||
string | null
|
||||
>(() =>
|
||||
shouldRestoreInitialAgentUiStateRef.current &&
|
||||
isInitialAgentUiStateOwnedByCurrentUser
|
||||
? (initialAgentUiStateRef.current.activeSessionId ?? null)
|
||||
: null,
|
||||
@@ -95,6 +101,7 @@ export function useRpgCreationSessionController(
|
||||
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
|
||||
string | null
|
||||
>(() =>
|
||||
shouldRestoreInitialAgentUiStateRef.current &&
|
||||
isInitialAgentUiStateOwnedByCurrentUser
|
||||
? (initialAgentUiStateRef.current.activeOperationId ?? null)
|
||||
: null,
|
||||
@@ -209,7 +216,25 @@ export function useRpgCreationSessionController(
|
||||
useEffect(() => {
|
||||
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
|
||||
|
||||
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
|
||||
if (
|
||||
!initialAgentSessionId ||
|
||||
hasAppliedInitialAgentWorkspaceRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
initialAgentUiStateRef.current.ownerUserId &&
|
||||
userId &&
|
||||
initialAgentUiStateRef.current.ownerUserId !== userId
|
||||
) {
|
||||
hasAppliedInitialAgentWorkspaceRef.current = true;
|
||||
isHydratingInitialAgentWorkspaceRef.current = false;
|
||||
persistAgentUiState(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRestoreInitialAgentUiStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -781,7 +806,10 @@ export function useRpgCreationSessionController(
|
||||
}, []);
|
||||
|
||||
return {
|
||||
initialAgentSessionId: initialAgentUiStateRef.current.activeSessionId ?? null,
|
||||
initialAgentSessionId:
|
||||
shouldRestoreInitialAgentUiStateRef.current
|
||||
? (initialAgentUiStateRef.current.activeSessionId ?? null)
|
||||
: null,
|
||||
isCreatingAgentSession,
|
||||
activeAgentSessionId,
|
||||
activeAgentOperationId,
|
||||
|
||||
29
src/services/big-fish-gallery/bigFishGalleryClient.ts
Normal file
29
src/services/big-fish-gallery/bigFishGalleryClient.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_GALLERY_API_BASE = '/api/runtime/big-fish/gallery';
|
||||
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取大鱼吃小鱼公开广场列表。
|
||||
*/
|
||||
export async function listBigFishGallery() {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
BIG_FISH_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼广场失败',
|
||||
{
|
||||
retry: BIG_FISH_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishGalleryClient = {
|
||||
list: listBigFishGallery,
|
||||
};
|
||||
4
src/services/big-fish-gallery/index.ts
Normal file
4
src/services/big-fish-gallery/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
bigFishGalleryClient,
|
||||
listBigFishGallery,
|
||||
} from './bigFishGalleryClient';
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from 'vitest';
|
||||
import {
|
||||
clearCustomWorldAgentUiState,
|
||||
readCustomWorldAgentUiState,
|
||||
shouldRestoreCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from './customWorldAgentUiState';
|
||||
|
||||
@@ -73,3 +74,49 @@ test('custom world agent ui state reads from query first and persists to session
|
||||
clearCustomWorldAgentUiState(env);
|
||||
expect(readCustomWorldAgentUiState(env)).toEqual({});
|
||||
});
|
||||
|
||||
test('custom world agent ui state only auto restores stored pointers on RPG creation paths', () => {
|
||||
const sessionStorage = createMemoryStorage();
|
||||
sessionStorage.setItem(
|
||||
'genarrative.custom-world-agent-ui.v1',
|
||||
JSON.stringify({
|
||||
activeSessionId: 'session-1',
|
||||
ownerUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
shouldRestoreCustomWorldAgentUiState({
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
},
|
||||
history: null,
|
||||
sessionStorage,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldRestoreCustomWorldAgentUiState({
|
||||
location: {
|
||||
pathname: '/creation/rpg/agent',
|
||||
search: '',
|
||||
},
|
||||
history: null,
|
||||
sessionStorage,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('custom world agent ui state restores explicit query pointers on any main path', () => {
|
||||
expect(
|
||||
shouldRestoreCustomWorldAgentUiState({
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '?customWorldSessionId=session-1',
|
||||
},
|
||||
history: null,
|
||||
sessionStorage: createMemoryStorage(),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,49 @@ function normalizeGenerationSource(value: unknown) {
|
||||
return value === 'agent-draft-foundation' ? value : null;
|
||||
}
|
||||
|
||||
function hasExplicitAgentUiStateQuery(
|
||||
params: URLSearchParams,
|
||||
) {
|
||||
return (
|
||||
params.has(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY) ||
|
||||
params.has(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY) ||
|
||||
params.has(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePathname(value: string | undefined) {
|
||||
const pathname = value?.trim().toLowerCase() ?? '';
|
||||
if (!pathname || pathname === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return pathname.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function isRpgCreationRestorePath(pathname: string | undefined) {
|
||||
const normalizedPathname = normalizePathname(pathname);
|
||||
return (
|
||||
normalizedPathname === '/creation/rpg' ||
|
||||
normalizedPathname.startsWith('/creation/rpg/')
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRestoreCustomWorldAgentUiState(
|
||||
env?: CustomWorldAgentUiEnvironment,
|
||||
) {
|
||||
const resolved = resolveEnvironment(env);
|
||||
const params = new URLSearchParams(resolved.location?.search ?? '');
|
||||
|
||||
// URL 显式恢复参数优先于当前路径,用于支持外部分享或登录回跳后的深链恢复。
|
||||
if (hasExplicitAgentUiStateQuery(params)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// sessionStorage 里的残留指针只能在 RPG 创作页面生效,
|
||||
// 避免刷新平台首页时被旧工作区状态强制带到 Agent 页面。
|
||||
return isRpgCreationRestorePath(resolved.location?.pathname);
|
||||
}
|
||||
|
||||
export function readCustomWorldAgentUiState(
|
||||
env?: CustomWorldAgentUiEnvironment,
|
||||
): CustomWorldAgentUiState {
|
||||
|
||||
@@ -13,6 +13,14 @@ export function buildPuzzlePublicWorkCode(profileId: string) {
|
||||
return `PZ-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildBigFishPublicWorkCode(sessionId: string) {
|
||||
const normalized = normalizePublicCodeText(sessionId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `BF-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -22,3 +30,16 @@ export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameBigFishPublicWorkCode(
|
||||
keyword: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildBigFishPublicWorkCode(sessionId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user