This commit is contained in:
2026-04-24 22:27:45 +08:00
35 changed files with 1862 additions and 237 deletions

View File

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

View File

@@ -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'
? '请补充剩余关键字。'
: '请总结一下当前已经成形的大鱼吃小鱼设定。',
});
}}
/>
);
}

View File

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

View File

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

View File

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

View File

@@ -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'
? '请补充剩余关键字。'
: '请总结一下当前已经成形的拼图设定。',
});
}}
/>
);
}

View File

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

View File

@@ -52,6 +52,7 @@ export interface StoryRequestOptions {
export interface TextStreamOptions {
onUpdate?: (text: string) => void;
signal?: AbortSignal;
}
export interface CustomWorldSceneImageRequest {

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

View File

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

View File

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