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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
125
src/services/puzzle-clear/puzzleClearClient.test.ts
Normal file
125
src/services/puzzle-clear/puzzleClearClient.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
417
src/services/puzzle-clear/puzzleClearClient.ts
Normal file
417
src/services/puzzle-clear/puzzleClearClient.ts
Normal 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,
|
||||
};
|
||||
507
src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts
Normal file
507
src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts
Normal 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();
|
||||
});
|
||||
859
src/services/puzzle-clear/puzzleClearLocalRuntime.ts
Normal file
859
src/services/puzzle-clear/puzzleClearLocalRuntime.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user