1
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { BigFishAgentWorkspace } from './BigFishAgentWorkspace';
|
||||
|
||||
const baseSession: BigFishSessionSnapshotResponse = {
|
||||
sessionId: 'big-fish-session-1',
|
||||
currentTurn: 3,
|
||||
progressPercent: 64,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
gameplayPromise: {
|
||||
key: 'gameplayPromise',
|
||||
label: '玩法承诺',
|
||||
value: '从微光小鱼一路吞噬成长为深海巨兽',
|
||||
status: 'confirmed',
|
||||
},
|
||||
ecologyVisualTheme: {
|
||||
key: 'ecologyVisualTheme',
|
||||
label: '生态视觉主题',
|
||||
value: '幽蓝珊瑚海沟',
|
||||
status: 'confirmed',
|
||||
},
|
||||
growthLadder: {
|
||||
key: 'growthLadder',
|
||||
label: '成长阶梯',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
riskTempo: {
|
||||
key: 'riskTempo',
|
||||
label: '风险节奏',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
assetSlots: [],
|
||||
assetCoverage: {
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
requiredLevelCount: 8,
|
||||
publishReady: false,
|
||||
blockers: [],
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '爽点和生态已经清楚,继续补剩余关键词。',
|
||||
createdAt: '2026-04-24T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '爽点和生态已经清楚,继续补剩余关键词。',
|
||||
publishReady: false,
|
||||
updatedAt: '2026-04-24T10:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
});
|
||||
|
||||
test('big fish workspace submits quick keyword fill request after two turns', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<BigFishAgentWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '补充剩余关键字' }));
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请补充剩余关键字。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('big fish workspace hides keyword fill before two turns', () => {
|
||||
render(
|
||||
<BigFishAgentWorkspace
|
||||
session={{ ...baseSession, currentTurn: 1 }}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull();
|
||||
});
|
||||
@@ -84,6 +84,17 @@ export function BigFishAgentWorkspace({
|
||||
isStreamingReply={Boolean(streamingReplyText)}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={[
|
||||
{
|
||||
key: 'summarize',
|
||||
label: '总结当前设定',
|
||||
},
|
||||
{
|
||||
key: 'quickFill',
|
||||
label: '补充剩余关键字',
|
||||
minTurn: 2,
|
||||
},
|
||||
]}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage({
|
||||
@@ -94,6 +105,15 @@ export function BigFishAgentWorkspace({
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'big_fish_compile_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
onSubmitMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('big-fish'),
|
||||
text:
|
||||
action.key === 'quickFill'
|
||||
? '请补充剩余关键字。'
|
||||
: '请总结一下当前已经成形的大鱼吃小鱼设定。',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BigFishAssetSlotResponse,
|
||||
BigFishRuntimeEntityResponse,
|
||||
BigFishRuntimeSnapshotResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type BigFishRuntimeShellProps = {
|
||||
run: BigFishRuntimeSnapshotResponse | null;
|
||||
assetSlots?: BigFishAssetSlotResponse[];
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -54,32 +57,87 @@ function projectEntity(
|
||||
};
|
||||
}
|
||||
|
||||
function findBigFishAssetSlot(
|
||||
slots: BigFishAssetSlotResponse[],
|
||||
assetKind: string,
|
||||
level?: number,
|
||||
motionKey?: string,
|
||||
) {
|
||||
return slots.find((slot) => {
|
||||
if (slot.assetKind !== assetKind || slot.status !== 'ready') {
|
||||
return false;
|
||||
}
|
||||
if (level !== undefined && slot.level !== level) {
|
||||
return false;
|
||||
}
|
||||
if (motionKey !== undefined && slot.motionKey !== motionKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRuntimeEntityAsset(
|
||||
entity: BigFishRuntimeEntityResponse,
|
||||
assetSlots: BigFishAssetSlotResponse[],
|
||||
) {
|
||||
return (
|
||||
findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'move_swim') ??
|
||||
findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'idle_float') ??
|
||||
findBigFishAssetSlot(assetSlots, 'level_main_image', entity.level)
|
||||
);
|
||||
}
|
||||
|
||||
function BigFishEntityDot({
|
||||
entity,
|
||||
run,
|
||||
owned,
|
||||
assetSlots,
|
||||
}: {
|
||||
entity: BigFishRuntimeEntityResponse;
|
||||
run: BigFishRuntimeSnapshotResponse;
|
||||
owned: boolean;
|
||||
assetSlots: BigFishAssetSlotResponse[];
|
||||
}) {
|
||||
const projected = projectEntity(entity, run);
|
||||
const isLeader = run.leaderEntityId === entity.entityId;
|
||||
const assetSlot = resolveRuntimeEntityAsset(entity, assetSlots);
|
||||
const entityImageSrc = assetSlot?.assetUrl?.trim() || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border shadow-lg transition-all ${
|
||||
owned
|
||||
? isLeader
|
||||
? 'border-cyan-100 bg-cyan-300 shadow-cyan-950/30'
|
||||
: 'border-cyan-100/70 bg-cyan-500/88 shadow-cyan-950/24'
|
||||
: entity.level > run.playerLevel
|
||||
? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24'
|
||||
: 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20'
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border shadow-lg transition-all ${
|
||||
entityImageSrc
|
||||
? owned
|
||||
? isLeader
|
||||
? 'border-cyan-50 shadow-cyan-950/40'
|
||||
: 'border-cyan-100/80 shadow-cyan-950/28'
|
||||
: entity.level > run.playerLevel
|
||||
? 'border-rose-100/80 shadow-rose-950/28'
|
||||
: 'border-emerald-100/80 shadow-emerald-950/24'
|
||||
: owned
|
||||
? isLeader
|
||||
? 'border-cyan-100 bg-cyan-300 shadow-cyan-950/30'
|
||||
: 'border-cyan-100/70 bg-cyan-500/88 shadow-cyan-950/24'
|
||||
: entity.level > run.playerLevel
|
||||
? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24'
|
||||
: 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20'
|
||||
}`}
|
||||
style={projected}
|
||||
>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-slate-950">
|
||||
{entityImageSrc ? (
|
||||
<>
|
||||
<ResolvedAssetImage
|
||||
src={entityImageSrc}
|
||||
alt={`Lv.${entity.level} 实体`}
|
||||
className={`h-full w-full object-cover ${
|
||||
owned && isLeader ? 'scale-110' : ''
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,transparent_32%,rgba(2,6,23,0.18)_72%,rgba(2,6,23,0.36)_100%)]" />
|
||||
</>
|
||||
) : null}
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-white [text-shadow:0_1px_2px_rgba(2,6,23,0.9)]">
|
||||
{entity.level}
|
||||
</span>
|
||||
</div>
|
||||
@@ -88,6 +146,7 @@ function BigFishEntityDot({
|
||||
|
||||
export function BigFishRuntimeShell({
|
||||
run,
|
||||
assetSlots = [],
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
@@ -142,10 +201,20 @@ export function BigFishRuntimeShell({
|
||||
|
||||
const statusLabel =
|
||||
run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中';
|
||||
const backgroundAsset =
|
||||
findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
<div className="relative h-full w-full max-w-[430px] overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]">
|
||||
{backgroundAsset ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundAsset}
|
||||
alt="大鱼吃小鱼场地背景"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(8,47,73,0.2),rgba(2,6,23,0.6))]" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:32px_32px] opacity-30" />
|
||||
|
||||
<div className="absolute left-0 top-0 z-20 flex w-full items-center justify-between px-4 py-4">
|
||||
@@ -168,6 +237,7 @@ export function BigFishRuntimeShell({
|
||||
entity={entity}
|
||||
run={run}
|
||||
owned={false}
|
||||
assetSlots={assetSlots}
|
||||
/>
|
||||
))}
|
||||
{run.ownedEntities.map((entity) => (
|
||||
@@ -176,6 +246,7 @@ export function BigFishRuntimeShell({
|
||||
entity={entity}
|
||||
run={run}
|
||||
owned
|
||||
assetSlots={assetSlots}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -61,11 +61,11 @@ import {
|
||||
} from '../../services/puzzle-agent';
|
||||
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
startPuzzleRun,
|
||||
swapPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime';
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
startLocalPuzzleRun,
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
@@ -1028,8 +1028,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { run } = await startPuzzleRun({ profileId });
|
||||
setPuzzleRun(run);
|
||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(startLocalPuzzleRun(item));
|
||||
setSelectionStage('puzzle-runtime');
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
||||
@@ -1064,28 +1065,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const swapPuzzlePiecesInRun = useCallback(
|
||||
async (payload: { firstPieceId: string; secondPieceId: string }) => {
|
||||
(payload: { firstPieceId: string; secondPieceId: string }) => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { run } = await swapPuzzlePieces(puzzleRun.runId, payload);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
[isPuzzleBusy, puzzleRun],
|
||||
);
|
||||
|
||||
const dragPuzzlePiece = useCallback(
|
||||
async (payload: {
|
||||
(payload: {
|
||||
pieceId: string;
|
||||
targetRow: number;
|
||||
targetCol: number;
|
||||
@@ -1094,19 +1086,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
[isPuzzleBusy, puzzleRun],
|
||||
);
|
||||
|
||||
const advancePuzzleLevel = useCallback(async () => {
|
||||
@@ -1114,18 +1097,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '进入下一关失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
}, [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage]);
|
||||
setPuzzleRun(advanceLocalPuzzleLevel(puzzleRun));
|
||||
}, [isPuzzleBusy, puzzleRun]);
|
||||
|
||||
const leaveAgentWorkspace = useCallback(() => {
|
||||
enterCreateTab();
|
||||
@@ -1872,6 +1846,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
<BigFishRuntimeShell
|
||||
run={bigFishRun}
|
||||
assetSlots={bigFishSession?.assetSlots ?? []}
|
||||
isBusy={isBigFishBusy}
|
||||
error={bigFishError}
|
||||
onBack={() => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
|
||||
|
||||
const baseSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 3,
|
||||
progressPercent: 62,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雾港遗迹拼图',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '潮雾中的灯塔与断桥',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '画面主体已经清楚,继续收束剩余关键词。',
|
||||
createdAt: '2026-04-24T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '画面主体已经清楚,继续收束剩余关键词。',
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-24T10:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
});
|
||||
|
||||
test('puzzle workspace submits quick keyword fill request after two turns', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '补充剩余关键字' }));
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请补充剩余关键字。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle workspace hides keyword fill before two turns', () => {
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={{ ...baseSession, currentTurn: 1 }}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull();
|
||||
});
|
||||
@@ -100,6 +100,17 @@ export function PuzzleAgentWorkspace({
|
||||
isStreamingReply={Boolean(streamingReplyText)}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={[
|
||||
{
|
||||
key: 'summarize',
|
||||
label: '总结当前设定',
|
||||
},
|
||||
{
|
||||
key: 'quickFill',
|
||||
label: '补充剩余关键字',
|
||||
minTurn: 2,
|
||||
},
|
||||
]}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage({
|
||||
@@ -110,6 +121,15 @@ export function PuzzleAgentWorkspace({
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'compile_puzzle_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
onSubmitMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
||||
text:
|
||||
action.key === 'quickFill'
|
||||
? '请补充剩余关键字。'
|
||||
: '请总结一下当前已经成形的拼图设定。',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ export function useRpgCreationSessionController(
|
||||
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
|
||||
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
|
||||
const currentAgentSessionIdRef = useRef<string | null>(null);
|
||||
const activeAgentReplyAbortControllerRef = useRef<AbortController | null>(
|
||||
null,
|
||||
);
|
||||
const latestAgentSessionSyncRequestIdRef = useRef(0);
|
||||
|
||||
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
|
||||
@@ -128,6 +131,11 @@ export function useRpgCreationSessionController(
|
||||
latestAgentSessionSyncRequestIdRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const abortActiveAgentReplyStream = useCallback(() => {
|
||||
activeAgentReplyAbortControllerRef.current?.abort();
|
||||
activeAgentReplyAbortControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const mergePendingAgentUserMessageIntoSession = useCallback(
|
||||
(
|
||||
session: CustomWorldAgentSessionSnapshot | null,
|
||||
@@ -223,8 +231,26 @@ export function useRpgCreationSessionController(
|
||||
setSelectionStage('agent-workspace');
|
||||
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionStage !== 'agent-workspace' &&
|
||||
selectionStage !== 'custom-world-generating'
|
||||
) {
|
||||
abortActiveAgentReplyStream();
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
}
|
||||
}, [abortActiveAgentReplyStream, selectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortActiveAgentReplyStream();
|
||||
};
|
||||
}, [abortActiveAgentReplyStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAgentSessionId) {
|
||||
abortActiveAgentReplyStream();
|
||||
invalidateAgentSessionSyncRequests();
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
@@ -238,6 +264,7 @@ export function useRpgCreationSessionController(
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
abortActiveAgentReplyStream();
|
||||
invalidateAgentSessionSyncRequests();
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
@@ -255,6 +282,7 @@ export function useRpgCreationSessionController(
|
||||
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
|
||||
|
||||
if (currentAgentSessionIdRef.current !== activeAgentSessionId) {
|
||||
abortActiveAgentReplyStream();
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
setStreamingAgentReplyText('');
|
||||
@@ -306,6 +334,7 @@ export function useRpgCreationSessionController(
|
||||
};
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
abortActiveAgentReplyStream,
|
||||
enterCreateTab,
|
||||
invalidateAgentSessionSyncRequests,
|
||||
persistAgentUiState,
|
||||
@@ -540,6 +569,8 @@ export function useRpgCreationSessionController(
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(true);
|
||||
setPendingAgentUserMessage(pendingMessagePayload);
|
||||
const replyAbortController = new AbortController();
|
||||
activeAgentReplyAbortControllerRef.current = replyAbortController;
|
||||
setAgentSession((current) =>
|
||||
mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload),
|
||||
);
|
||||
@@ -550,10 +581,17 @@ export function useRpgCreationSessionController(
|
||||
payload,
|
||||
{
|
||||
onUpdate: (text) => {
|
||||
if (replyAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setStreamingAgentReplyText(text);
|
||||
},
|
||||
signal: replyAbortController.signal,
|
||||
},
|
||||
);
|
||||
if (replyAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const mergedNextSession = mergePendingAgentUserMessageIntoSession(
|
||||
nextSession,
|
||||
pendingMessagePayload,
|
||||
@@ -568,6 +606,9 @@ export function useRpgCreationSessionController(
|
||||
hasServerEchoedPendingMessage ? null : pendingMessagePayload,
|
||||
);
|
||||
} catch (error) {
|
||||
if (replyAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const errorMessage = resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'发送共创消息失败。',
|
||||
@@ -597,7 +638,12 @@ export function useRpgCreationSessionController(
|
||||
setStreamingAgentReplyText('');
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
} finally {
|
||||
setIsStreamingAgentReply(false);
|
||||
if (activeAgentReplyAbortControllerRef.current === replyAbortController) {
|
||||
activeAgentReplyAbortControllerRef.current = null;
|
||||
}
|
||||
if (!replyAbortController.signal.aborted) {
|
||||
setIsStreamingAgentReply(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface StoryRequestOptions {
|
||||
|
||||
export interface TextStreamOptions {
|
||||
onUpdate?: (text: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageRequest {
|
||||
|
||||
195
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
195
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleGridSize,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return clearedLevelCount >= 3 ? 4 : 3;
|
||||
}
|
||||
|
||||
function buildInitialPositions(gridSize: PuzzleGridSize) {
|
||||
const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({
|
||||
row: Math.floor(index / gridSize),
|
||||
col: index % gridSize,
|
||||
}));
|
||||
return positions.slice(1).concat(positions.slice(0, 1));
|
||||
}
|
||||
|
||||
function rebuildBoardSnapshot(
|
||||
gridSize: PuzzleGridSize,
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleBoardSnapshot {
|
||||
const resolvedPieceIds = new Set(
|
||||
pieces
|
||||
.filter(
|
||||
(piece) =>
|
||||
piece.currentRow === piece.correctRow &&
|
||||
piece.currentCol === piece.correctCol,
|
||||
)
|
||||
.map((piece) => piece.pieceId),
|
||||
);
|
||||
const allTilesResolved = resolvedPieceIds.size === pieces.length;
|
||||
|
||||
return {
|
||||
rows: gridSize,
|
||||
cols: gridSize,
|
||||
pieces: pieces.map((piece) => ({
|
||||
...piece,
|
||||
mergedGroupId: resolvedPieceIds.has(piece.pieceId)
|
||||
? 'resolved-main'
|
||||
: null,
|
||||
})),
|
||||
mergedGroups: resolvedPieceIds.size
|
||||
? [
|
||||
{
|
||||
groupId: 'resolved-main',
|
||||
pieceIds: Array.from(resolvedPieceIds),
|
||||
occupiedCells: pieces
|
||||
.filter((piece) => resolvedPieceIds.has(piece.pieceId))
|
||||
.map((piece) => ({
|
||||
row: piece.currentRow,
|
||||
col: piece.currentCol,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
selectedPieceId: null,
|
||||
allTilesResolved,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
|
||||
const shuffledPositions = buildInitialPositions(gridSize);
|
||||
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||
const correctRow = Math.floor(index / gridSize);
|
||||
const correctCol = index % gridSize;
|
||||
const current = shuffledPositions[index];
|
||||
return {
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow,
|
||||
correctCol,
|
||||
currentRow: current.row,
|
||||
currentCol: current.col,
|
||||
mergedGroupId: null,
|
||||
};
|
||||
});
|
||||
return rebuildBoardSnapshot(gridSize, pieces);
|
||||
}
|
||||
|
||||
function applyNextBoard(
|
||||
run: PuzzleRunSnapshot,
|
||||
nextBoard: PuzzleBoardSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!run.currentLevel) {
|
||||
return run;
|
||||
}
|
||||
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount:
|
||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||
? run.clearedLevelCount + 1
|
||||
: run.clearedLevelCount,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
board: nextBoard,
|
||||
status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
return {
|
||||
runId: `local-puzzle-run-${item.profileId}-${Date.now()}`,
|
||||
entryProfileId: item.profileId,
|
||||
clearedLevelCount: 0,
|
||||
currentLevelIndex: 1,
|
||||
currentGridSize: gridSize,
|
||||
playedProfileIds: [item.profileId],
|
||||
previousLevelTags: item.themeTags,
|
||||
currentLevel: {
|
||||
runId: `local-puzzle-run-${item.profileId}`,
|
||||
levelIndex: 1,
|
||||
gridSize,
|
||||
profileId: item.profileId,
|
||||
levelName: item.levelName,
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
board: buildInitialBoard(gridSize),
|
||||
status: 'playing',
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function swapLocalPuzzlePieces(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId);
|
||||
const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId);
|
||||
if (!first || !second) {
|
||||
return run;
|
||||
}
|
||||
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
||||
first.currentRow = second.currentRow;
|
||||
first.currentCol = second.currentCol;
|
||||
second.currentRow = firstPosition.row;
|
||||
second.currentCol = firstPosition.col;
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
}
|
||||
|
||||
export function dragLocalPuzzlePiece(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
}
|
||||
if (
|
||||
payload.targetRow < 0 ||
|
||||
payload.targetCol < 0 ||
|
||||
payload.targetRow >= currentLevel.gridSize ||
|
||||
payload.targetCol >= currentLevel.gridSize
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
||||
if (!moving) {
|
||||
return run;
|
||||
}
|
||||
const occupying = pieces.find(
|
||||
(piece) =>
|
||||
piece.pieceId !== payload.pieceId &&
|
||||
piece.currentRow === payload.targetRow &&
|
||||
piece.currentCol === payload.targetCol,
|
||||
);
|
||||
const source = { row: moving.currentRow, col: moving.currentCol };
|
||||
moving.currentRow = payload.targetRow;
|
||||
moving.currentCol = payload.targetCol;
|
||||
if (occupying) {
|
||||
occupying.currentRow = source.row;
|
||||
occupying.currentCol = source.col;
|
||||
}
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
return run;
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export async function streamRpgCreationMessage(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
'发送共创消息失败',
|
||||
options.signal,
|
||||
);
|
||||
|
||||
return readCreationAgentSessionFromSse<RpgAgentSessionSnapshot>(response, {
|
||||
|
||||
@@ -21,11 +21,13 @@ export async function openRpgCreationSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user