@@ -520,6 +516,58 @@ export function BigFishResultView({
}}
/>
) : null}
+
+ {error ? (
+ {
+ onDismissError?.();
+ }}
+ />
+ ) : null}
+
+ );
+}
+
+function BigFishResultErrorModal({
+ message,
+ onClose,
+}: {
+ message: string;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ 发布失败
+
+
+ {message}
+
+
+
+
+
);
}
diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx
index e49471d1..10088c99 100644
--- a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx
+++ b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx
@@ -108,6 +108,24 @@ function resolveRuntimeEntityAsset(
);
}
+function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) {
+ if (run.status === 'won') {
+ return {
+ title: '通关完成',
+ message: `已成长到 Lv.${run.playerLevel},本轮生态征服完成。`,
+ tone: 'from-emerald-300/28 via-cyan-300/18 to-white/10',
+ };
+ }
+ if (run.status === 'failed') {
+ return {
+ title: '本轮失败',
+ message: '己方鱼群已经耗尽,重新调整路线再来一次。',
+ tone: 'from-rose-300/30 via-orange-300/16 to-white/10',
+ };
+ }
+ return null;
+}
+
function BigFishEntityDot({
entity,
run,
@@ -241,6 +259,7 @@ export function BigFishRuntimeShell({
const statusLabel =
run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中';
+ const settlementCopy = resolveSettlementCopy(run);
const backgroundAsset =
findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null;
@@ -298,6 +317,21 @@ export function BigFishRuntimeShell({
))}
+
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
index b8cccedb..b4ee6fb0 100644
--- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
+++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
@@ -1820,6 +1820,9 @@ export function PlatformEntryFlowShellImpl({
onBack={() => {
setSelectionStage('big-fish-agent-workspace');
}}
+ onDismissError={() => {
+ setBigFishError(null);
+ }}
onExecuteAction={(payload) => {
void executeBigFishAction(payload);
}}
diff --git a/src/editor/shared/jsonClient.test.ts b/src/editor/shared/jsonClient.test.ts
index 54626055..492c924f 100644
--- a/src/editor/shared/jsonClient.test.ts
+++ b/src/editor/shared/jsonClient.test.ts
@@ -3,6 +3,29 @@ import {describe, expect, it} from 'vitest';
import {parseApiErrorMessage} from './jsonClient';
describe('parseApiErrorMessage', () => {
+ it('prefers api error detail messages for business failures', () => {
+ expect(
+ parseApiErrorMessage(
+ JSON.stringify({
+ ok: false,
+ data: null,
+ error: {
+ code: 'BAD_REQUEST',
+ message: '请求参数不合法',
+ details: {
+ message: 'big_fish 发布校验未通过:还缺少 16 个基础动作',
+ provider: 'spacetimedb',
+ },
+ },
+ meta: {
+ apiVersion: '2026-04-08',
+ },
+ }),
+ 'Fallback failure',
+ ),
+ ).toBe('big_fish 发布校验未通过:还缺少 16 个基础动作');
+ });
+
it('prefers nested api error messages', () => {
expect(
parseApiErrorMessage(
diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx
new file mode 100644
index 00000000..4eac46a3
--- /dev/null
+++ b/src/hooks/useResolvedAssetReadUrl.test.tsx
@@ -0,0 +1,108 @@
+// @vitest-environment jsdom
+
+import { render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { ResolvedAssetImage } from '../components/ResolvedAssetImage';
+import { clearStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
+import { clearSignedAssetReadUrlCache } from '../services/assetReadUrlService';
+
+describe('useResolvedAssetReadUrl', () => {
+ beforeEach(() => {
+ clearSignedAssetReadUrlCache();
+ clearStoredAccessToken({ emit: false });
+ setStoredAccessToken('test-access-token', { emit: false });
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ clearStoredAccessToken({ emit: false });
+ });
+
+ test('generated 私有资源签名完成前不会把裸路径写入 img', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ ok: true,
+ data: {
+ read: {
+ objectKey:
+ 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
+ signedUrl: 'https://signed.example.com/puzzle.png',
+ expiresAt: '2099-01-01T00:10:00Z',
+ },
+ },
+ error: null,
+ meta: {
+ apiVersion: '2026-04-08',
+ routeVersion: '2026-04-08',
+ latencyMs: 1,
+ timestamp: '2099-01-01T00:00:00Z',
+ },
+ }),
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ ),
+ );
+
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
+
+ const image = await screen.findByRole('img', { name: '候选图' });
+ expect(image.getAttribute('src')).toBe('https://signed.example.com/puzzle.png');
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
+ 'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png',
+ );
+ });
+
+ test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ ok: false,
+ data: null,
+ error: {
+ code: 'NOT_FOUND',
+ message: '对象不存在',
+ },
+ meta: {
+ apiVersion: '2026-04-08',
+ routeVersion: '2026-04-08',
+ latencyMs: 1,
+ timestamp: '2099-01-01T00:00:00Z',
+ },
+ }),
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ ),
+ );
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ });
+ expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
+ });
+});
+
diff --git a/src/hooks/useResolvedAssetReadUrl.ts b/src/hooks/useResolvedAssetReadUrl.ts
index 4fae92e6..ddb2044a 100644
--- a/src/hooks/useResolvedAssetReadUrl.ts
+++ b/src/hooks/useResolvedAssetReadUrl.ts
@@ -18,7 +18,9 @@ export function useResolvedAssetReadUrl(
const normalizedSource = source?.trim() ?? '';
const shouldResolve =
enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource);
- const [resolvedUrl, setResolvedUrl] = useState(normalizedSource);
+ const [resolvedUrl, setResolvedUrl] = useState(
+ shouldResolve ? '' : normalizedSource,
+ );
useEffect(() => {
if (!normalizedSource) {
@@ -32,8 +34,8 @@ export function useResolvedAssetReadUrl(
}
let cancelled = false;
- // 生成资源的签名 URL 还没回来前,先保留原始路径占位,避免结果页/运行时首屏出现空白图块。
- setResolvedUrl(normalizedSource);
+ // 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。
+ setResolvedUrl('');
void resolveAssetReadUrl(normalizedSource, {
expireSeconds: options.expireSeconds,
@@ -45,8 +47,8 @@ export function useResolvedAssetReadUrl(
})
.catch(() => {
if (!cancelled) {
- // 读取签名失败时回退原始路径,至少保持现有 UI 可见错误表象。
- setResolvedUrl(normalizedSource);
+ // 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。
+ setResolvedUrl('');
}
});
diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
new file mode 100644
index 00000000..be9ba9e5
--- /dev/null
+++ b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from 'vitest';
+
+import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
+import {
+ advanceLocalPuzzleLevel,
+ dragLocalPuzzlePiece,
+ startLocalPuzzleRun,
+} from './puzzleLocalRuntime';
+
+const baseWork: PuzzleWorkSummary = {
+ workId: 'work-1',
+ profileId: 'profile-1',
+ ownerUserId: 'user-1',
+ sourceSessionId: null,
+ authorDisplayName: '测试作者',
+ levelName: '测试拼图',
+ summary: '服务层测试用拼图。',
+ themeTags: ['测试', '拼图'],
+ coverImageSrc: '/generated-puzzle-assets/test.png',
+ coverAssetId: null,
+ publicationStatus: 'published',
+ updatedAt: '2026-04-25T00:00:00.000Z',
+ publishedAt: '2026-04-25T00:00:00.000Z',
+ playCount: 0,
+ publishReady: true,
+};
+
+function solveCurrentLevel(run: ReturnType) {
+ let nextRun = run;
+ for (let index = 0; index < 12; index += 1) {
+ const currentLevel = nextRun.currentLevel;
+ if (!currentLevel || currentLevel.status === 'cleared') {
+ return nextRun;
+ }
+
+ const misplacedPiece = currentLevel.board.pieces.find(
+ (piece) =>
+ piece.currentRow !== piece.correctRow ||
+ piece.currentCol !== piece.correctCol,
+ );
+ if (!misplacedPiece) {
+ return nextRun;
+ }
+
+ nextRun = dragLocalPuzzlePiece(nextRun, {
+ pieceId: misplacedPiece.pieceId,
+ targetRow: misplacedPiece.correctRow,
+ targetCol: misplacedPiece.correctCol,
+ });
+ }
+ return nextRun;
+}
+
+describe('puzzleLocalRuntime', () => {
+ test('通关后提供下一关入口并能推进到新棋盘', () => {
+ const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
+
+ expect(clearedRun.currentLevel?.status).toBe('cleared');
+ expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
+
+ const nextRun = advanceLocalPuzzleLevel(clearedRun);
+
+ expect(nextRun.currentLevelIndex).toBe(2);
+ expect(nextRun.currentLevel?.status).toBe('playing');
+ expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关');
+ expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false);
+ expect(nextRun.recommendedNextProfileId).toBeNull();
+ });
+});
diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts
index cfa15349..527a207f 100644
--- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts
+++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts
@@ -89,17 +89,66 @@ function applyNextBoard(
return run;
}
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
+ const nextClearedLevelCount =
+ status === 'cleared' && run.currentLevel.status !== 'cleared'
+ ? run.clearedLevelCount + 1
+ : run.clearedLevelCount;
return {
...run,
- clearedLevelCount:
- status === 'cleared' && run.currentLevel.status !== 'cleared'
- ? run.clearedLevelCount + 1
- : run.clearedLevelCount,
+ clearedLevelCount: nextClearedLevelCount,
currentLevel: {
...run.currentLevel,
board: nextBoard,
status,
},
+ recommendedNextProfileId:
+ status === 'cleared'
+ ? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
+ : run.recommendedNextProfileId,
+ };
+}
+
+function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
+ return `${entryProfileId}::local-level-${levelIndex}`;
+}
+
+// 第一版单机玩法没有后端推荐池,本地沿用当前作品图片并生成可推进的临时关卡名。
+function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
+ return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
+}
+
+// 本地运行态只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
+function buildNextLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
+ const currentLevel = run.currentLevel;
+ if (!currentLevel || currentLevel.status !== 'cleared') {
+ return run;
+ }
+
+ const nextLevelIndex = run.currentLevelIndex + 1;
+ const gridSize = resolvePuzzleGridSize(run.clearedLevelCount);
+ const nextProfileId =
+ run.recommendedNextProfileId ??
+ buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
+
+ return {
+ ...run,
+ currentLevelIndex: nextLevelIndex,
+ currentGridSize: gridSize,
+ playedProfileIds: run.playedProfileIds.includes(nextProfileId)
+ ? run.playedProfileIds
+ : [...run.playedProfileIds, nextProfileId],
+ previousLevelTags: currentLevel.themeTags,
+ currentLevel: {
+ ...currentLevel,
+ runId: run.runId,
+ levelIndex: nextLevelIndex,
+ gridSize,
+ profileId: nextProfileId,
+ levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
+ board: buildInitialBoard(gridSize),
+ status: 'playing',
+ },
+ recommendedNextProfileId: null,
};
}
@@ -191,5 +240,5 @@ export function dragLocalPuzzlePiece(
}
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
- return run;
+ return buildNextLocalLevel(run);
}