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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ export {
type BarkBattleImageGenerationFailures,
type BarkBattleUploadedAsset,
createBarkBattleDraft,
deleteBarkBattleWork,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,

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

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,

View File

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

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,

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',

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,

View File

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

View File

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

View File

@@ -0,0 +1,507 @@
import { expect, test } from 'vitest';
import type {
PuzzleClearCardAsset,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import {
advancePuzzleClearLocalLevel,
createPuzzleClearLocalRuntimeSnapshot,
isPuzzleClearLocalRuntimeSnapshot,
markPuzzleClearLocalTimeUp,
retryPuzzleClearLocalLevel,
swapPuzzleClearLocalCards,
} from './puzzleClearLocalRuntime';
function createCard(index: number): PuzzleClearCardAsset {
return {
cardId: `card-${index}`,
groupId: `group-${Math.floor(index / 2)}`,
shape: '1x2',
orientation: 'horizontal',
partX: index % 2,
partY: 0,
imageSrc: `/cards/${index}.png`,
imageObjectKey: `generated-puzzle-clear-assets/cards/${index}.png`,
assetObjectId: `assetobj_card_${index}`,
sourceAtlasCell: `${index}:0:0`,
};
}
function createCustomCard(
groupId: string,
cardId: string,
partX: number,
partY: number,
shape: PuzzleClearCardAsset['shape'] = '1x2',
orientation: PuzzleClearCardAsset['orientation'] = 'horizontal',
): PuzzleClearCardAsset {
return {
cardId,
groupId,
shape,
orientation,
partX,
partY,
imageSrc: `/cards/${cardId}.png`,
imageObjectKey: `generated-puzzle-clear-assets/${cardId}.png`,
assetObjectId: `assetobj_${cardId}`,
sourceAtlasCell: `${cardId}:${partX}:${partY}`,
};
}
function findCompletedGroupIds(
cells: PuzzleClearWorkProfileResponse['cardAssets'] extends never
? never
: ReturnType<typeof createPuzzleClearLocalRuntimeSnapshot>['board']['cells'],
) {
const byGroup = new Map<
string,
Array<{
row: number;
col: number;
card: PuzzleClearCardAsset;
}>
>();
for (const cell of cells) {
if (!cell.card) {
continue;
}
const entries = byGroup.get(cell.card.groupId) ?? [];
entries.push({ row: cell.row, col: cell.col, card: cell.card });
byGroup.set(cell.card.groupId, entries);
}
return [...byGroup.entries()]
.filter(([, entries]) => {
const first = entries[0]?.card;
if (!first) {
return false;
}
const width = first.shape === '2x2' || first.shape === '2x3' ? 2 : first.shape === '1x3' ? 3 : 2;
const height = first.shape === '2x2' ? 2 : first.shape === '2x3' ? 3 : 1;
if (entries.length !== width * height) {
return false;
}
const minRow = Math.min(...entries.map((entry) => entry.row));
const minCol = Math.min(...entries.map((entry) => entry.col));
return entries.every(
(entry) =>
entry.row === minRow + entry.card.partY &&
entry.col === minCol + entry.card.partX,
);
})
.map(([groupId]) => groupId);
}
function createDraftWork(): PuzzleClearWorkProfileResponse {
return createDraftWorkWithCardCount(70);
}
function createDraftWorkWithCardCount(cardCount: number): PuzzleClearWorkProfileResponse {
const 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: 2560,
height: 2560,
};
const cards = Array.from({ length: cardCount }, (_, index) => createCard(index));
const draft = {
templateId: 'puzzle-clear',
templateName: '拼消消',
profileId: 'puzzle-clear-profile-draft',
workTitle: '星港拼消消',
workDescription: '',
themePrompt: '星港',
boardBackgroundPrompt: '星港中央棋盘底图',
generateBoardBackground: true,
boardBackgroundAsset: atlasAsset,
cardBackImageSrc: '/creation-type-references/puzzle-clear-card-back.webp',
atlasAsset,
patternGroups: [],
cardAssets: cards,
generationStatus: 'ready' as const,
};
return {
summary: {
runtimeKind: 'puzzle-clear',
workId: 'puzzle-clear-work-draft',
profileId: 'puzzle-clear-profile-draft',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-clear-session-draft',
workTitle: '星港拼消消',
workDescription: '',
themePrompt: '星港',
coverImageSrc: atlasAsset.imageSrc,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T00:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft,
boardBackgroundAsset: atlasAsset,
atlasAsset,
patternGroups: [],
cardAssets: cards,
};
}
test('草稿试玩创建本地运行态,不要求作品先发布', () => {
const run = createPuzzleClearLocalRuntimeSnapshot(createDraftWork());
expect(run.runtimeMode).toBe('draft');
expect(run.status).toBe('playing');
expect(run.board.rows).toBe(6);
expect(run.board.cols).toBe(6);
expect(run.targetClears).toBe(35);
expect(run.readyColumns).toHaveLength(run.board.cols);
expect(isPuzzleClearLocalRuntimeSnapshot(run)).toBe(true);
});
test('本地草稿运行态支持交换、重试、单关推进和超时失败', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const preparedRun = {
...run,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: createCustomCard('swap-a', 'swap-a-0', 0, 0), lockedGroupId: null },
{ row: 0, col: 1, card: createCustomCard('swap-b', 'swap-b-0', 0, 0), lockedGroupId: null },
{ row: 0, col: 2, card: createCustomCard('swap-c', 'swap-c-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 0, card: createCustomCard('swap-d', 'swap-d-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 1, card: createCustomCard('swap-e', 'swap-e-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 2, card: createCustomCard('swap-f', 'swap-f-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 0, card: createCustomCard('swap-g', 'swap-g-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 1, card: createCustomCard('swap-h', 'swap-h-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 2, card: createCustomCard('swap-i', 'swap-i-0', 0, 0), lockedGroupId: null },
],
},
};
const beforeFirst = preparedRun.board.cells[0]?.card?.cardId;
const beforeSecond = preparedRun.board.cells[3]?.card?.cardId;
const swapped = swapPuzzleClearLocalCards(preparedRun, {
fromRow: 0,
fromCol: 0,
toRow: 1,
toCol: 0,
});
expect(swapped.board.cells[0]?.card?.cardId).toBe(beforeSecond);
expect(swapped.board.cells[3]?.card?.cardId).toBe(beforeFirst);
expect(swapped.board.cells[1]?.card?.cardId).toBe(
preparedRun.board.cells[1]?.card?.cardId,
);
const failed = markPuzzleClearLocalTimeUp(swapped);
expect(failed.status).toBe('level_failed');
expect(failed.finishedAtMs).not.toBeNull();
const retried = retryPuzzleClearLocalLevel(failed, work);
expect(retried.status).toBe('playing');
expect(retried.levelIndex).toBe(failed.levelIndex);
const next = advancePuzzleClearLocalLevel(retried, work);
expect(next.levelIndex).toBe(1);
expect(next.status).toBe('playing');
expect(next.targetClears).toBe(35);
});
test('本地运行态固定使用 6x6 棋盘和 35 次消除目标', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work, 4);
expect(run.levelIndex).toBe(1);
expect(run.board.rows).toBe(6);
expect(run.board.cols).toBe(6);
expect(run.targetClears).toBe(35);
});
test('本地草稿只会保留单关目标所需的 35 个图案组,剩余卡进入顶部准备区', () => {
const work = createDraftWorkWithCardCount(80);
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const boardCardCount = run.board.rows * run.board.cols;
const reserveCardCount = run.readyColumns.flat().length;
expect(run.board.cells.every((cell) => cell.card?.shape === '1x2')).toBe(true);
expect(boardCardCount).toBe(36);
expect(reserveCardCount).toBe(34);
expect(run.readyColumns).toHaveLength(run.board.cols);
});
test('本地草稿开局不会直接摆出已完成的图案组', () => {
const work = createDraftWorkWithCardCount(80);
const run = createPuzzleClearLocalRuntimeSnapshot(work);
expect(findCompletedGroupIds(run.board.cells)).toEqual([]);
});
test('本地草稿第一关不会把 1x2 当成半锁定拼接组留在场上', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const firstPart = createCustomCard('pair-lock', 'pair-lock-0', 0, 0);
const secondPart = createCustomCard('pair-lock', 'pair-lock-1', 1, 0);
const preparedRun = {
...run,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: firstPart, lockedGroupId: null },
{ row: 0, col: 1, card: createCustomCard('noise-a', 'noise-a', 0, 0), lockedGroupId: null },
{ row: 0, col: 2, card: createCustomCard('noise-b', 'noise-b', 0, 0), lockedGroupId: null },
{ row: 1, col: 0, card: createCustomCard('noise-c', 'noise-c', 0, 0), lockedGroupId: null },
{ row: 1, col: 1, card: secondPart, lockedGroupId: null },
{ row: 1, col: 2, card: createCustomCard('noise-d', 'noise-d', 0, 0), lockedGroupId: null },
{ row: 2, col: 0, card: createCustomCard('noise-e', 'noise-e', 0, 0), lockedGroupId: null },
{ row: 2, col: 1, card: createCustomCard('noise-f', 'noise-f', 0, 0), lockedGroupId: null },
{ row: 2, col: 2, card: createCustomCard('noise-g', 'noise-g', 0, 0), lockedGroupId: null },
],
},
readyColumns: [[], [], []],
};
const movedNearButIncomplete = swapPuzzleClearLocalCards(preparedRun, {
fromRow: 1,
fromCol: 1,
toRow: 1,
toCol: 0,
});
expect(movedNearButIncomplete.board.cells.every((cell) => cell.lockedGroupId === null)).toBe(
true,
);
expect(movedNearButIncomplete.clearsDone).toBe(0);
});
test('本地草稿补位时会从其它准备列借牌避免出现空格子', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const borrowedCard = createCustomCard('borrowed', 'borrowed-0', 0, 0);
const emptiedRun = {
...run,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: null, lockedGroupId: null },
{ row: 0, col: 1, card: createCustomCard('noise-a', 'noise-a-0', 0, 0), lockedGroupId: null },
{ row: 0, col: 2, card: createCustomCard('noise-b', 'noise-b-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 0, card: createCustomCard('noise-c', 'noise-c-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 1, card: createCustomCard('noise-d', 'noise-d-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 2, card: createCustomCard('noise-e', 'noise-e-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 0, card: createCustomCard('noise-f', 'noise-f-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 1, card: createCustomCard('noise-g', 'noise-g-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 2, card: createCustomCard('noise-h', 'noise-h-0', 0, 0), lockedGroupId: null },
],
},
readyColumns: [[], [], [borrowedCard]],
};
const next = swapPuzzleClearLocalCards(emptiedRun, {
fromRow: 0,
fromCol: 1,
toRow: 0,
toCol: 1,
});
expect(next.board.cells.every((cell) => cell.card)).toBe(true);
expect(next.board.cells.some((cell) => cell.card?.cardId === borrowedCard.cardId)).toBe(true);
});
test('本地草稿允许把卡牌拖入空格并完成落位', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const refillCard = createCustomCard('refill-source', 'refill-source-0', 0, 0);
const withEmptyTarget = {
...run,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: createCustomCard('source', 'source-0', 0, 0), lockedGroupId: null },
{ row: 0, col: 1, card: null, lockedGroupId: null },
{ row: 0, col: 2, card: createCustomCard('noise-a', 'noise-a-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 0, card: createCustomCard('noise-b', 'noise-b-0', 0, 0), lockedGroupId: null },
{ row: 1, col: 1, card: createCustomCard('pair', 'pair-0', 0, 0, '1x3'), lockedGroupId: null },
{ row: 1, col: 2, card: createCustomCard('pair', 'pair-1', 1, 0, '1x3'), lockedGroupId: null },
{ row: 2, col: 0, card: createCustomCard('noise-c', 'noise-c-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 1, card: createCustomCard('noise-d', 'noise-d-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 2, card: createCustomCard('noise-e', 'noise-e-0', 0, 0), lockedGroupId: null },
],
},
readyColumns: [[refillCard], [], []],
};
const next = swapPuzzleClearLocalCards(withEmptyTarget, {
fromRow: 0,
fromCol: 0,
toRow: 0,
toCol: 1,
});
expect(next.board.cells.find((cell) => cell.row === 0 && cell.col === 1)?.card).toBeTruthy();
expect(next.board.cells.find((cell) => cell.row === 0 && cell.col === 1)?.card?.cardId).toBe(
'source-0',
);
expect(next.board.cells.find((cell) => cell.row === 0 && cell.col === 0)?.card?.cardId).toBe(
refillCard.cardId,
);
});
test('本地草稿交换成完整 1x2 图案后会消除并从顶部准备区补牌', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const firstPart = createCustomCard('pair-a', 'pair-a-0', 0, 0);
const secondPart = createCustomCard('pair-a', 'pair-a-1', 1, 0);
const filler = [
createCustomCard('noise-0', 'noise-0', 0, 0),
createCustomCard('noise-1', 'noise-1', 0, 0),
createCustomCard('noise-2', 'noise-2', 0, 0),
createCustomCard('noise-3', 'noise-3', 0, 0),
createCustomCard('noise-4', 'noise-4', 0, 0),
createCustomCard('noise-5', 'noise-5', 0, 0),
createCustomCard('noise-6', 'noise-6', 0, 0),
];
const refillForCol0 = createCustomCard('refill-a', 'refill-a-0', 0, 0);
const refillForCol1 = createCustomCard('refill-b', 'refill-b-0', 0, 0);
const preparedRun = {
...run,
targetClears: 2,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: firstPart, lockedGroupId: null },
{ row: 0, col: 1, card: filler[0]!, lockedGroupId: null },
{ row: 0, col: 2, card: filler[1]!, lockedGroupId: null },
{ row: 1, col: 0, card: filler[2]!, lockedGroupId: null },
{ row: 1, col: 1, card: secondPart, lockedGroupId: null },
{ row: 1, col: 2, card: filler[3]!, lockedGroupId: null },
{ row: 2, col: 0, card: filler[4]!, lockedGroupId: null },
{ row: 2, col: 1, card: filler[5]!, lockedGroupId: null },
{ row: 2, col: 2, card: filler[6]!, lockedGroupId: null },
],
},
readyColumns: [[refillForCol0], [refillForCol1], []],
};
const next = swapPuzzleClearLocalCards(preparedRun, {
fromRow: 1,
fromCol: 1,
toRow: 0,
toCol: 1,
});
expect(next.clearsDone).toBe(1);
expect(next.status).toBe('playing');
expect(next.board.cells.every((cell) => cell.card)).toBe(true);
expect(
next.board.cells.find((cell) => cell.row === 0 && cell.col === 0)?.card?.cardId,
).toBe(refillForCol0.cardId);
expect(
next.board.cells.find((cell) => cell.row === 0 && cell.col === 1)?.card?.cardId,
).toBe(refillForCol1.cardId);
expect(next.readyColumns[0]).toHaveLength(0);
expect(next.readyColumns[1]).toHaveLength(0);
});
test('本地草稿达到当前关目标但场上仍有卡牌时不会提前完成', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const firstPart = createCustomCard('pair-b', 'pair-b-0', 0, 0);
const secondPart = createCustomCard('pair-b', 'pair-b-1', 1, 0);
const filler = [
createCustomCard('noise-a', 'noise-a', 0, 0),
createCustomCard('noise-b', 'noise-b', 0, 0),
createCustomCard('noise-c', 'noise-c', 0, 0),
createCustomCard('noise-d', 'noise-d', 0, 0),
createCustomCard('noise-e', 'noise-e', 0, 0),
createCustomCard('noise-f', 'noise-f', 0, 0),
createCustomCard('noise-g', 'noise-g', 0, 0),
];
const preparedRun = {
...run,
targetClears: 1,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: firstPart, lockedGroupId: null },
{ row: 0, col: 1, card: filler[0]!, lockedGroupId: null },
{ row: 0, col: 2, card: filler[1]!, lockedGroupId: null },
{ row: 1, col: 0, card: filler[2]!, lockedGroupId: null },
{ row: 1, col: 1, card: secondPart, lockedGroupId: null },
{ row: 1, col: 2, card: filler[3]!, lockedGroupId: null },
{ row: 2, col: 0, card: filler[4]!, lockedGroupId: null },
{ row: 2, col: 1, card: filler[5]!, lockedGroupId: null },
{ row: 2, col: 2, card: filler[6]!, lockedGroupId: null },
],
},
readyColumns: [
[createCustomCard('refill-c', 'refill-c-0', 0, 0)],
[createCustomCard('refill-d', 'refill-d-0', 0, 0)],
[],
],
};
const next = swapPuzzleClearLocalCards(preparedRun, {
fromRow: 1,
fromCol: 1,
toRow: 0,
toCol: 1,
});
expect(next.clearsDone).toBe(1);
expect(next.status).toBe('playing');
expect(next.finishedAtMs).toBeNull();
});
test('本地草稿只有在达到目标且清空棋盘后才进入关卡完成状态', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const firstPart = createCustomCard('pair-c', 'pair-c-0', 0, 0);
const secondPart = createCustomCard('pair-c', 'pair-c-1', 1, 0);
const preparedRun = {
...run,
targetClears: 1,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: firstPart, lockedGroupId: null },
{ row: 0, col: 1, card: null, lockedGroupId: null },
{ row: 0, col: 2, card: null, lockedGroupId: null },
{ row: 1, col: 0, card: null, lockedGroupId: null },
{ row: 1, col: 1, card: secondPart, lockedGroupId: null },
{ row: 1, col: 2, card: null, lockedGroupId: null },
{ row: 2, col: 0, card: null, lockedGroupId: null },
{ row: 2, col: 1, card: null, lockedGroupId: null },
{ row: 2, col: 2, card: null, lockedGroupId: null },
],
},
readyColumns: [[], [], []],
};
const next = swapPuzzleClearLocalCards(preparedRun, {
fromRow: 1,
fromCol: 1,
toRow: 0,
toCol: 1,
});
expect(next.clearsDone).toBe(1);
expect(next.board.cells.every((cell) => cell.card === null)).toBe(true);
expect(next.status).toBe('finished');
expect(next.finishedAtMs).not.toBeNull();
});

