Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao

# Conflicts:
#	server-rs/crates/api-server/src/jump_hop.rs
#	server-rs/crates/api-server/src/modules/jump_hop.rs
This commit is contained in:
2026-06-06 21:04:46 +08:00
451 changed files with 25780 additions and 2687 deletions
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createBarkBattleDraft,
deleteBarkBattleWork,
generateAllBarkBattleImageAssets,
publishBarkBattleWork,
regenerateBarkBattleImageAsset,
@@ -73,6 +74,21 @@ describe('barkBattleCreationClient', () => {
);
});
it('deletes a draft or published work through runtime works API', async () => {
requestJsonMock.mockResolvedValueOnce({ items: [] });
await deleteBarkBattleWork('bark-battle-work-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/bark-battle/works/bark-battle-work-1',
{ method: 'DELETE' },
'删除汪汪声浪作品失败',
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
}),
);
});
it('persists generated image slots into an existing draft config', async () => {
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
@@ -290,6 +290,17 @@ export function listBarkBattleWorks(
);
}
export function deleteBarkBattleWork(workId: string) {
return requestJson<BarkBattleWorksResponse>(
`${BARK_BATTLE_RUNTIME_API_BASE}/works/${encodeURIComponent(workId)}`,
{ method: 'DELETE' },
'删除汪汪声浪作品失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
},
);
}
export function listBarkBattleGallery() {
return requestJson<BarkBattleWorksResponse>(
`${BARK_BATTLE_RUNTIME_API_BASE}/gallery`,
@@ -441,6 +452,7 @@ export async function generateAllBarkBattleImageAssets(payload: {
export const barkBattleCreationClient = {
createDraft: createBarkBattleDraft,
deleteWork: deleteBarkBattleWork,
generateAllImageAssets: generateAllBarkBattleImageAssets,
listGallery: listBarkBattleGallery,
listWorks: listBarkBattleWorks,
@@ -7,6 +7,7 @@ export {
type BarkBattleImageGenerationFailures,
type BarkBattleUploadedAsset,
createBarkBattleDraft,
deleteBarkBattleWork,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,
+103
View File
@@ -27,6 +27,19 @@ beforeEach(() => {
requestJsonMock.mockReset();
});
test('jump hop delete work uses creation works endpoint', async () => {
const { jumpHopClient } = await import('./jumpHopClient');
requestJsonMock.mockResolvedValueOnce({ items: [] });
await jumpHopClient.deleteWork('jump-hop-profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/jump-hop/works/jump-hop-profile-1',
{ method: 'DELETE' },
'删除跳一跳作品失败',
);
});
test('jump hop creation keeps image2 generation requests alive long enough', async () => {
await import('./jumpHopClient');
@@ -37,3 +50,93 @@ test('jump hop creation keeps image2 generation requests alive long enough', asy
}),
);
});
test('jump hop work detail preserves flattened back button asset', async () => {
const backButtonAsset = {
assetId: 'back-button-1',
imageSrc: '/generated-jump-hop-assets/back-button-1.png',
imageObjectKey: 'jump-hop/back-button-1.png',
assetObjectId: 'asset-object-back-button-1',
generationProvider: 'image2',
prompt: '主题返回按钮',
width: 1024,
height: 1024,
};
const characterAsset = {
assetId: 'character-1',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'character-object-1',
generationProvider: 'builtin-three',
prompt: '内置默认角色',
width: 0,
height: 0,
};
const draft = {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
themeText: '森林茶馆',
workTitle: '森林茶馆跳一跳',
workDescription: '森林茶馆主题',
themeTags: ['森林茶馆', '跳一跳'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
defaultCharacter: null,
characterPrompt: '内置默认角色',
tilePrompt: '森林茶馆主题地块',
endMoodPrompt: null,
characterAsset,
tileAtlasAsset: characterAsset,
tileAssets: [],
path: {
seed: 'profile-1',
difficulty: 'standard',
platforms: [],
scoring: {
perfectRadiusRatio: 0.24,
hitRadiusRatio: 0.52,
maxChargeMs: 1200,
minChargeMs: 80,
maxJumpDistance: 5,
},
},
coverComposite: null,
backButtonAsset: null,
generationStatus: 'ready',
};
requestJsonMock.mockResolvedValue({
item: {
runtimeKind: 'jump-hop',
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'owner-1',
sourceSessionId: 'session-1',
themeText: '森林茶馆',
workTitle: '森林茶馆跳一跳',
workDescription: '森林茶馆主题',
themeTags: ['森林茶馆', '跳一跳'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-05T00:00:00Z',
publishedAt: '2026-06-05T00:00:00Z',
publishReady: true,
generationStatus: 'ready',
draft,
path: draft.path,
defaultCharacter: null,
characterAsset,
tileAtlasAsset: characterAsset,
tileAssets: [],
backButtonAsset,
},
});
const { jumpHopClient } = await import('./jumpHopClient');
const response = await jumpHopClient.getWorkDetail('profile-1');
expect(response.item.backButtonAsset).toEqual(backButtonAsset);
});
+11
View File
@@ -136,6 +136,8 @@ function normalizeJumpHopWorkProfile(
characterAsset: flattened.characterAsset,
tileAtlasAsset: flattened.tileAtlasAsset,
tileAssets: flattened.tileAssets,
backButtonAsset:
flattened.backButtonAsset ?? flattened.draft?.backButtonAsset ?? null,
};
}
@@ -248,6 +250,14 @@ export async function publishJumpHopWork(profileId: string) {
return normalizeJumpHopWorkMutationResponse(response);
}
export async function deleteJumpHopWork(profileId: string) {
return requestJson<JumpHopWorksResponse>(
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'DELETE' },
'删除跳一跳作品失败',
);
}
export async function startJumpHopRuntimeRun(
profileId: string,
options: JumpHopStartRunOptions = {},
@@ -339,6 +349,7 @@ export async function restartJumpHopRuntimeRun(
export const jumpHopClient = {
createSession: createJumpHopCreationSession,
deleteWork: deleteJumpHopWork,
getSession: getJumpHopCreationSession,
executeAction: executeJumpHopCreationAction,
getGalleryDetail: getJumpHopGalleryDetail,
@@ -5,6 +5,7 @@ import {
buildJumpHopGenerationAnchorEntries,
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleClearGenerationAnchorEntries,
buildPuzzleGenerationAnchorEntries,
buildWoodenFishGenerationAnchorEntries,
createMiniGameDraftGenerationState,
@@ -653,6 +654,56 @@ describe('miniGameDraftGenerationProgress', () => {
]);
});
test('puzzle clear draft generation exposes atlas and slice pipeline', () => {
const state = createMiniGameDraftGenerationState('puzzle-clear');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 190_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'puzzle-clear-draft',
'puzzle-clear-background',
'puzzle-clear-atlas',
'puzzle-clear-slices',
'puzzle-clear-write-draft',
]);
expect(progress?.phaseId).toBe('puzzle-clear-atlas');
expect(progress?.phaseLabel).toBe('生成复合图集');
expect(progress?.estimatedRemainingMs).toBe(430_000);
});
test('puzzle clear generation anchors expose title, theme and background mode', () => {
const entries = buildPuzzleClearGenerationAnchorEntries(null, {
templateId: 'puzzle-clear',
workTitle: '星港拼消消',
workDescription: '星港主题连续消除。',
themePrompt: '霓虹星港',
boardBackgroundPrompt: '美少女',
generateBoardBackground: true,
boardBackgroundAsset: null,
});
expect(entries).toEqual([
{
id: 'puzzle-clear-title',
label: '作品',
value: '星港拼消消',
},
{
id: 'puzzle-clear-theme',
label: '主题',
value: '霓虹星港',
},
{
id: 'puzzle-clear-background',
label: '底图',
value: '美少女',
},
]);
});
test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries(
{
+136 -4
View File
@@ -11,6 +11,10 @@ import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/puzzleAgentSession';
import type {
PuzzleClearSessionSnapshotResponse,
PuzzleClearWorkspaceCreateRequest,
} from '../../packages/shared/src/contracts/puzzleClear';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
@@ -33,6 +37,7 @@ export type MiniGameDraftGenerationKind =
| 'match3d'
| 'baby-object-match'
| 'jump-hop'
| 'puzzle-clear'
| 'wooden-fish';
export type MiniGameDraftGenerationPhase =
@@ -66,6 +71,11 @@ export type MiniGameDraftGenerationPhase =
| 'jump-hop-tile-atlas'
| 'jump-hop-slice-tiles'
| 'jump-hop-write-draft'
| 'puzzle-clear-draft'
| 'puzzle-clear-background'
| 'puzzle-clear-atlas'
| 'puzzle-clear-slices'
| 'puzzle-clear-write-draft'
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
@@ -418,6 +428,41 @@ const JUMP_HOP_STEPS = [
const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000;
const PUZZLE_CLEAR_STEPS = [
{
id: 'puzzle-clear-draft',
label: '整理玩法草稿',
detail: '保存作品信息、主题词与底图策略。',
weight: 8,
},
{
id: 'puzzle-clear-background',
label: '准备场地底图',
detail: '处理上传底图或生成中央场地底图。',
weight: 22,
},
{
id: 'puzzle-clear-atlas',
label: '生成复合图集',
detail: '生成 135 组复合图案 atlas。',
weight: 42,
},
{
id: 'puzzle-clear-slices',
label: '切分卡牌碎片',
detail: '按预排坐标切成 1x1 卡牌碎片并校验。',
weight: 20,
},
{
id: 'puzzle-clear-write-draft',
label: '写入正式草稿',
detail: '保存底图、图集、碎片和作品摘要。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_CLEAR_ESTIMATED_WAIT_MS = 620_000;
const WOODEN_FISH_STEPS = [
{
id: 'wooden-fish-draft',
@@ -497,6 +542,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'jump-hop') {
return JUMP_HOP_STEPS;
}
if (kind === 'puzzle-clear') {
return PUZZLE_CLEAR_STEPS;
}
if (kind === 'wooden-fish') {
return WOODEN_FISH_STEPS;
}
@@ -571,9 +619,11 @@ export function createMiniGameDraftGenerationState(
? 'baby-object-draft'
: kind === 'jump-hop'
? 'jump-hop-draft'
: kind === 'wooden-fish'
? 'wooden-fish-draft'
: 'compile',
: kind === 'puzzle-clear'
? 'puzzle-clear-draft'
: kind === 'wooden-fish'
? 'wooden-fish-draft'
: 'compile',
startedAtMs,
completedAssetCount: 0,
totalAssetCount: 0,
@@ -657,6 +707,24 @@ function resolveJumpHopPhaseByElapsedMs(
return 'jump-hop-draft';
}
function resolvePuzzleClearPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 590_000) {
return 'puzzle-clear-write-draft';
}
if (elapsedMs >= 470_000) {
return 'puzzle-clear-slices';
}
if (elapsedMs >= 120_000) {
return 'puzzle-clear-atlas';
}
if (elapsedMs >= 8_000) {
return 'puzzle-clear-background';
}
return 'puzzle-clear-draft';
}
function buildWoodenFishPhaseTimeline(): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
@@ -798,6 +866,8 @@ function resolveElapsedActiveStepProgressRatio(
? BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS
: kind === 'jump-hop'
? JUMP_HOP_ESTIMATED_WAIT_MS
: kind === 'puzzle-clear'
? PUZZLE_CLEAR_ESTIMATED_WAIT_MS
: kind === 'wooden-fish'
? WOODEN_FISH_ESTIMATED_WAIT_MS
: 1;
@@ -924,7 +994,14 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
}
: state;
: state.kind === 'puzzle-clear' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolvePuzzleClearPhaseByElapsedMs(elapsedMs),
}
: state;
const puzzleTimedSteps =
normalizedState.kind === 'puzzle'
@@ -984,6 +1061,11 @@ export function buildMiniGameDraftGenerationProgress(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'puzzle-clear'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'wooden-fish'
? (woodenFishTimeline?.activeStepProgressRatio ?? 0)
: 0;
@@ -1023,6 +1105,8 @@ export function buildMiniGameDraftGenerationProgress(
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
: normalizedState.kind === 'jump-hop'
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
: normalizedState.kind === 'puzzle-clear'
? '拼消消草稿已准备完成,可进入结果页试玩或发布。'
: normalizedState.kind === 'wooden-fish'
? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
@@ -1050,6 +1134,11 @@ export function buildMiniGameDraftGenerationProgress(
? Math.max(0, BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'jump-hop'
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'puzzle-clear'
? Math.max(
0,
PUZZLE_CLEAR_ESTIMATED_WAIT_MS - elapsedMs,
)
: normalizedState.kind === 'wooden-fish'
? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs)
: null,
@@ -1163,6 +1252,49 @@ export function buildWoodenFishGenerationAnchorEntries(
.filter((entry) => entry.value.trim());
}
export function buildPuzzleClearGenerationAnchorEntries(
session: PuzzleClearSessionSnapshotResponse | null | undefined,
formPayload: PuzzleClearWorkspaceCreateRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const draft = session?.draft;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'puzzle-clear-title',
label: '作品',
value:
formPayload?.workTitle?.trim() || draft?.workTitle?.trim() || '拼消消',
},
{
key: 'puzzle-clear-theme',
label: '主题',
value:
formPayload?.themePrompt?.trim() || draft?.themePrompt?.trim() || '',
},
{
key: 'puzzle-clear-background',
label: '底图',
value:
formPayload?.boardBackgroundPrompt?.trim() ||
draft?.boardBackgroundPrompt?.trim() ||
(formPayload?.boardBackgroundAsset ?? draft?.boardBackgroundAsset)
?.prompt?.trim() ||
(formPayload?.generateBoardBackground ??
draft?.generateBoardBackground
? 'AI生成'
: '上传底图'),
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
export function buildPuzzleGenerationAnchorEntries(
session: PuzzleAgentSessionSnapshot | null | undefined,
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
+20
View File
@@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest';
import {
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildPuzzleClearPublicWorkCode,
buildWoodenFishPublicWorkCode,
isSameCustomWorldPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSamePuzzleClearPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from './publicWorkCode';
@@ -28,6 +30,24 @@ describe('publicWorkCode', () => {
).toBe(true);
});
it('builds and matches puzzle-clear public work codes from profile ids', () => {
expect(buildPuzzleClearPublicWorkCode('puzzle-clear-profile-12345678')).toBe(
'PC-12345678',
);
expect(
isSamePuzzleClearPublicWorkCode(
'pc-12345678',
'puzzle-clear-profile-12345678',
),
).toBe(true);
expect(
isSamePuzzleClearPublicWorkCode(
'puzzle clear profile 12345678',
'puzzle-clear-profile-12345678',
),
).toBe(true);
});
it('builds wooden fish public work codes with WF prefix', () => {
expect(buildWoodenFishPublicWorkCode('wooden-fish-profile-1234abcd')).toBe(
'WF-1234ABCD',
+21
View File
@@ -13,6 +13,14 @@ export function buildPuzzlePublicWorkCode(profileId: string) {
return `PZ-${suffix}`;
}
export function buildPuzzleClearPublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `PC-${suffix}`;
}
export function buildBigFishPublicWorkCode(sessionId: string) {
const normalized = normalizePublicCodeText(sessionId);
const fallback = normalized || '00000000';
@@ -115,6 +123,19 @@ export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
);
}
export function isSamePuzzleClearPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildPuzzleClearPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameBigFishPublicWorkCode(
keyword: string,
sessionId: string,
@@ -0,0 +1,125 @@
import { beforeEach, expect, test, vi } from 'vitest';
const requestJsonMock = vi.hoisted(() => vi.fn());
const { createCreationAgentClientMock } = vi.hoisted(() => ({
createCreationAgentClientMock: vi.fn(),
}));
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
vi.mock('../creation-agent', () => ({
createCreationAgentClient: createCreationAgentClientMock,
}));
vi.mock('../runtimeGuestAuth', () => ({
buildRuntimeGuestAuthOptions: vi.fn(() => ({})),
buildRuntimeGuestHeaders: vi.fn(() => ({})),
}));
beforeEach(() => {
vi.resetModules();
createCreationAgentClientMock.mockReset();
createCreationAgentClientMock.mockReturnValue({
createSession: vi.fn(),
getSession: vi.fn(),
sendMessage: vi.fn(),
streamMessage: vi.fn(),
executeAction: vi.fn(),
});
requestJsonMock.mockReset();
});
test('拼消消创作保留足够长的 image2 生成等待窗口', async () => {
await import('./puzzleClearClient');
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
expect.objectContaining({
createSessionTimeoutMs: 40 * 60 * 1000,
executeActionTimeoutMs: 40 * 60 * 1000,
}),
);
});
test('拼消消客户端区分创作详情和公开运行态详情', async () => {
const { puzzleClearClient } = await import('./puzzleClearClient');
requestJsonMock.mockResolvedValue({
item: {
summary: {
runtimeKind: 'puzzle-clear',
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'session-1',
workTitle: '拼消消',
workDescription: '',
themePrompt: '星港',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T00:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'puzzle-clear',
templateName: '拼消消',
profileId: 'profile-1',
workTitle: '拼消消',
workDescription: '',
themePrompt: '星港',
generateBoardBackground: true,
boardBackgroundAsset: null,
cardBackImageSrc: null,
atlasAsset: {
assetId: 'atlas-1',
imageSrc: '/generated-puzzle-clear-assets/atlas.png',
imageObjectKey: 'generated-puzzle-clear-assets/atlas.png',
assetObjectId: 'assetobj_atlas',
generationProvider: 'gpt-image-2',
prompt: '星港',
width: 1024,
height: 1536,
},
patternGroups: [],
cardAssets: [],
generationStatus: 'ready',
},
boardBackgroundAsset: null,
atlasAsset: {
assetId: 'atlas-1',
imageSrc: '/generated-puzzle-clear-assets/atlas.png',
imageObjectKey: 'generated-puzzle-clear-assets/atlas.png',
assetObjectId: 'assetobj_atlas',
generationProvider: 'gpt-image-2',
prompt: '星港',
width: 1024,
height: 1536,
},
patternGroups: [],
cardAssets: [],
},
});
await puzzleClearClient.getWorkDetail('profile-1');
await puzzleClearClient.getRuntimeWorkDetail('profile-1');
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/creation/puzzle-clear/works/profile-1',
{ method: 'GET' },
'读取拼消消作品详情失败',
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/runtime/puzzle-clear/works/profile-1',
{ method: 'GET' },
'读取拼消消公开作品详情失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
@@ -0,0 +1,417 @@
import type {
PuzzleClearActionRequest,
PuzzleClearActionResponse,
PuzzleClearGalleryCardResponse,
PuzzleClearGalleryDetailResponse,
PuzzleClearNextLevelRequest,
PuzzleClearRetryLevelRequest,
PuzzleClearRunResponse,
PuzzleClearSessionResponse,
PuzzleClearSessionSnapshotResponse,
PuzzleClearSwapRequest,
PuzzleClearTimeUpRequest,
PuzzleClearWorkDetailResponse,
PuzzleClearWorkMutationResponse,
PuzzleClearWorkProfileResponse,
PuzzleClearWorksResponse,
PuzzleClearWorkspaceCreateRequest,
PuzzleClearWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const PUZZLE_CLEAR_API_BASE = '/api/creation/puzzle-clear/sessions';
const PUZZLE_CLEAR_WORKS_API_BASE = '/api/creation/puzzle-clear/works';
const PUZZLE_CLEAR_RUNTIME_API_BASE = '/api/runtime/puzzle-clear';
// 中文注释:拼消消编译要串行等待底图、4 张素材工作表、切片、最终 atlas 合成和 OSS 写入;VectorEngine 偶发单张图可超过 10 分钟。
const PUZZLE_CLEAR_GENERATION_TIMEOUT_MS = 40 * 60 * 1000;
const PUZZLE_CLEAR_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const PUZZLE_CLEAR_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
export type {
PuzzleClearActionRequest,
PuzzleClearActionResponse,
PuzzleClearGalleryCardResponse,
PuzzleClearGalleryDetailResponse,
PuzzleClearNextLevelRequest,
PuzzleClearRetryLevelRequest,
PuzzleClearRunResponse,
PuzzleClearSessionResponse,
PuzzleClearSessionSnapshotResponse,
PuzzleClearSwapRequest,
PuzzleClearTimeUpRequest,
PuzzleClearWorkDetailResponse,
PuzzleClearWorkMutationResponse,
PuzzleClearWorkProfileResponse,
PuzzleClearWorksResponse,
PuzzleClearWorkspaceCreateRequest,
PuzzleClearWorkSummaryResponse,
};
export type CreatePuzzleClearSessionRequest =
PuzzleClearWorkspaceCreateRequest;
export type PuzzleClearSessionSnapshot = PuzzleClearSessionSnapshotResponse;
type PuzzleClearRuntimeRequestOptions = RuntimeGuestRequestOptions;
const puzzleClearCreationClient = createCreationAgentClient<
PuzzleClearWorkspaceCreateRequest,
PuzzleClearSessionResponse,
PuzzleClearSessionResponse,
PuzzleClearSessionSnapshotResponse,
never,
never,
PuzzleClearActionRequest,
PuzzleClearActionResponse
>({
apiBase: PUZZLE_CLEAR_API_BASE,
messages: {
createSession: '创建拼消消共创会话失败',
getSession: '读取拼消消共创会话失败',
sendMessage: '发送拼消消共创消息失败',
streamIncomplete: '拼消消共创消息流式结果不完整',
executeAction: '执行拼消消共创操作失败',
},
createSessionTimeoutMs: PUZZLE_CLEAR_GENERATION_TIMEOUT_MS,
executeActionTimeoutMs: PUZZLE_CLEAR_GENERATION_TIMEOUT_MS,
});
type FlattenedPuzzleClearWorkProfileResponse = Omit<
PuzzleClearWorkProfileResponse,
'summary'
> &
PuzzleClearWorkSummaryResponse;
export type PuzzleClearGalleryResponse = {
items: PuzzleClearGalleryCardResponse[];
hasMore: boolean;
nextCursor: string | null;
};
function normalizePuzzleClearWorkProfile(
work:
| PuzzleClearWorkProfileResponse
| FlattenedPuzzleClearWorkProfileResponse,
): PuzzleClearWorkProfileResponse {
if ('summary' in work && work.summary) {
return work;
}
const flattened = work as FlattenedPuzzleClearWorkProfileResponse;
const summary: PuzzleClearWorkProfileResponse['summary'] = {
runtimeKind: flattened.runtimeKind,
workId: flattened.workId,
profileId: flattened.profileId,
ownerUserId: flattened.ownerUserId,
sourceSessionId: flattened.sourceSessionId ?? null,
workTitle: flattened.workTitle,
workDescription: flattened.workDescription,
themePrompt: flattened.themePrompt,
coverImageSrc: flattened.coverImageSrc ?? null,
publicationStatus: flattened.publicationStatus,
playCount: flattened.playCount,
updatedAt: flattened.updatedAt,
publishedAt: flattened.publishedAt ?? null,
publishReady: flattened.publishReady,
generationStatus: flattened.generationStatus,
};
return {
summary,
draft: flattened.draft,
boardBackgroundAsset:
flattened.boardBackgroundAsset ?? flattened.draft?.boardBackgroundAsset ?? null,
atlasAsset: flattened.atlasAsset,
patternGroups: flattened.patternGroups,
cardAssets: flattened.cardAssets,
};
}
function normalizePuzzleClearActionResponse(
response: PuzzleClearActionResponse,
): PuzzleClearActionResponse {
return {
...response,
work: response.work ? normalizePuzzleClearWorkProfile(response.work) : null,
};
}
function normalizePuzzleClearWorkDetailResponse(
response: PuzzleClearWorkDetailResponse,
): PuzzleClearWorkDetailResponse {
return {
...response,
item: normalizePuzzleClearWorkProfile(response.item),
};
}
function normalizePuzzleClearWorkMutationResponse(
response: PuzzleClearWorkMutationResponse,
): PuzzleClearWorkMutationResponse {
return {
...response,
item: normalizePuzzleClearWorkProfile(response.item),
};
}
export function createPuzzleClearCreationSession(
payload: PuzzleClearWorkspaceCreateRequest,
) {
return puzzleClearCreationClient.createSession(payload);
}
export function getPuzzleClearCreationSession(sessionId: string) {
return puzzleClearCreationClient.getSession(sessionId);
}
export function executePuzzleClearCreationAction(
sessionId: string,
payload: PuzzleClearActionRequest,
) {
return puzzleClearCreationClient
.executeAction(sessionId, payload)
.then(normalizePuzzleClearActionResponse);
}
export async function getPuzzleClearWorkDetail(profileId: string) {
const response = await requestJson<PuzzleClearWorkDetailResponse>(
`${PUZZLE_CLEAR_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取拼消消作品详情失败',
);
return normalizePuzzleClearWorkDetailResponse(response);
}
export async function getPuzzleClearRuntimeWorkDetail(profileId: string) {
const response = await requestJson<PuzzleClearWorkDetailResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取拼消消公开作品详情失败',
{
retry: PUZZLE_CLEAR_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
return normalizePuzzleClearWorkDetailResponse(response);
}
export async function listPuzzleClearWorks() {
return requestJson<PuzzleClearWorksResponse>(
PUZZLE_CLEAR_WORKS_API_BASE,
{ method: 'GET' },
'读取拼消消作品列表失败',
{
retry: PUZZLE_CLEAR_RUNTIME_READ_RETRY,
},
);
}
export async function listPuzzleClearGallery() {
return requestJson<PuzzleClearGalleryResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/gallery`,
{ method: 'GET' },
'读取拼消消广场失败',
{
retry: PUZZLE_CLEAR_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
export async function getPuzzleClearGalleryDetail(publicWorkCode: string) {
const response = await requestJson<PuzzleClearGalleryDetailResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/gallery/${encodeURIComponent(publicWorkCode)}`,
{ method: 'GET' },
'读取拼消消广场详情失败',
{
retry: PUZZLE_CLEAR_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
return normalizePuzzleClearWorkDetailResponse(response);
}
export async function publishPuzzleClearWork(profileId: string) {
const response = await requestJson<PuzzleClearWorkMutationResponse>(
`${PUZZLE_CLEAR_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
{ method: 'POST' },
'发布拼消消作品失败',
);
return normalizePuzzleClearWorkMutationResponse(response);
}
export async function startPuzzleClearRuntimeRun(
profileId: string,
options: PuzzleClearRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<PuzzleClearRunResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/runs`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({ profileId }),
},
'启动拼消消运行态失败',
{
retry: PUZZLE_CLEAR_RUNTIME_WRITE_RETRY,
...requestOptions,
},
);
}
export async function getPuzzleClearRuntimeRun(
runId: string,
options: PuzzleClearRuntimeRequestOptions = {},
) {
return requestJson<PuzzleClearRunResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
{
method: 'GET',
headers: buildRuntimeGuestHeaders(options),
},
'读取拼消消运行态失败',
{
retry: PUZZLE_CLEAR_RUNTIME_READ_RETRY,
...buildRuntimeGuestAuthOptions(options),
},
);
}
export async function swapPuzzleClearCards(
runId: string,
payload: Omit<PuzzleClearSwapRequest, 'clientActionId'>,
options: PuzzleClearRuntimeRequestOptions = {},
) {
const requestPayload: PuzzleClearSwapRequest = {
...payload,
clientActionId: `swap-${runId}-${Date.now()}`,
};
return requestJson<PuzzleClearRunResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/swap`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'交换拼消消卡片失败',
{
retry: PUZZLE_CLEAR_RUNTIME_WRITE_RETRY,
...buildRuntimeGuestAuthOptions(options),
},
);
}
export async function retryPuzzleClearLevel(
runId: string,
options: PuzzleClearRuntimeRequestOptions = {},
) {
const requestPayload: PuzzleClearRetryLevelRequest = {
clientActionId: `retry-${runId}-${Date.now()}`,
};
return requestJson<PuzzleClearRunResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/retry-level`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'重试拼消消关卡失败',
{
retry: PUZZLE_CLEAR_RUNTIME_WRITE_RETRY,
...buildRuntimeGuestAuthOptions(options),
},
);
}
export async function advancePuzzleClearNextLevel(
runId: string,
options: PuzzleClearRuntimeRequestOptions = {},
) {
const requestPayload: PuzzleClearNextLevelRequest = {
clientActionId: `next-${runId}-${Date.now()}`,
};
return requestJson<PuzzleClearRunResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/next-level`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'推进拼消消关卡失败',
{
retry: PUZZLE_CLEAR_RUNTIME_WRITE_RETRY,
...buildRuntimeGuestAuthOptions(options),
},
);
}
export async function markPuzzleClearTimeUp(
runId: string,
options: PuzzleClearRuntimeRequestOptions = {},
) {
const requestPayload: PuzzleClearTimeUpRequest = {
clientActionId: `time-up-${runId}-${Date.now()}`,
};
return requestJson<PuzzleClearRunResponse>(
`${PUZZLE_CLEAR_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/time-up`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'同步拼消消倒计时失败',
{
retry: PUZZLE_CLEAR_RUNTIME_WRITE_RETRY,
...buildRuntimeGuestAuthOptions(options),
},
);
}
export const puzzleClearClient = {
advanceNextLevel: advancePuzzleClearNextLevel,
createSession: createPuzzleClearCreationSession,
executeAction: executePuzzleClearCreationAction,
getGalleryDetail: getPuzzleClearGalleryDetail,
getRun: getPuzzleClearRuntimeRun,
getSession: getPuzzleClearCreationSession,
getRuntimeWorkDetail: getPuzzleClearRuntimeWorkDetail,
getWorkDetail: getPuzzleClearWorkDetail,
listGallery: listPuzzleClearGallery,
listWorks: listPuzzleClearWorks,
markTimeUp: markPuzzleClearTimeUp,
publishWork: publishPuzzleClearWork,
retryLevel: retryPuzzleClearLevel,
startRun: startPuzzleClearRuntimeRun,
swapCards: swapPuzzleClearCards,
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -50,3 +50,16 @@ test('wooden fish list works uses creation works endpoint', async () => {
'读取敲木鱼作品列表失败',
);
});
test('wooden fish delete work uses creation works endpoint', async () => {
const { woodenFishClient } = await import('./woodenFishClient');
requestJsonMock.mockResolvedValueOnce({ items: [] });
await woodenFishClient.deleteWork('wooden-fish-profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/wooden-fish/works/wooden-fish-profile-1',
{ method: 'DELETE' },
'删除敲木鱼作品失败',
);
});
@@ -233,6 +233,14 @@ export async function publishWoodenFishWork(profileId: string) {
return normalizeWoodenFishWorkMutationResponse(response);
}
export async function deleteWoodenFishWork(profileId: string) {
return requestJson<WoodenFishWorksResponse>(
`${WOODEN_FISH_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'DELETE' },
'删除敲木鱼作品失败',
);
}
export async function startWoodenFishRuntimeRun(
profileId: string,
options: WoodenFishRuntimeRequestOptions = {},
@@ -317,6 +325,7 @@ export async function finishWoodenFishRun(
export const woodenFishClient = {
checkpointRun: checkpointWoodenFishRun,
createSession: createWoodenFishCreationSession,
deleteWork: deleteWoodenFishWork,
executeAction: executeWoodenFishCreationAction,
finishRun: finishWoodenFishRun,
getGalleryDetail: getWoodenFishGalleryDetail,