Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb

This commit is contained in:
2026-04-25 14:29:58 +08:00
17 changed files with 489 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
@@ -146,4 +146,28 @@ describe('BigFishResultView', () => {
expect(screen.getByAltText('荧潮幼体')).toBeTruthy();
expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy();
});
test('shows publish failures in a dismissible modal', () => {
const onDismissError = vi.fn();
render(
<BigFishResultView
session={createSession()}
error="big_fish 发布校验未通过:还缺少 16 个基础动作"
onBack={() => {}}
onDismissError={onDismissError}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.getByText('发布失败')).toBeTruthy();
expect(
screen.getByText('big_fish 发布校验未通过:还缺少 16 个基础动作'),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(onDismissError).toHaveBeenCalledTimes(1);
});
});

View File

@@ -37,6 +37,7 @@ type BigFishResultViewProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onDismissError?: () => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
onStartTestRun: () => void;
};
@@ -330,6 +331,7 @@ export function BigFishResultView({
isBusy = false,
error = null,
onBack,
onDismissError,
onExecuteAction,
onStartTestRun,
}: BigFishResultViewProps) {
@@ -417,12 +419,6 @@ export function BigFishResultView({
</div>
</div>
{error ? (
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-h-0 overflow-y-auto pr-1">
<div className="grid gap-3 sm:grid-cols-2">
@@ -520,6 +516,58 @@ export function BigFishResultView({
}}
/>
) : null}
{error ? (
<BigFishResultErrorModal
message={error}
onClose={() => {
onDismissError?.();
}}
/>
) : null}
</div>
);
}
function BigFishResultErrorModal({
message,
onClose,
}: {
message: string;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="big-fish-result-error-title"
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div
id="big-fish-result-error-title"
className="text-base font-black text-slate-950"
>
</div>
<div className="mt-2 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</div>
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
>
</button>
</div>
</div>
);
}

View File

@@ -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({
))}
</div>
{settlementCopy ? (
<div className="pointer-events-none 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`}
>
<div className="text-3xl font-black tracking-[0.22em] 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>
</div>
) : null}
<div className="pointer-events-none absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
{isBusy ? <div>...</div> : null}
{error ? <div className="text-rose-200">{error}</div> : null}

View File

@@ -32,6 +32,37 @@ function ensureScrollApis() {
}
}
test('creation agent workspace keeps initial chat progress at zero percent', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
const progressbar = screen.getByRole('progressbar');
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
expect((progressbar.firstElementChild as HTMLElement | null)?.style.width).toBe(
'0%',
);
});
test('creation agent workspace filters duplicate recommended replies', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')

View File

@@ -311,6 +311,7 @@ export function CreationAgentWorkspace({
}
const progress = normalizeCreationAgentProgress(session.progressPercent);
const progressFillWidth = progress <= 0 ? '0%' : `${Math.max(6, progress)}%`;
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
const canShowPrimaryAction = progress >= 100;
const visibleQuickActions = quickActions.filter((action) =>
@@ -414,10 +415,16 @@ export function CreationAgentWorkspace({
{progress}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/12">
<div
className="h-2 overflow-hidden rounded-full bg-white/12"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<div
className={`h-full rounded-full transition-all ${theme.accentBgClass}`}
style={{ width: `${Math.max(6, progress)}%` }}
style={{ width: progressFillWidth }}
/>
</div>
<div className="mt-2 text-xs leading-5 text-white/64">

View File

@@ -1820,6 +1820,9 @@ export function PlatformEntryFlowShellImpl({
onBack={() => {
setSelectionStage('big-fish-agent-workspace');
}}
onDismissError={() => {
setBigFishError(null);
}}
onExecuteAction={(payload) => {
void executeBigFishAction(payload);
}}

View File

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

View File

@@ -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(
<ResolvedAssetImage
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
alt="候选图"
/>,
);
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(
<ResolvedAssetImage
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
alt="候选图"
/>,
);
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
});
});

View File

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

View File

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

View File

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