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

This commit is contained in:
2026-04-27 14:23:33 +08:00
50 changed files with 1908 additions and 270 deletions

View File

@@ -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}
/>
);

View File

@@ -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();
});

View File

@@ -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: [],
};
}

View File

@@ -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();
});
});

View File

@@ -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>

View 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();
});
});

View File

@@ -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>
);

View File

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

View File

@@ -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();
});

View File

@@ -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: [],
};
}

View File

@@ -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();

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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,

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

View File

@@ -0,0 +1,4 @@
export {
bigFishGalleryClient,
listBigFishGallery,
} from './bigFishGalleryClient';

View File

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

View File

@@ -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 {

View File

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