View File

@@ -0,0 +1,859 @@
import type {
PuzzleClearBoardCell,
PuzzleClearBoardSnapshot,
PuzzleClearCardAsset,
PuzzleClearRuntimeSnapshotResponse,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
const PUZZLE_CLEAR_LOCAL_RUNTIME_DURATION_SECONDS = 600;
const PUZZLE_CLEAR_LOCAL_MAX_LEVEL = 1;
const PUZZLE_CLEAR_LEVEL_CONFIGS = [
{ size: 6, targetClears: 35 },
] as const;
type PuzzleClearLevelConfig = (typeof PUZZLE_CLEAR_LEVEL_CONFIGS)[number];
const PUZZLE_CLEAR_FALLBACK_LEVEL_CONFIG: PuzzleClearLevelConfig =
PUZZLE_CLEAR_LEVEL_CONFIGS[PUZZLE_CLEAR_LEVEL_CONFIGS.length - 1]!;
const PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES: readonly PuzzleClearCardAsset['shape'][][] =
[
['1x2', '1x3', '2x2', '2x3'],
];
type PuzzleClearLocalRuntimeBoardCell = {
row: number;
col: number;
card: PuzzleClearCardAsset | null;
lockedGroupId: string | null;
};
type PuzzleClearLocalRuntimeElimination = {
groupId: string;
positions: Array<{ row: number; col: number }>;
};
function cloneCard(card: PuzzleClearCardAsset | null): PuzzleClearCardAsset | null {
return card ? { ...card } : null;
}
function cloneBoardCell(cell: PuzzleClearLocalRuntimeBoardCell) {
return {
row: cell.row,
col: cell.col,
card: cloneCard(cell.card),
lockedGroupId: cell.lockedGroupId,
};
}
function resolvePuzzleClearLevelConfig(
levelIndex: number,
): PuzzleClearLevelConfig {
const normalizedLevelIndex = Math.min(
PUZZLE_CLEAR_LEVEL_CONFIGS.length - 1,
Math.max(0, levelIndex - 1),
);
return (
PUZZLE_CLEAR_LEVEL_CONFIGS[normalizedLevelIndex] ??
PUZZLE_CLEAR_FALLBACK_LEVEL_CONFIG
);
}
function resolvePuzzleClearLevelUnlockedShapes(
levelIndex: number,
): readonly PuzzleClearCardAsset['shape'][] {
const normalizedLevelIndex = Math.min(
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES.length - 1,
Math.max(0, levelIndex - 1),
);
return (
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES[normalizedLevelIndex] ??
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES[PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES.length - 1]!
);
}
function stablePuzzleClearGroupKey(seed: string, groupId: string) {
let state = 0xcbf2_9ce4_8422_2325n;
for (const character of `${seed}${groupId}`) {
state ^= BigInt(character.charCodeAt(0));
state = (state * 0x1000_0000_01b3n) & 0xffff_ffff_ffff_ffffn;
}
return state;
}
function buildPuzzleClearLevelCards(
work: PuzzleClearWorkProfileResponse,
levelIndex: number,
) {
const cards = work.cardAssets.length > 0 ? work.cardAssets : work.draft.cardAssets;
const unlockedShapes = new Set(resolvePuzzleClearLevelUnlockedShapes(levelIndex));
const targetGroups = resolvePuzzleClearLevelConfig(levelIndex).targetClears;
const groupedCards = new Map<string, PuzzleClearCardAsset[]>();
for (const card of cards) {
if (!unlockedShapes.has(card.shape)) {
continue;
}
const entries = groupedCards.get(card.groupId) ?? [];
entries.push({ ...card });
groupedCards.set(card.groupId, entries);
}
return [...groupedCards.entries()]
.map(([groupId, entries]) => ({
groupId,
cards: entries.sort((left, right) => left.partY - right.partY || left.partX - right.partX),
}))
.sort((left, right) => {
const leftKey = stablePuzzleClearGroupKey(work.summary.profileId, left.groupId);
const rightKey = stablePuzzleClearGroupKey(work.summary.profileId, right.groupId);
if (leftKey === rightKey) {
return left.groupId.localeCompare(right.groupId);
}
return leftKey < rightKey ? -1 : 1;
})
.slice(0, targetGroups)
.flatMap((group) => group.cards);
}
function shufflePuzzleClearLocalCards(
cards: PuzzleClearCardAsset[],
seed: string,
) {
return [...cards].sort((left, right) => {
const leftKey = stablePuzzleClearGroupKey(
seed,
`${left.groupId}:${left.cardId}:${left.partX}:${left.partY}`,
);
const rightKey = stablePuzzleClearGroupKey(
seed,
`${right.groupId}:${right.cardId}:${right.partX}:${right.partY}`,
);
if (leftKey === rightKey) {
return left.cardId.localeCompare(right.cardId);
}
return leftKey < rightKey ? -1 : 1;
});
}
function buildLocalRuntimeBoard(
cards: PuzzleClearCardAsset[],
levelIndex: number,
seed: string,
) {
const size = resolvePuzzleClearLevelConfig(levelIndex).size;
const visibleCards = cards.slice(0, size * size);
for (let attempt = 0; attempt < 128; attempt += 1) {
const attemptSeed = `${seed}:level-${levelIndex}:attempt-${attempt}`;
const shuffledCards = shufflePuzzleClearLocalCards(visibleCards, attemptSeed);
const boardCells: PuzzleClearLocalRuntimeBoardCell[] = [];
let index = 0;
for (let row = 0; row < size; row += 1) {
for (let col = 0; col < size; col += 1) {
boardCells.push({
row,
col,
card: cloneCard(shuffledCards[index] ?? null),
lockedGroupId: null,
});
index += 1;
}
}
const board = {
rows: size,
cols: size,
cells: boardCells,
} satisfies PuzzleClearBoardSnapshot;
if (
findPuzzleClearLocalEliminations(board).length === 0 &&
hasPuzzleClearLocalPotentialMove(board)
) {
return board;
}
}
return {
rows: size,
cols: size,
cells: Array.from({ length: size * size }, (_, index) => {
const row = Math.floor(index / size);
const col = index % size;
return {
row,
col,
card: cloneCard(visibleCards[index] ?? null),
lockedGroupId: null,
};
}),
} satisfies PuzzleClearBoardSnapshot;
}
function buildLocalRuntimeReadyColumns(
cards: PuzzleClearCardAsset[],
columnCount: number,
) {
const readyColumns = Array.from({ length: columnCount }, () => [] as PuzzleClearCardAsset[]);
const boardCardCount = columnCount * columnCount;
const reserveCards = cards.slice(boardCardCount);
reserveCards.forEach((card, index) => {
readyColumns[index % columnCount]?.push({ ...card });
});
return readyColumns;
}
function cloneRun(
run: PuzzleClearRuntimeSnapshotResponse,
): PuzzleClearRuntimeSnapshotResponse {
return {
...run,
board: {
...run.board,
cells: run.board.cells.map(cloneBoardCell),
},
readyColumns: run.readyColumns.map((column) =>
column.map((card) => ({ ...card })),
),
};
}
function findCell(
board: PuzzleClearBoardSnapshot,
row: number,
col: number,
) {
return board.cells.find((cell) => cell.row === row && cell.col === col) ?? null;
}
function resolveShapeDimensions(card: PuzzleClearCardAsset) {
const baseDimensions: Record<PuzzleClearCardAsset['shape'], [number, number]> = {
'1x2': [2, 1],
'1x3': [3, 1],
'2x2': [2, 2],
'2x3': [3, 2],
};
const [width, height] = baseDimensions[card.shape];
const canRotate = card.shape === '1x2' || card.shape === '1x3' || card.shape === '2x3';
if (card.orientation === 'vertical' && canRotate) {
return { width: height, height: width };
}
return { width, height };
}
function getPartDistance(left: PuzzleClearCardAsset, right: PuzzleClearCardAsset) {
return Math.abs(left.partX - right.partX) + Math.abs(left.partY - right.partY);
}
function areNeighborCells(
left: { row: number; col: number },
right: { row: number; col: number },
) {
return Math.abs(left.row - right.row) + Math.abs(left.col - right.col) === 1;
}
function positionKey(row: number, col: number) {
return `${row}:${col}`;
}
function partPositionKey(row: number, col: number, partX: number, partY: number) {
return `${row}:${col}:${partX}:${partY}`;
}
function getCardAt(board: PuzzleClearBoardSnapshot, row: number, col: number) {
return findCell(board, row, col)?.card ?? null;
}
function findPuzzleClearLocalEliminations(
board: PuzzleClearBoardSnapshot,
): PuzzleClearLocalRuntimeElimination[] {
const byGroup = new Map<
string,
Array<{ row: number; col: number; card: PuzzleClearCardAsset }>
>();
for (const cell of board.cells) {
if (!cell.card) {
continue;
}
const entries = byGroup.get(cell.card.groupId) ?? [];
entries.push({ row: cell.row, col: cell.col, card: cell.card });
byGroup.set(cell.card.groupId, entries);
}
const eliminations: PuzzleClearLocalRuntimeElimination[] = [];
for (const [groupId, entries] of byGroup) {
const first = entries[0]?.card;
if (!first) {
continue;
}
const { width, height } = resolveShapeDimensions(first);
if (entries.length !== width * height) {
continue;
}
const minRow = Math.min(...entries.map((entry) => entry.row));
const minCol = Math.min(...entries.map((entry) => entry.col));
const expected = new Set<string>();
for (let partY = 0; partY < height; partY += 1) {
for (let partX = 0; partX < width; partX += 1) {
expected.add(partPositionKey(minRow + partY, minCol + partX, partX, partY));
}
}
const actual = new Set(
entries.map((entry) =>
partPositionKey(entry.row, entry.col, entry.card.partX, entry.card.partY),
),
);
if (expected.size === actual.size && [...expected].every((key) => actual.has(key))) {
eliminations.push({
groupId,
positions: entries.map((entry) => ({ row: entry.row, col: entry.col })),
});
}
}
return eliminations;
}
function findPuzzleClearLocalCompletedPartialGroups(
board: PuzzleClearBoardSnapshot,
): PuzzleClearLocalRuntimeElimination[] {
const byGroup = new Map<
string,
Array<{ row: number; col: number; card: PuzzleClearCardAsset }>
>();
for (const cell of board.cells) {
if (!cell.card) {
continue;
}
const entries = byGroup.get(cell.card.groupId) ?? [];
entries.push({ row: cell.row, col: cell.col, card: cell.card });
byGroup.set(cell.card.groupId, entries);
}
const groups: PuzzleClearLocalRuntimeElimination[] = [];
for (const [groupId, entries] of byGroup) {
const first = entries[0]?.card;
if (!first || first.shape === '1x2' || entries.length < 2) {
continue;
}
const ordered = [...entries].sort(
(left, right) =>
left.card.partY - right.card.partY || left.card.partX - right.card.partX,
);
const adjacent = ordered.slice(1).every((entry, index) => {
const previous = ordered[index]!;
return (
getPartDistance(previous.card, entry.card) === 1 &&
areNeighborCells(previous, entry)
);
});
if (adjacent) {
groups.push({
groupId,
positions: entries.map((entry) => ({ row: entry.row, col: entry.col })),
});
}
}
return groups;
}
function markPuzzleClearLocalCompletedGroups(board: PuzzleClearBoardSnapshot) {
for (const group of findPuzzleClearLocalCompletedPartialGroups(board)) {
for (const position of group.positions) {
const cell = findCell(board, position.row, position.col);
if (cell) {
cell.lockedGroupId = group.groupId;
}
}
}
}
function clearPuzzleClearLocalLockedGroup(
board: PuzzleClearBoardSnapshot,
groupId: string,
) {
for (const cell of board.cells) {
if (cell.lockedGroupId === groupId) {
cell.lockedGroupId = null;
}
}
}
function clearPuzzleClearLocalElimination(
board: PuzzleClearBoardSnapshot,
elimination: PuzzleClearLocalRuntimeElimination,
) {
for (const position of elimination.positions) {
const cell = findCell(board, position.row, position.col);
if (cell) {
cell.card = null;
cell.lockedGroupId = null;
}
}
}
function findPuzzleClearLocalMatchingRefillIndex(
column: PuzzleClearCardAsset[] | undefined,
board: PuzzleClearBoardSnapshot,
) {
if (!column || column.length === 0) {
return -1;
}
return column.findIndex((candidate) =>
board.cells.some((cell) => {
const card = cell.card;
return (
card?.groupId === candidate.groupId && getPartDistance(card, candidate) === 1
);
}),
);
}
function popPuzzleClearLocalRefillCard(
readyColumns: PuzzleClearCardAsset[][],
preferredCol: number,
board: PuzzleClearBoardSnapshot,
) {
const preferredColumn = readyColumns[preferredCol];
const matchingIndex = findPuzzleClearLocalMatchingRefillIndex(
preferredColumn,
board,
);
if (matchingIndex >= 0) {
return preferredColumn?.splice(matchingIndex, 1)[0] ?? null;
}
for (const [columnIndex, column] of readyColumns.entries()) {
if (columnIndex === preferredCol) {
continue;
}
const fallbackMatchingIndex = findPuzzleClearLocalMatchingRefillIndex(
column,
board,
);
if (fallbackMatchingIndex >= 0) {
return column.splice(fallbackMatchingIndex, 1)[0] ?? null;
}
}
if (preferredColumn && preferredColumn.length > 0) {
return preferredColumn.pop() ?? null;
}
for (const [columnIndex, column] of readyColumns.entries()) {
if (columnIndex === preferredCol) {
continue;
}
const card = column.pop();
if (card) {
return card;
}
}
return null;
}
function refillPuzzleClearLocalColumnSegment(
board: PuzzleClearBoardSnapshot,
readyColumns: PuzzleClearCardAsset[][],
col: number,
startRow: number,
endRow: number,
) {
const existing: PuzzleClearCardAsset[] = [];
for (let row = endRow - 1; row >= startRow; row -= 1) {
const cell = findCell(board, row, col);
if (cell?.card) {
existing.push(cell.card);
cell.card = null;
cell.lockedGroupId = null;
}
}
for (let row = endRow - 1; row >= startRow; row -= 1) {
const cell = findCell(board, row, col);
if (!cell) {
continue;
}
cell.card = existing.shift() ?? popPuzzleClearLocalRefillCard(readyColumns, col, board);
cell.lockedGroupId = null;
}
}
function applyPuzzleClearLocalGravityAndRefill(
board: PuzzleClearBoardSnapshot,
readyColumns: PuzzleClearCardAsset[][],
) {
for (let col = 0; col < board.cols; col += 1) {
let segmentStart = 0;
while (segmentStart < board.rows) {
while (
segmentStart < board.rows &&
Boolean(findCell(board, segmentStart, col)?.lockedGroupId)
) {
segmentStart += 1;
}
const start = segmentStart;
while (
segmentStart < board.rows &&
!findCell(board, segmentStart, col)?.lockedGroupId
) {
segmentStart += 1;
}
if (start < segmentStart) {
refillPuzzleClearLocalColumnSegment(board, readyColumns, col, start, segmentStart);
}
}
}
}
function hasPuzzleClearLocalEmptyCell(board: PuzzleClearBoardSnapshot) {
return board.cells.some((cell) => !cell.card);
}
function hasPuzzleClearLocalRemainingCards(board: PuzzleClearBoardSnapshot) {
return board.cells.some((cell) => Boolean(cell.card));
}
function hasPuzzleClearLocalPotentialMove(board: PuzzleClearBoardSnapshot) {
if (findPuzzleClearLocalEliminations(board).length > 0) {
return false;
}
const sourceCells = board.cells.filter(
(cell): cell is PuzzleClearBoardCell & { card: PuzzleClearCardAsset } =>
Boolean(cell.card),
);
for (const source of sourceCells) {
for (const target of board.cells) {
if (source.row === target.row && source.col === target.col) {
continue;
}
const candidate: PuzzleClearBoardSnapshot = {
rows: board.rows,
cols: board.cols,
cells: board.cells.map(cloneBoardCell),
};
const candidateSource = findCell(candidate, source.row, source.col);
const candidateTarget = findCell(candidate, target.row, target.col);
if (!candidateSource?.card || !candidateTarget) {
continue;
}
if (candidateTarget.card) {
const swappedCard = candidateSource.card;
candidateSource.card = candidateTarget.card;
candidateTarget.card = swappedCard;
} else {
candidateTarget.card = candidateSource.card;
candidateSource.card = null;
}
if (findPuzzleClearLocalEliminations(candidate).length > 0) {
return true;
}
}
}
return false;
}
function ensurePuzzleClearLocalPlayableMove(board: PuzzleClearBoardSnapshot) {
if (!hasPuzzleClearLocalRemainingCards(board) || hasPuzzleClearLocalPotentialMove(board)) {
return;
}
for (const source of board.cells) {
if (source.lockedGroupId || !source.card) {
continue;
}
for (const target of board.cells) {
if (
target.lockedGroupId ||
(source.row === target.row && source.col === target.col)
) {
continue;
}
const sourceCard: PuzzleClearCardAsset | null = source.card;
source.card = target.card;
target.card = sourceCard;
source.lockedGroupId = null;
target.lockedGroupId = null;
if (
findPuzzleClearLocalEliminations(board).length === 0 &&
hasPuzzleClearLocalPotentialMove(board)
) {
return;
}
target.card = source.card;
source.card = sourceCard;
source.lockedGroupId = null;
target.lockedGroupId = null;
}
}
}
function resolvePuzzleClearLocalEliminationsAndRefill(
run: PuzzleClearRuntimeSnapshotResponse,
) {
let resolvedClears = 0;
const maxPasses = run.board.rows * run.board.cols;
for (let pass = 0; pass < maxPasses; pass += 1) {
const eliminations = findPuzzleClearLocalEliminations(run.board);
if (eliminations.length === 0) {
break;
}
for (const elimination of eliminations) {
clearPuzzleClearLocalElimination(run.board, elimination);
run.clearsDone += 1;
resolvedClears += 1;
}
applyPuzzleClearLocalGravityAndRefill(run.board, run.readyColumns);
}
return resolvedClears;
}
function swapPuzzleClearLocalCardPositions(
board: PuzzleClearBoardSnapshot,
fromRow: number,
fromCol: number,
toRow: number,
toCol: number,
) {
const from = findCell(board, fromRow, fromCol);
const to = findCell(board, toRow, toCol);
if (!from || !to) {
return false;
}
const fromCard = from.card;
from.card = to.card;
to.card = fromCard;
from.lockedGroupId = null;
to.lockedGroupId = null;
return true;
}
function movePuzzleClearLocalLockedGroup(
board: PuzzleClearBoardSnapshot,
groupId: string,
payload: {
fromRow: number;
fromCol: number;
toRow: number;
toCol: number;
},
) {
const deltaRow = payload.toRow - payload.fromRow;
const deltaCol = payload.toCol - payload.fromCol;
if (deltaRow === 0 && deltaCol === 0) {
return true;
}
const groupCells = board.cells
.filter((cell) => cell.lockedGroupId === groupId && cell.card)
.map((cell) => ({
row: cell.row,
col: cell.col,
card: cloneCard(cell.card)!,
}));
if (groupCells.length === 0) {
return false;
}
const moved = groupCells.map((cell) => ({
...cell,
nextRow: cell.row + deltaRow,
nextCol: cell.col + deltaCol,
}));
if (
moved.some(
(cell) =>
cell.nextRow < 0 ||
cell.nextCol < 0 ||
cell.nextRow >= board.rows ||
cell.nextCol >= board.cols,
)
) {
return false;
}
const oldKeys = new Set(groupCells.map((cell) => positionKey(cell.row, cell.col)));
const newKeys = new Set(moved.map((cell) => positionKey(cell.nextRow, cell.nextCol)));
const displaced = moved
.map((cell) => findCell(board, cell.nextRow, cell.nextCol))
.filter((cell): cell is PuzzleClearLocalRuntimeBoardCell => Boolean(cell))
.filter((cell) => !oldKeys.has(positionKey(cell.row, cell.col)))
.map((cell) => ({
card: cloneCard(cell.card),
lockedGroupId: cell.lockedGroupId,
}));
for (const cell of moved) {
const target = findCell(board, cell.nextRow, cell.nextCol);
if (target) {
target.card = cloneCard(cell.card);
target.lockedGroupId = groupId;
}
}
let displacedIndex = 0;
for (const cell of groupCells) {
if (newKeys.has(positionKey(cell.row, cell.col))) {
continue;
}
const target = findCell(board, cell.row, cell.col);
const replacement = displaced[displacedIndex];
displacedIndex += 1;
if (target) {
target.card = replacement?.card ?? null;
target.lockedGroupId = replacement?.lockedGroupId ?? null;
}
}
return true;
}
function swapPuzzleClearLocalBoardCells(
board: PuzzleClearBoardSnapshot,
payload: {
fromRow: number;
fromCol: number;
toRow: number;
toCol: number;
},
) {
const from = findCell(board, payload.fromRow, payload.fromCol);
const to = findCell(board, payload.toRow, payload.toCol);
if (!from || !to || !from.card) {
return false;
}
if (from.lockedGroupId) {
return movePuzzleClearLocalLockedGroup(board, from.lockedGroupId, payload);
}
const targetLockedGroupId = to.lockedGroupId;
const sourceCard = cloneCard(from.card);
if (!to.card) {
to.card = sourceCard;
to.lockedGroupId = null;
from.card = null;
from.lockedGroupId = null;
if (targetLockedGroupId) {
clearPuzzleClearLocalLockedGroup(board, targetLockedGroupId);
}
return true;
}
if (
!swapPuzzleClearLocalCardPositions(
board,
payload.fromRow,
payload.fromCol,
payload.toRow,
payload.toCol,
)
) {
return false;
}
if (targetLockedGroupId) {
clearPuzzleClearLocalLockedGroup(board, targetLockedGroupId);
}
return true;
}
export function createPuzzleClearLocalRuntimeSnapshot(
work: PuzzleClearWorkProfileResponse,
levelIndex = 1,
): PuzzleClearRuntimeSnapshotResponse {
const normalizedLevelIndex = Math.min(
PUZZLE_CLEAR_LOCAL_MAX_LEVEL,
Math.max(1, levelIndex),
);
const levelConfig = resolvePuzzleClearLevelConfig(normalizedLevelIndex);
const levelCards = buildPuzzleClearLevelCards(work, normalizedLevelIndex);
const board = buildLocalRuntimeBoard(
levelCards,
normalizedLevelIndex,
work.summary.profileId,
);
const readyColumns = buildLocalRuntimeReadyColumns(levelCards, board.cols);
return {
runId: `local-puzzle-clear-${work.summary.profileId}`,
profileId: work.summary.profileId,
ownerUserId: work.summary.ownerUserId,
runtimeMode: 'draft',
status: 'playing',
levelIndex: normalizedLevelIndex,
clearsDone: 0,
targetClears: levelConfig.targetClears,
levelDurationSeconds: PUZZLE_CLEAR_LOCAL_RUNTIME_DURATION_SECONDS,
levelStartedAtMs: Date.now(),
board,
readyColumns,
startedAtMs: Date.now(),
finishedAtMs: null,
};
}
export function isPuzzleClearLocalRuntimeSnapshot(
run: PuzzleClearRuntimeSnapshotResponse | null | undefined,
) {
return run?.runtimeMode === 'draft' || run?.runId.startsWith('local-puzzle-clear-');
}
export function swapPuzzleClearLocalCards(
run: PuzzleClearRuntimeSnapshotResponse,
payload: {
fromRow: number;
fromCol: number;
toRow: number;
toCol: number;
},
): PuzzleClearRuntimeSnapshotResponse {
if (run.status !== 'playing') {
return run;
}
const next = cloneRun(run);
if (!swapPuzzleClearLocalBoardCells(next.board, payload)) {
return run;
}
let resolvedClears = resolvePuzzleClearLocalEliminationsAndRefill(next);
if (resolvedClears === 0) {
if (hasPuzzleClearLocalEmptyCell(next.board)) {
applyPuzzleClearLocalGravityAndRefill(next.board, next.readyColumns);
resolvedClears = resolvePuzzleClearLocalEliminationsAndRefill(next);
}
}
markPuzzleClearLocalCompletedGroups(next.board);
ensurePuzzleClearLocalPlayableMove(next.board);
markPuzzleClearLocalCompletedGroups(next.board);
if (
next.clearsDone >= next.targetClears &&
!hasPuzzleClearLocalRemainingCards(next.board)
) {
next.status =
next.levelIndex >= PUZZLE_CLEAR_LOCAL_MAX_LEVEL ? 'finished' : 'level_cleared';
next.finishedAtMs = Date.now();
}
return next;
}
export function retryPuzzleClearLocalLevel(
run: PuzzleClearRuntimeSnapshotResponse,
work: PuzzleClearWorkProfileResponse,
) {
return createPuzzleClearLocalRuntimeSnapshot(work, run.levelIndex);
}
export function advancePuzzleClearLocalLevel(
run: PuzzleClearRuntimeSnapshotResponse,
work: PuzzleClearWorkProfileResponse,
) {
const nextLevel = Math.min(
PUZZLE_CLEAR_LOCAL_MAX_LEVEL,
Math.max(1, run.levelIndex + 1),
);
return createPuzzleClearLocalRuntimeSnapshot(work, nextLevel);
}
export function markPuzzleClearLocalTimeUp(
run: PuzzleClearRuntimeSnapshotResponse,
): PuzzleClearRuntimeSnapshotResponse {
if (run.status !== 'playing') {
return run;
}
return {
...run,
status: 'level_failed' as const,
finishedAtMs: Date.now(),
};
}

View File

@@ -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' },
'删除敲木鱼作品失败',
);
});

View File

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