Merge codex/sse-stream-architecture into architecture adjustment
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,19 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type {
|
||||
BarkBattleDraftConfig,
|
||||
BarkBattlePublishedConfig,
|
||||
BarkBattleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import {
|
||||
buildBarkBattleDraftConfigFromWorkSummary,
|
||||
buildBarkBattlePublishedConfigFromDraft,
|
||||
buildBarkBattlePublishedConfigFromWork,
|
||||
buildBarkBattlePublishSnapshot,
|
||||
mergeBarkBattlePublishedConfigAssets,
|
||||
mergeBarkBattleWorksByWorkId,
|
||||
mergeBarkBattleWorkSummary,
|
||||
resolveBarkBattleDraftGenerationStatus,
|
||||
shouldPreserveLocalBarkBattleWorkOnRefresh,
|
||||
} from './barkBattleWorkCache';
|
||||
|
||||
@@ -20,6 +30,7 @@ function buildBarkBattleWork(
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
@@ -34,6 +45,29 @@ function buildBarkBattleWork(
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleDraft(
|
||||
overrides: Partial<BarkBattleDraftConfig> = {},
|
||||
): BarkBattleDraftConfig {
|
||||
return {
|
||||
draftId: 'bark-battle-draft-1',
|
||||
workId: 'BB-cache-race-12345678',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v2',
|
||||
title: '汪汪测试杯',
|
||||
description: '测试声浪赛',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('preserves local published bark battle when refresh only returns same work draft', () => {
|
||||
const published = buildBarkBattleWork({
|
||||
status: 'published',
|
||||
@@ -106,3 +140,124 @@ test('preserves local ready bark battle draft when refresh has not returned it y
|
||||
expect(merged[0]?.generationStatus).toBe('ready');
|
||||
});
|
||||
|
||||
test('resolves bark battle draft generation status from required images', () => {
|
||||
expect(
|
||||
resolveBarkBattleDraftGenerationStatus(
|
||||
buildBarkBattleDraft({ uiBackgroundImageSrc: undefined }),
|
||||
false,
|
||||
),
|
||||
).toBe('pending_assets');
|
||||
expect(
|
||||
resolveBarkBattleDraftGenerationStatus(
|
||||
buildBarkBattleDraft({ opponentCharacterImageSrc: '' }),
|
||||
true,
|
||||
),
|
||||
).toBe('partial_failed');
|
||||
expect(resolveBarkBattleDraftGenerationStatus(buildBarkBattleDraft(), true)).toBe(
|
||||
'ready',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds draft runtime config with stable defaults', () => {
|
||||
const config = buildBarkBattlePublishedConfigFromDraft(
|
||||
buildBarkBattleDraft({
|
||||
workId: undefined,
|
||||
configVersion: undefined,
|
||||
rulesetVersion: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.workId).toBe('bark-battle-draft-1');
|
||||
expect(config.draftId).toBe('bark-battle-draft-1');
|
||||
expect(config.configVersion).toBe(1);
|
||||
expect(config.rulesetVersion).toBe('bark-battle-ruleset-v1');
|
||||
expect(config.playTypeId).toBe('bark-battle');
|
||||
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
|
||||
});
|
||||
|
||||
test('builds work runtime config with publishedAt fallback', () => {
|
||||
const config = buildBarkBattlePublishedConfigFromWork(
|
||||
buildBarkBattleWork({ publishedAt: null }),
|
||||
);
|
||||
|
||||
expect(config.workId).toBe('BB-cache-race-12345678');
|
||||
expect(config.description).toBe('测试声浪赛');
|
||||
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
|
||||
expect(config.playerCharacterImageSrc).toBe('/generated-bark-battle/player.png');
|
||||
});
|
||||
|
||||
test('builds draft config from work summary with stable defaults', () => {
|
||||
const config = buildBarkBattleDraftConfigFromWorkSummary(
|
||||
buildBarkBattleWork({
|
||||
draftId: null,
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
draftId: 'BB-cache-race-12345678',
|
||||
workId: 'BB-cache-race-12345678',
|
||||
title: '汪汪测试杯',
|
||||
description: '测试声浪赛',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
difficultyPreset: 'normal',
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
});
|
||||
expect(config.playerCharacterImageSrc).toBeUndefined();
|
||||
expect(config.opponentCharacterImageSrc).toBeUndefined();
|
||||
expect(config.uiBackgroundImageSrc).toBeUndefined();
|
||||
});
|
||||
|
||||
test('builds publish snapshot without empty asset fields', () => {
|
||||
const snapshot = buildBarkBattlePublishSnapshot(
|
||||
buildBarkBattleDraft({
|
||||
playerCharacterImageSrc: '',
|
||||
opponentCharacterImageSrc: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshot).not.toHaveProperty('playerCharacterImageSrc');
|
||||
expect(snapshot).not.toHaveProperty('opponentCharacterImageSrc');
|
||||
expect(snapshot.uiBackgroundImageSrc).toBe(
|
||||
'/generated-bark-battle/background.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('merges draft assets into published config when publish response omits them', () => {
|
||||
const draft = buildBarkBattleDraft();
|
||||
const published: BarkBattlePublishedConfig = {
|
||||
workId: 'BB-cache-race-12345678',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v2',
|
||||
playTypeId: 'bark-battle',
|
||||
title: '汪汪测试杯',
|
||||
description: '测试声浪赛',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
difficultyPreset: 'normal',
|
||||
updatedAt: '2026-05-21T10:01:00.000Z',
|
||||
publishedAt: '2026-05-21T10:01:00.000Z',
|
||||
};
|
||||
|
||||
const merged = mergeBarkBattlePublishedConfigAssets(published, draft);
|
||||
|
||||
expect(merged.playerCharacterImageSrc).toBe(
|
||||
'/generated-bark-battle/player.png',
|
||||
);
|
||||
expect(merged.opponentCharacterImageSrc).toBe(
|
||||
'/generated-bark-battle/opponent.png',
|
||||
);
|
||||
expect(merged.uiBackgroundImageSrc).toBe(
|
||||
'/generated-bark-battle/background.png',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
BarkBattleConfigEditorPayload,
|
||||
BarkBattleDraftConfig,
|
||||
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
|
||||
BarkBattlePublishedConfig,
|
||||
BarkBattleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
|
||||
@@ -36,6 +38,132 @@ export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBarkBattleDraftRequiredImages(draft: BarkBattleDraftConfig) {
|
||||
return Boolean(
|
||||
draft.playerCharacterImageSrc?.trim() &&
|
||||
draft.opponentCharacterImageSrc?.trim() &&
|
||||
draft.uiBackgroundImageSrc?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBarkBattleDraftGenerationStatus(
|
||||
draft: BarkBattleDraftConfig,
|
||||
partialFailed: boolean,
|
||||
): BarkBattleGenerationStatus {
|
||||
if (hasBarkBattleDraftRequiredImages(draft)) {
|
||||
return 'ready';
|
||||
}
|
||||
return partialFailed ? 'partial_failed' : 'pending_assets';
|
||||
}
|
||||
|
||||
export function buildBarkBattlePublishedConfigFromDraft(
|
||||
draft: BarkBattleDraftConfig,
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
workId: draft.workId ?? draft.draftId,
|
||||
draftId: draft.draftId,
|
||||
configVersion: draft.configVersion ?? 1,
|
||||
rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1',
|
||||
playTypeId: 'bark-battle',
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
themeDescription: draft.themeDescription,
|
||||
playerImageDescription: draft.playerImageDescription,
|
||||
opponentImageDescription: draft.opponentImageDescription,
|
||||
onomatopoeia: draft.onomatopoeia,
|
||||
playerCharacterImageSrc: draft.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc: draft.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc: draft.uiBackgroundImageSrc,
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
updatedAt: draft.updatedAt,
|
||||
publishedAt: draft.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattlePublishSnapshot(
|
||||
draft: BarkBattleDraftConfig,
|
||||
): BarkBattleConfigEditorPayload {
|
||||
return {
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
themeDescription: draft.themeDescription,
|
||||
playerImageDescription: draft.playerImageDescription,
|
||||
opponentImageDescription: draft.opponentImageDescription,
|
||||
onomatopoeia: draft.onomatopoeia,
|
||||
...(draft.playerCharacterImageSrc
|
||||
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
|
||||
: {}),
|
||||
...(draft.opponentCharacterImageSrc
|
||||
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
|
||||
: {}),
|
||||
...(draft.uiBackgroundImageSrc
|
||||
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
|
||||
: {}),
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeBarkBattlePublishedConfigAssets(
|
||||
published: BarkBattlePublishedConfig,
|
||||
draft: BarkBattleDraftConfig,
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
...published,
|
||||
playerCharacterImageSrc:
|
||||
published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc:
|
||||
published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc:
|
||||
published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattlePublishedConfigFromWork(
|
||||
work: BarkBattleWorkSummary,
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
workId: work.workId,
|
||||
draftId: work.draftId ?? null,
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
playTypeId: 'bark-battle',
|
||||
title: work.title,
|
||||
description: work.summary,
|
||||
themeDescription: work.themeDescription,
|
||||
playerImageDescription: work.playerImageDescription,
|
||||
opponentImageDescription: work.opponentImageDescription,
|
||||
onomatopoeia: work.onomatopoeia,
|
||||
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
|
||||
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
|
||||
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
|
||||
difficultyPreset: work.difficultyPreset,
|
||||
updatedAt: work.updatedAt,
|
||||
publishedAt: work.publishedAt ?? work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattleDraftConfigFromWorkSummary(
|
||||
work: BarkBattleWorkSummary,
|
||||
): BarkBattleDraftConfig {
|
||||
return {
|
||||
draftId: work.draftId ?? work.workId,
|
||||
workId: work.workId,
|
||||
title: work.title,
|
||||
description: work.summary,
|
||||
themeDescription: work.themeDescription,
|
||||
playerImageDescription: work.playerImageDescription,
|
||||
opponentImageDescription: work.opponentImageDescription,
|
||||
onomatopoeia: work.onomatopoeia,
|
||||
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
|
||||
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
|
||||
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
|
||||
difficultyPreset: work.difficultyPreset,
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
|
||||
item: BarkBattleWorkSummary,
|
||||
refreshed: readonly BarkBattleWorkSummary[],
|
||||
@@ -85,11 +213,7 @@ export function buildBarkBattleWorkSummaryFromDraft(
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
status: 'draft',
|
||||
generationStatus,
|
||||
publishReady: Boolean(
|
||||
draft.playerCharacterImageSrc?.trim() &&
|
||||
draft.opponentCharacterImageSrc?.trim() &&
|
||||
draft.uiBackgroundImageSrc?.trim(),
|
||||
),
|
||||
publishReady: hasBarkBattleDraftRequiredImages(draft),
|
||||
playCount: 0,
|
||||
updatedAt: draft.updatedAt,
|
||||
publishedAt: null,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
type PlatformCreationLaunchTarget,
|
||||
resolvePlatformCreationLaunchIntent,
|
||||
} from './platformCreationLaunchModel';
|
||||
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
|
||||
|
||||
describe('platformCreationLaunchModel', () => {
|
||||
test('keeps airp as a placeholder noop before prepare', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'airp',
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
shouldPrepare: false,
|
||||
reason: 'placeholder',
|
||||
});
|
||||
});
|
||||
|
||||
test('blocks hidden baby object match after prepare', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'baby-object-match',
|
||||
isBabyObjectMatchVisible: false,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'blocked',
|
||||
shouldPrepare: true,
|
||||
message: EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves known creation launch targets', () => {
|
||||
const targets: PlatformCreationLaunchTarget[] = [
|
||||
'rpg',
|
||||
'big-fish',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'jump-hop',
|
||||
'wooden-fish',
|
||||
'puzzle',
|
||||
'bark-battle',
|
||||
'visual-novel',
|
||||
'baby-object-match',
|
||||
];
|
||||
|
||||
targets.forEach((target) => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: target,
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'launch',
|
||||
shouldPrepare: true,
|
||||
target,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unknown creation type as a prepared noop', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'unknown-template',
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
shouldPrepare: true,
|
||||
reason: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
87
src/components/platform-entry/platformCreationLaunchModel.ts
Normal file
87
src/components/platform-entry/platformCreationLaunchModel.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
|
||||
export type PlatformCreationLaunchTarget =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish'
|
||||
| 'puzzle'
|
||||
| 'bark-battle'
|
||||
| 'visual-novel'
|
||||
| 'baby-object-match';
|
||||
|
||||
export type PlatformCreationLaunchIntent =
|
||||
| {
|
||||
type: 'noop';
|
||||
shouldPrepare: false;
|
||||
reason: 'placeholder';
|
||||
}
|
||||
| {
|
||||
type: 'noop';
|
||||
shouldPrepare: true;
|
||||
reason: 'unknown';
|
||||
}
|
||||
| {
|
||||
type: 'blocked';
|
||||
shouldPrepare: true;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'launch';
|
||||
shouldPrepare: true;
|
||||
target: PlatformCreationLaunchTarget;
|
||||
};
|
||||
|
||||
const PLATFORM_CREATION_LAUNCH_TARGETS = new Set<PlatformCreationTypeId>([
|
||||
'rpg',
|
||||
'big-fish',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'jump-hop',
|
||||
'wooden-fish',
|
||||
'puzzle',
|
||||
'bark-battle',
|
||||
'visual-novel',
|
||||
'baby-object-match',
|
||||
]);
|
||||
|
||||
export function resolvePlatformCreationLaunchIntent(params: {
|
||||
type: PlatformCreationTypeId;
|
||||
isBabyObjectMatchVisible: boolean;
|
||||
}): PlatformCreationLaunchIntent {
|
||||
if (params.type === 'airp') {
|
||||
return {
|
||||
type: 'noop',
|
||||
shouldPrepare: false,
|
||||
reason: 'placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
params.type === 'baby-object-match' &&
|
||||
!params.isBabyObjectMatchVisible
|
||||
) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
shouldPrepare: true,
|
||||
message: EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) {
|
||||
return {
|
||||
type: 'noop',
|
||||
shouldPrepare: true,
|
||||
reason: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'launch',
|
||||
shouldPrepare: true,
|
||||
target: params.type as PlatformCreationLaunchTarget,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../services/jump-hop/jumpHopClient';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
buildBabyObjectMatchCreationUrlState,
|
||||
buildBarkBattleCreationUrlState,
|
||||
buildBigFishCreationUrlState,
|
||||
buildJumpHopCreationUrlState,
|
||||
buildMatch3DCreationUrlState,
|
||||
buildPuzzleCreationUrlState,
|
||||
buildPuzzleDraftRuntimeUrlState,
|
||||
buildPuzzlePublishedRuntimeUrlState,
|
||||
buildPuzzleRuntimeUrlStateKey,
|
||||
buildSquareHoleCreationUrlState,
|
||||
buildVisualNovelCreationUrlState,
|
||||
buildWoodenFishCreationUrlState,
|
||||
hasCreationUrlStateValue,
|
||||
hasPuzzleRuntimeUrlStateValue,
|
||||
matchesBabyObjectMatchCreationUrlRestoreTarget,
|
||||
matchesBarkBattleCreationUrlRestoreTarget,
|
||||
matchesBigFishCreationUrlRestoreTarget,
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget,
|
||||
matchesVisualNovelCreationUrlRestoreTarget,
|
||||
normalizeCreationUrlValue,
|
||||
resolveCreationUrlRestoreTarget,
|
||||
resolveInitialCreationUrlRestoreDecision,
|
||||
resolveJumpHopCreationUrlRestoreStage,
|
||||
resolveWoodenFishCreationUrlRestoreStage,
|
||||
} from './platformCreationUrlStateModel';
|
||||
|
||||
describe('platformCreationUrlStateModel', () => {
|
||||
test('normalizes private creation url state values', () => {
|
||||
expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1');
|
||||
expect(normalizeCreationUrlValue(' ')).toBeNull();
|
||||
expect(
|
||||
hasCreationUrlStateValue({
|
||||
sessionId: ' ',
|
||||
profileId: null,
|
||||
draftId: undefined,
|
||||
workId: 'work-1',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(hasCreationUrlStateValue({})).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves initial creation url restore readiness', () => {
|
||||
const readyParams = {
|
||||
handled: false,
|
||||
pathname: '/creation/puzzle/result',
|
||||
state: { sessionId: 'puzzle-session-1' },
|
||||
isLoadingPlatform: false,
|
||||
canReadProtectedData: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
handled: true,
|
||||
}),
|
||||
).toEqual({ type: 'skip' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
pathname: '/works/detail',
|
||||
}),
|
||||
).toEqual({ type: 'mark-handled' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
state: {},
|
||||
}),
|
||||
).toEqual({ type: 'mark-handled' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
isLoadingPlatform: true,
|
||||
}),
|
||||
).toEqual({ type: 'wait' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
canReadProtectedData: false,
|
||||
}),
|
||||
).toEqual({ type: 'wait' });
|
||||
expect(resolveInitialCreationUrlRestoreDecision(readyParams)).toEqual({
|
||||
type: 'restore',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves supported creation url restore targets from paths', () => {
|
||||
const state = {
|
||||
sessionId: ' session-1 ',
|
||||
profileId: ' profile-1 ',
|
||||
draftId: ' draft-1 ',
|
||||
workId: ' work-1 ',
|
||||
};
|
||||
const cases = [
|
||||
['/creation/big-fish/result', 'big-fish'],
|
||||
['/creation/match3d/result', 'match3d'],
|
||||
['/creation/square-hole/result', 'square-hole'],
|
||||
['/creation/puzzle/result', 'puzzle'],
|
||||
['/creation/visual-novel/result', 'visual-novel'],
|
||||
['/creation/bark-battle/result', 'bark-battle'],
|
||||
['/creation/baby-object-match/result', 'baby-object-match'],
|
||||
['/creation/jump-hop/result', 'jump-hop'],
|
||||
['/creation/wooden-fish/result', 'wooden-fish'],
|
||||
] as const;
|
||||
|
||||
cases.forEach(([pathname, kind]) => {
|
||||
expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({
|
||||
kind,
|
||||
sessionId: 'session-1',
|
||||
profileId: 'profile-1',
|
||||
draftId: 'draft-1',
|
||||
workId: 'work-1',
|
||||
isGeneratingPath: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes creation url restore target values and generating paths', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', {
|
||||
sessionId: ' ',
|
||||
profileId: ' jump-profile-1 ',
|
||||
draftId: undefined,
|
||||
workId: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'jump-hop',
|
||||
sessionId: null,
|
||||
profileId: 'jump-profile-1',
|
||||
draftId: null,
|
||||
workId: null,
|
||||
isGeneratingPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('derives big fish restore session from work id when needed', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
|
||||
workId: 'big-fish-work-river',
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'big-fish',
|
||||
sessionId: null,
|
||||
profileId: null,
|
||||
draftId: null,
|
||||
workId: 'big-fish-work-river',
|
||||
isGeneratingPath: false,
|
||||
bigFishSessionId: 'river',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
|
||||
sessionId: 'big-fish-session-carp',
|
||||
workId: 'big-fish-work-river',
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'big-fish',
|
||||
bigFishSessionId: 'big-fish-session-carp',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unsupported creation paths without a concrete restore target', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/rpg/result', {
|
||||
sessionId: 'rpg-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/unknown/result', {
|
||||
sessionId: 'unknown-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fishery/result', {
|
||||
sessionId: 'big-fish-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/works/detail', {
|
||||
workId: 'work-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('matches restore targets against work and draft identities', () => {
|
||||
const bigFishTarget = resolveCreationUrlRestoreTarget(
|
||||
'/creation/big-fish/result',
|
||||
{
|
||||
workId: 'big-fish-work-river',
|
||||
},
|
||||
);
|
||||
expect(bigFishTarget?.kind).toBe('big-fish');
|
||||
if (bigFishTarget?.kind !== 'big-fish') {
|
||||
throw new Error('big fish target expected');
|
||||
}
|
||||
expect(
|
||||
matchesBigFishCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: 'river' },
|
||||
bigFishTarget,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBigFishCreationUrlRestoreTarget(
|
||||
{ workId: 'big-fish-work-river' },
|
||||
bigFishTarget,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const target = {
|
||||
sessionId: 'session-1',
|
||||
profileId: 'profile-1',
|
||||
draftId: 'draft-1',
|
||||
workId: 'work-1',
|
||||
};
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: 'session-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ profileId: 'profile-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ workId: 'work-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesVisualNovelCreationUrlRestoreTarget(
|
||||
{ profileId: 'profile-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBarkBattleCreationUrlRestoreTarget(
|
||||
{ draftId: 'draft-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBabyObjectMatchCreationUrlRestoreTarget(
|
||||
{ profileId: 'work-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: null, profileId: null, workId: null },
|
||||
{ sessionId: null, profileId: null, workId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesBarkBattleCreationUrlRestoreTarget(
|
||||
{ workId: null, draftId: null },
|
||||
{ workId: null, draftId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves work backed restore stages', () => {
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: true,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: true,
|
||||
}),
|
||||
).toBe('jump-hop-generating');
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: true,
|
||||
}),
|
||||
).toBe('jump-hop-result');
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: false,
|
||||
}),
|
||||
).toBe('jump-hop-workspace');
|
||||
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: true,
|
||||
hasRestoredDraft: true,
|
||||
}),
|
||||
).toBe('wooden-fish-generating');
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: true,
|
||||
}),
|
||||
).toBe('wooden-fish-result');
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
}),
|
||||
).toBe('wooden-fish-workspace');
|
||||
});
|
||||
|
||||
test('builds creation restore state for core session based plays', () => {
|
||||
expect(
|
||||
buildBigFishCreationUrlState({
|
||||
sessionId: ' big-fish-session-1 ',
|
||||
} as BigFishSessionSnapshotResponse),
|
||||
).toEqual({
|
||||
sessionId: 'big-fish-session-1',
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildMatch3DCreationUrlState({
|
||||
sessionId: 'match3d-session-1',
|
||||
draft: { profileId: 'match3d-profile-draft' },
|
||||
} as Match3DAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'match3d-session-1',
|
||||
profileId: 'match3d-profile-draft',
|
||||
workId: 'match3d-profile-draft',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildSquareHoleCreationUrlState({
|
||||
sessionId: 'square-session-1',
|
||||
publishedProfileId: 'square-profile-published',
|
||||
} as SquareHoleSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'square-session-1',
|
||||
profileId: 'square-profile-published',
|
||||
workId: 'square-profile-published',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildVisualNovelCreationUrlState({
|
||||
sessionId: 'visual-session-1',
|
||||
draft: { profileId: 'visual-profile-1' },
|
||||
} as VisualNovelAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'visual-session-1',
|
||||
profileId: 'visual-profile-1',
|
||||
workId: 'visual-profile-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle creation and runtime query state', () => {
|
||||
expect(
|
||||
buildPuzzleCreationUrlState({
|
||||
sessionId: 'puzzle-session-ocean',
|
||||
} as PuzzleAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'puzzle-session-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
workId: 'puzzle-work-ocean',
|
||||
});
|
||||
|
||||
const draftRuntime = buildPuzzleDraftRuntimeUrlState(
|
||||
buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
'level-2',
|
||||
);
|
||||
expect(draftRuntime).toEqual({
|
||||
mode: 'draft',
|
||||
runtimeSessionId: 'puzzle-session-ocean',
|
||||
runtimeProfileId: 'puzzle-profile-ocean',
|
||||
runtimeLevelId: 'level-2',
|
||||
});
|
||||
expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true);
|
||||
expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe(
|
||||
'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|',
|
||||
);
|
||||
|
||||
const publishedRuntime = buildPuzzlePublishedRuntimeUrlState(
|
||||
buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }),
|
||||
);
|
||||
expect(publishedRuntime.mode).toBe('published');
|
||||
expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean');
|
||||
expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u);
|
||||
});
|
||||
|
||||
test('builds creation state for work backed plays with work id priority', () => {
|
||||
expect(
|
||||
buildJumpHopCreationUrlState({
|
||||
session: {
|
||||
sessionId: 'jump-session-1',
|
||||
draft: { profileId: 'jump-profile-draft' },
|
||||
} as JumpHopSessionSnapshotResponse,
|
||||
work: {
|
||||
summary: {
|
||||
profileId: 'jump-profile-work',
|
||||
workId: 'jump-work-1',
|
||||
},
|
||||
} as JumpHopWorkProfileResponse,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'jump-session-1',
|
||||
profileId: 'jump-profile-work',
|
||||
workId: 'jump-work-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishCreationUrlState({
|
||||
session: {
|
||||
sessionId: 'wood-session-1',
|
||||
draft: { profileId: 'wood-profile-draft' },
|
||||
} as WoodenFishSessionSnapshotResponse,
|
||||
work: {
|
||||
summary: {
|
||||
profileId: 'wood-profile-work',
|
||||
workId: 'wood-work-1',
|
||||
},
|
||||
} as WoodenFishWorkProfileResponse,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'wood-session-1',
|
||||
profileId: 'wood-profile-work',
|
||||
draftId: 'wood-profile-work',
|
||||
workId: 'wood-work-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds creation state for draft backed local plays', () => {
|
||||
expect(
|
||||
buildBarkBattleCreationUrlState({
|
||||
draftId: 'bark-draft-1',
|
||||
workId: 'bark-work-1',
|
||||
} as BarkBattleDraftConfig),
|
||||
).toEqual({
|
||||
draftId: 'bark-draft-1',
|
||||
workId: 'bark-work-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildBabyObjectMatchCreationUrlState({
|
||||
draftId: 'baby-draft-1',
|
||||
profileId: 'baby-profile-1',
|
||||
} as BabyObjectMatchDraft),
|
||||
).toEqual({
|
||||
profileId: 'baby-profile-1',
|
||||
draftId: 'baby-draft-1',
|
||||
workId: 'baby-profile-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-base',
|
||||
profileId: 'puzzle-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-base',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾港口拼图。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾港口拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
431
src/components/platform-entry/platformCreationUrlStateModel.ts
Normal file
431
src/components/platform-entry/platformCreationUrlStateModel.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
type CreationUrlState,
|
||||
isCreationRestorePath,
|
||||
} from '../../services/creationUrlState';
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../services/jump-hop/jumpHopClient';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
buildPuzzleSessionIdFromProfileId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */
|
||||
export function normalizeCreationUrlValue(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
export function hasCreationUrlStateValue(state: CreationUrlState) {
|
||||
return Boolean(
|
||||
normalizeCreationUrlValue(state.sessionId) ||
|
||||
normalizeCreationUrlValue(state.profileId) ||
|
||||
normalizeCreationUrlValue(state.draftId) ||
|
||||
normalizeCreationUrlValue(state.workId),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) {
|
||||
return Boolean(
|
||||
normalizeCreationUrlValue(state.runtimeSessionId) ||
|
||||
normalizeCreationUrlValue(state.runtimeProfileId) ||
|
||||
normalizeCreationUrlValue(state.runtimeLevelId) ||
|
||||
normalizeCreationUrlValue(state.publicWorkCode) ||
|
||||
normalizeCreationUrlValue(state.mode),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) {
|
||||
return [
|
||||
normalizeCreationUrlValue(state.mode),
|
||||
normalizeCreationUrlValue(state.runtimeSessionId),
|
||||
normalizeCreationUrlValue(state.runtimeProfileId),
|
||||
normalizeCreationUrlValue(state.runtimeLevelId),
|
||||
normalizeCreationUrlValue(state.publicWorkCode),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export type CreationUrlRestoreTargetKind =
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'puzzle'
|
||||
| 'visual-novel'
|
||||
| 'bark-battle'
|
||||
| 'baby-object-match'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish';
|
||||
|
||||
type CreationUrlRestoreTargetBase = {
|
||||
kind: CreationUrlRestoreTargetKind;
|
||||
sessionId: string | null;
|
||||
profileId: string | null;
|
||||
draftId: string | null;
|
||||
workId: string | null;
|
||||
isGeneratingPath: boolean;
|
||||
};
|
||||
|
||||
export type BigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
|
||||
kind: 'big-fish';
|
||||
bigFishSessionId: string | null;
|
||||
};
|
||||
|
||||
type NonBigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
|
||||
kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'>;
|
||||
};
|
||||
|
||||
export type CreationUrlRestoreTarget =
|
||||
| BigFishCreationUrlRestoreTarget
|
||||
| NonBigFishCreationUrlRestoreTarget;
|
||||
|
||||
export type BigFishRestoreWorkIdentity = {
|
||||
sourceSessionId?: string | null;
|
||||
workId?: string | null;
|
||||
};
|
||||
|
||||
export type SessionProfileWorkRestoreIdentity = {
|
||||
sourceSessionId?: string | null;
|
||||
profileId?: string | null;
|
||||
workId?: string | null;
|
||||
};
|
||||
|
||||
export type ProfileRestoreWorkIdentity = {
|
||||
profileId?: string | null;
|
||||
};
|
||||
|
||||
export type BarkBattleRestoreWorkIdentity = {
|
||||
workId?: string | null;
|
||||
draftId?: string | null;
|
||||
};
|
||||
|
||||
export type BabyObjectMatchRestoreDraftIdentity = {
|
||||
profileId?: string | null;
|
||||
draftId?: string | null;
|
||||
};
|
||||
|
||||
const CREATION_URL_RESTORE_TARGET_ROUTES = [
|
||||
['/creation/big-fish', 'big-fish'],
|
||||
['/creation/match3d', 'match3d'],
|
||||
['/creation/square-hole', 'square-hole'],
|
||||
['/creation/puzzle', 'puzzle'],
|
||||
['/creation/visual-novel', 'visual-novel'],
|
||||
['/creation/bark-battle', 'bark-battle'],
|
||||
['/creation/baby-object-match', 'baby-object-match'],
|
||||
['/creation/jump-hop', 'jump-hop'],
|
||||
['/creation/wooden-fish', 'wooden-fish'],
|
||||
] as const satisfies readonly (readonly [
|
||||
string,
|
||||
CreationUrlRestoreTargetKind,
|
||||
])[];
|
||||
|
||||
export function resolveCreationUrlRestoreTarget(
|
||||
pathname: string | undefined,
|
||||
state: CreationUrlState,
|
||||
): CreationUrlRestoreTarget | null {
|
||||
const path = pathname?.trim() ?? '';
|
||||
const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) =>
|
||||
path === prefix || path.startsWith(`${prefix}/`),
|
||||
);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = route[1];
|
||||
const sessionId = normalizeCreationUrlValue(state.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(state.profileId);
|
||||
const draftId = normalizeCreationUrlValue(state.draftId);
|
||||
const workId = normalizeCreationUrlValue(state.workId);
|
||||
const base = {
|
||||
kind,
|
||||
sessionId,
|
||||
profileId,
|
||||
draftId,
|
||||
workId,
|
||||
isGeneratingPath: path.includes('/generating'),
|
||||
};
|
||||
|
||||
if (kind === 'big-fish') {
|
||||
return {
|
||||
...base,
|
||||
kind,
|
||||
bigFishSessionId:
|
||||
sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return base as NonBigFishCreationUrlRestoreTarget;
|
||||
}
|
||||
|
||||
function matchesRestoreValue(
|
||||
itemValue: string | null | undefined,
|
||||
targetValue: string | null,
|
||||
) {
|
||||
return Boolean(targetValue && itemValue === targetValue);
|
||||
}
|
||||
|
||||
export function matchesBigFishCreationUrlRestoreTarget(
|
||||
item: BigFishRestoreWorkIdentity,
|
||||
target: BigFishCreationUrlRestoreTarget,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.sourceSessionId, target.bigFishSessionId) ||
|
||||
matchesRestoreValue(item.workId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
item: SessionProfileWorkRestoreIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'sessionId' | 'profileId' | 'workId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.sourceSessionId, target.sessionId) ||
|
||||
matchesRestoreValue(item.profileId, target.profileId) ||
|
||||
matchesRestoreValue(item.workId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesVisualNovelCreationUrlRestoreTarget(
|
||||
item: ProfileRestoreWorkIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'profileId'>,
|
||||
) {
|
||||
return matchesRestoreValue(item.profileId, target.profileId);
|
||||
}
|
||||
|
||||
export function matchesBarkBattleCreationUrlRestoreTarget(
|
||||
item: BarkBattleRestoreWorkIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'workId' | 'draftId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.workId, target.workId) ||
|
||||
matchesRestoreValue(item.draftId, target.draftId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesBabyObjectMatchCreationUrlRestoreTarget(
|
||||
item: BabyObjectMatchRestoreDraftIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'profileId' | 'draftId' | 'workId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.profileId, target.profileId) ||
|
||||
matchesRestoreValue(item.draftId, target.draftId) ||
|
||||
matchesRestoreValue(item.profileId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveJumpHopCreationUrlRestoreStage(params: {
|
||||
isGeneratingPath: boolean;
|
||||
hasRestoredDraft: boolean;
|
||||
hasRestoredWork: boolean;
|
||||
}): SelectionStage {
|
||||
if (params.isGeneratingPath) {
|
||||
return 'jump-hop-generating';
|
||||
}
|
||||
|
||||
return params.hasRestoredDraft || params.hasRestoredWork
|
||||
? 'jump-hop-result'
|
||||
: 'jump-hop-workspace';
|
||||
}
|
||||
|
||||
export function resolveWoodenFishCreationUrlRestoreStage(params: {
|
||||
isGeneratingPath: boolean;
|
||||
hasRestoredDraft: boolean;
|
||||
}): SelectionStage {
|
||||
if (params.isGeneratingPath) {
|
||||
return 'wooden-fish-generating';
|
||||
}
|
||||
|
||||
return params.hasRestoredDraft
|
||||
? 'wooden-fish-result'
|
||||
: 'wooden-fish-workspace';
|
||||
}
|
||||
|
||||
export type InitialCreationUrlRestoreDecision =
|
||||
| { type: 'skip' }
|
||||
| { type: 'mark-handled' }
|
||||
| { type: 'wait' }
|
||||
| { type: 'restore' };
|
||||
|
||||
export function resolveInitialCreationUrlRestoreDecision(params: {
|
||||
handled: boolean;
|
||||
pathname: string | undefined;
|
||||
state: CreationUrlState;
|
||||
isLoadingPlatform: boolean;
|
||||
canReadProtectedData: boolean;
|
||||
}): InitialCreationUrlRestoreDecision {
|
||||
if (params.handled) {
|
||||
return { type: 'skip' };
|
||||
}
|
||||
|
||||
if (
|
||||
!isCreationRestorePath(params.pathname) ||
|
||||
!hasCreationUrlStateValue(params.state)
|
||||
) {
|
||||
return { type: 'mark-handled' };
|
||||
}
|
||||
|
||||
if (params.isLoadingPlatform || !params.canReadProtectedData) {
|
||||
return { type: 'wait' };
|
||||
}
|
||||
|
||||
return { type: 'restore' };
|
||||
}
|
||||
|
||||
export function buildBigFishCreationUrlState(
|
||||
session: BigFishSessionSnapshotResponse | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
workId: sessionId ? `big-fish-work-${sessionId}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DCreationUrlState(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.draft?.profileId ?? session?.publishedProfileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSquareHoleCreationUrlState(
|
||||
session: SquareHoleSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.draft?.profileId ?? session?.publishedProfileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleCreationUrlState(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId),
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleDraftRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRuntimeUrlState {
|
||||
const runtimeSessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ??
|
||||
buildPuzzleSessionIdFromProfileId(item.profileId);
|
||||
|
||||
return {
|
||||
mode: 'draft',
|
||||
runtimeSessionId,
|
||||
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
|
||||
runtimeLevelId: normalizeCreationUrlValue(levelId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzlePublishedRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRuntimeUrlState {
|
||||
return {
|
||||
mode: 'published',
|
||||
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
|
||||
runtimeLevelId: normalizeCreationUrlValue(levelId),
|
||||
publicWorkCode: buildPuzzlePublicWorkCode(item.profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVisualNovelCreationUrlState(
|
||||
session: VisualNovelAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(session?.draft?.profileId);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId ?? sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopCreationUrlState(params: {
|
||||
session?: JumpHopSessionSnapshotResponse | null;
|
||||
work?: JumpHopWorkProfileResponse | null;
|
||||
}): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
params.work?.summary.profileId ?? params.session?.draft?.profileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishCreationUrlState(params: {
|
||||
session?: WoodenFishSessionSnapshotResponse | null;
|
||||
work?: WoodenFishWorkProfileResponse | null;
|
||||
}): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
params.work?.summary.profileId ?? params.session?.draft?.profileId,
|
||||
);
|
||||
const draftId = profileId ?? sessionId;
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
draftId,
|
||||
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattleCreationUrlState(
|
||||
draft: BarkBattleDraftConfig | null,
|
||||
): CreationUrlState {
|
||||
return {
|
||||
draftId: normalizeCreationUrlValue(draft?.draftId),
|
||||
workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchCreationUrlState(
|
||||
draft: BabyObjectMatchDraft | null,
|
||||
): CreationUrlState {
|
||||
const profileId = normalizeCreationUrlValue(draft?.profileId);
|
||||
return {
|
||||
profileId,
|
||||
draftId: normalizeCreationUrlValue(draft?.draftId),
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
|
||||
|
||||
describe('platformCreationWorkDeleteFlow', () => {
|
||||
test('resolves RPG library delete confirmation without draft notice keys', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg-library',
|
||||
entry: {
|
||||
profileId: 'rpg-profile',
|
||||
worldName: '潮雾列岛',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'rpg-profile',
|
||||
title: '潮雾列岛',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves RPG work delete detail and notice keys by work status', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg',
|
||||
work: buildRpgWork(),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'rpg-work',
|
||||
title: 'RPG 草稿',
|
||||
detail: '删除后会从你的作品列表中移除。',
|
||||
noticeKeys: ['rpg:rpg-work', 'rpg:rpg-session', 'rpg:rpg-profile'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg',
|
||||
work: buildRpgWork({ status: 'published' }),
|
||||
}).detail,
|
||||
).toBe('删除后会从你的作品列表和公开广场中移除。');
|
||||
});
|
||||
|
||||
test('resolves mini game delete models with shared public and private detail copy', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'big-fish',
|
||||
work: buildBigFishWork({ status: 'published' }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 'big-fish-work',
|
||||
title: '大鱼作品',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: ['big-fish:big-fish-work', 'big-fish:big-fish-session'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'match3d',
|
||||
work: buildMatch3DWork(),
|
||||
}).detail,
|
||||
).toBe('删除后会从你的作品列表中移除。');
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'square-hole',
|
||||
work: buildSquareHoleWork({ publicationStatus: 'published' }),
|
||||
}).noticeKeys,
|
||||
).toEqual([
|
||||
'square-hole:square-hole-work',
|
||||
'square-hole:square-hole-profile',
|
||||
'square-hole:square-hole-session',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolves puzzle title fallback and stable result notice keys', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'puzzle',
|
||||
work: buildPuzzleWork({
|
||||
workTitle: ' ',
|
||||
levelName: ' 雾港第一关 ',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'puzzle-work',
|
||||
title: '雾港第一关',
|
||||
detail: '删除后会从你的作品列表中移除。',
|
||||
noticeKeys: [
|
||||
'puzzle:puzzle-work',
|
||||
'puzzle:puzzle-profile',
|
||||
'puzzle:puzzle-session-ocean',
|
||||
'puzzle:puzzle-work-ocean',
|
||||
'puzzle:puzzle-profile-ocean',
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'puzzle',
|
||||
work: buildPuzzleWork({ workTitle: '', levelName: ' ' }),
|
||||
}).title,
|
||||
).toBe('未命名拼图');
|
||||
});
|
||||
|
||||
test('resolves visual novel and baby object match special delete copy', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'visual-novel',
|
||||
work: buildVisualNovelWork({ title: '', publishStatus: 'published' }),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'visual-novel-profile',
|
||||
title: '未命名视觉小说',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: ['visual-novel:visual-novel-profile'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'baby-object-match',
|
||||
work: buildBabyObjectMatchDraft({
|
||||
workTitle: ' ',
|
||||
publicationStatus: 'published',
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'baby-profile',
|
||||
title: '宝贝识物',
|
||||
detail: '删除后会从你的作品列表和寓教于乐板块中移除。',
|
||||
noticeKeys: [
|
||||
'baby-object-match:baby-profile',
|
||||
'baby-object-match:baby-draft',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildRpgWork(
|
||||
overrides: Partial<CustomWorldWorkSummary> = {},
|
||||
): CustomWorldWorkSummary {
|
||||
return {
|
||||
workId: 'rpg-work',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: 'RPG 草稿',
|
||||
subtitle: '待完善',
|
||||
summary: 'RPG 摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
stage: 'draft',
|
||||
stageLabel: '草稿',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
sessionId: 'rpg-session',
|
||||
profileId: 'rpg-profile',
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work',
|
||||
sourceSessionId: 'big-fish-session',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '大鱼作品',
|
||||
subtitle: '大鱼吃小鱼',
|
||||
summary: '大鱼摘要。',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work',
|
||||
profileId: 'puzzle-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: '拼图作品',
|
||||
workDescription: '拼图摘要。',
|
||||
levelName: '拼图第一关',
|
||||
summary: '拼图摘要。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work',
|
||||
profileId: 'match3d-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session',
|
||||
gameName: '抓大鹅作品',
|
||||
themeText: '糖果厨房',
|
||||
summary: '抓大鹅摘要。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work',
|
||||
profileId: 'square-hole-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session',
|
||||
gameName: '方洞作品',
|
||||
themeText: '图形',
|
||||
twistRule: '反直觉',
|
||||
summary: '方洞摘要。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '背景',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 8,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile',
|
||||
ownerUserId: 'user-1',
|
||||
title: '视觉小说作品',
|
||||
description: '视觉小说摘要。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'draft',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-draft',
|
||||
profileId: 'baby-profile',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '宝贝识物作品',
|
||||
workDescription: '宝贝识物摘要。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'apple',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'banana',
|
||||
itemName: '香蕉',
|
||||
imageSrc: '/banana.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: [],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
288
src/components/platform-entry/platformCreationWorkDeleteFlow.ts
Normal file
288
src/components/platform-entry/platformCreationWorkDeleteFlow.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
collectDraftNoticeKeys,
|
||||
} from './platformDraftGenerationShelfModel';
|
||||
|
||||
const PRIVATE_WORK_DELETE_DETAIL = '删除后会从你的作品列表中移除。';
|
||||
const PUBLIC_GALLERY_DELETE_DETAIL = '删除后会从你的作品列表和公开广场中移除。';
|
||||
const EDUTAINMENT_PUBLIC_DELETE_DETAIL =
|
||||
'删除后会从你的作品列表和寓教于乐板块中移除。';
|
||||
|
||||
export type PlatformCreationWorkDeleteConfirmationModel = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
noticeKeys: string[];
|
||||
};
|
||||
|
||||
export type PlatformCreationWorkDeleteInput =
|
||||
| {
|
||||
kind: 'rpg-library';
|
||||
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>;
|
||||
}
|
||||
| {
|
||||
kind: 'rpg';
|
||||
work: Pick<
|
||||
CustomWorldWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'big-fish';
|
||||
work: Pick<
|
||||
BigFishWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sourceSessionId'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
work: Pick<
|
||||
PuzzleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'workTitle'
|
||||
| 'levelName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'match3d';
|
||||
work: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'square-hole';
|
||||
work: Pick<
|
||||
SquareHoleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'visual-novel';
|
||||
work: Pick<
|
||||
VisualNovelWorkSummary,
|
||||
'profileId' | 'title' | 'publishStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'baby-object-match';
|
||||
work: Pick<
|
||||
BabyObjectMatchDraft,
|
||||
| 'profileId'
|
||||
| 'draftId'
|
||||
| 'workTitle'
|
||||
| 'templateName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
};
|
||||
|
||||
export function resolvePlatformCreationWorkDeleteConfirmationModel(
|
||||
input: PlatformCreationWorkDeleteInput,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
switch (input.kind) {
|
||||
case 'rpg-library':
|
||||
return resolveRpgLibraryDeleteConfirmationModel(input.entry);
|
||||
case 'rpg':
|
||||
return resolveRpgWorkDeleteConfirmationModel(input.work);
|
||||
case 'big-fish':
|
||||
return resolveBigFishWorkDeleteConfirmationModel(input.work);
|
||||
case 'puzzle':
|
||||
return resolvePuzzleWorkDeleteConfirmationModel(input.work);
|
||||
case 'match3d':
|
||||
return resolveMatch3DWorkDeleteConfirmationModel(input.work);
|
||||
case 'square-hole':
|
||||
return resolveSquareHoleWorkDeleteConfirmationModel(input.work);
|
||||
case 'visual-novel':
|
||||
return resolveVisualNovelWorkDeleteConfirmationModel(input.work);
|
||||
case 'baby-object-match':
|
||||
return resolveBabyObjectMatchDeleteConfirmationModel(input.work);
|
||||
default: {
|
||||
const exhaustive: never = input;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusDeleteDetail(
|
||||
status: string,
|
||||
publishedDetail = PUBLIC_GALLERY_DELETE_DETAIL,
|
||||
) {
|
||||
return status === 'published' ? publishedDetail : PRIVATE_WORK_DELETE_DETAIL;
|
||||
}
|
||||
|
||||
function resolveTrimmedTitle(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const trimmedValue = value?.trim();
|
||||
return trimmedValue || fallback;
|
||||
}
|
||||
|
||||
function resolveRpgLibraryDeleteConfirmationModel(
|
||||
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: entry.profileId,
|
||||
title: entry.worldName,
|
||||
detail: PUBLIC_GALLERY_DELETE_DETAIL,
|
||||
noticeKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRpgWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
CustomWorldWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.title,
|
||||
detail: resolveStatusDeleteDetail(work.status),
|
||||
noticeKeys: collectDraftNoticeKeys('rpg', [
|
||||
work.workId,
|
||||
work.sessionId,
|
||||
work.profileId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBigFishWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
BigFishWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sourceSessionId'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.title,
|
||||
detail: resolveStatusDeleteDetail(work.status),
|
||||
noticeKeys: collectDraftNoticeKeys('big-fish', [
|
||||
work.workId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePuzzleWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
PuzzleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'workTitle'
|
||||
| 'levelName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: resolveTrimmedTitle(
|
||||
work.workTitle,
|
||||
resolveTrimmedTitle(work.levelName, '未命名拼图'),
|
||||
),
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('puzzle', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
buildPuzzleResultWorkId(work.sourceSessionId),
|
||||
buildPuzzleResultProfileId(work.sourceSessionId),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatch3DWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.gameName,
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('match3d', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSquareHoleWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
SquareHoleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.gameName,
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('square-hole', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVisualNovelWorkDeleteConfirmationModel(
|
||||
work: Pick<VisualNovelWorkSummary, 'profileId' | 'title' | 'publishStatus'>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.profileId,
|
||||
title: work.title || '未命名视觉小说',
|
||||
detail: resolveStatusDeleteDetail(work.publishStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('visual-novel', [work.profileId]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBabyObjectMatchDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
BabyObjectMatchDraft,
|
||||
'profileId' | 'draftId' | 'workTitle' | 'templateName' | 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.profileId,
|
||||
title: resolveTrimmedTitle(work.workTitle, work.templateName),
|
||||
detail: resolveStatusDeleteDetail(
|
||||
work.publicationStatus,
|
||||
EDUTAINMENT_PUBLIC_DELETE_DETAIL,
|
||||
),
|
||||
noticeKeys: collectDraftNoticeKeys('baby-object-match', [
|
||||
work.profileId,
|
||||
work.draftId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
113
src/components/platform-entry/platformDialogStateModel.test.ts
Normal file
113
src/components/platform-entry/platformDialogStateModel.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
buildPlatformTaskCompletionDialogDismissKey,
|
||||
formatPlatformDialogSource,
|
||||
isBackgroundGenerationStillRunningMessage,
|
||||
normalizePlatformDialogMessage,
|
||||
PLATFORM_TASK_COMPLETION_MESSAGE,
|
||||
resolveActivePlatformDialog,
|
||||
resolvePlatformErrorDialog,
|
||||
} from './platformDialogStateModel';
|
||||
|
||||
describe('platformDialogStateModel', () => {
|
||||
test('normalizes platform dialog messages', () => {
|
||||
expect(normalizePlatformDialogMessage(' 图片失败 ')).toBe('图片失败');
|
||||
expect(normalizePlatformDialogMessage(' ')).toBeNull();
|
||||
expect(normalizePlatformDialogMessage(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('formats dialog source with optional identity', () => {
|
||||
expect(formatPlatformDialogSource('拼图草稿', ' puzzle-session-1 ')).toBe(
|
||||
'拼图草稿 puzzle-session-1',
|
||||
);
|
||||
expect(formatPlatformDialogSource('拼图草稿', ' ')).toBe('拼图草稿');
|
||||
});
|
||||
|
||||
test('detects background generation still running messages', () => {
|
||||
expect(
|
||||
isBackgroundGenerationStillRunningMessage('后台仍在处理,请稍后查看。'),
|
||||
).toBe(true);
|
||||
expect(isBackgroundGenerationStillRunningMessage('素材生成失败。')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves the first non-empty error candidate', () => {
|
||||
expect(
|
||||
resolvePlatformErrorDialog([
|
||||
{
|
||||
key: 'empty',
|
||||
source: '空来源',
|
||||
message: ' ',
|
||||
},
|
||||
{
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: ' 素材生成失败。 ',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformErrorDialog([
|
||||
{
|
||||
key: 'empty',
|
||||
source: '空来源',
|
||||
message: null,
|
||||
},
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds stable dismiss keys for error and completion dialogs', () => {
|
||||
expect(
|
||||
buildPlatformErrorDialogDismissKey({
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
}),
|
||||
).toBe('puzzle:拼图草稿 puzzle-session-1:素材生成失败。');
|
||||
expect(buildPlatformErrorDialogDismissKey(null)).toBeNull();
|
||||
|
||||
expect(
|
||||
buildPlatformTaskCompletionDialogDismissKey({
|
||||
key: 'match3d',
|
||||
source: '抓大鹅草稿 match3d-session-1',
|
||||
message: PLATFORM_TASK_COMPLETION_MESSAGE,
|
||||
completedAtMs: null,
|
||||
}),
|
||||
).toBe(
|
||||
`match3d:抓大鹅草稿 match3d-session-1:${PLATFORM_TASK_COMPLETION_MESSAGE}:0`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hides active dialog when the dismiss key has already been recorded', () => {
|
||||
const dialog = {
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
};
|
||||
const dismissKey = buildPlatformErrorDialogDismissKey(dialog);
|
||||
|
||||
expect(
|
||||
resolveActivePlatformDialog(
|
||||
dialog,
|
||||
dismissKey,
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveActivePlatformDialog(
|
||||
dialog,
|
||||
'other-dismiss-key',
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
),
|
||||
).toBe(dialog);
|
||||
});
|
||||
});
|
||||
85
src/components/platform-entry/platformDialogStateModel.ts
Normal file
85
src/components/platform-entry/platformDialogStateModel.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PlatformErrorDialogPayload } from './PlatformErrorDialog';
|
||||
import type { PlatformTaskCompletionDialogPayload } from './PlatformTaskCompletionDialog';
|
||||
|
||||
export type PlatformErrorDialogState = PlatformErrorDialogPayload & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type PlatformTaskFailureDialogState = PlatformErrorDialogState & {
|
||||
failedAtMs: number;
|
||||
};
|
||||
|
||||
export type PlatformTaskCompletionDialogState =
|
||||
PlatformTaskCompletionDialogPayload & {
|
||||
key: string;
|
||||
completedAtMs: number | null;
|
||||
};
|
||||
|
||||
export type PlatformDialogCandidate = {
|
||||
key: string;
|
||||
source: string;
|
||||
message: string | null | undefined;
|
||||
};
|
||||
|
||||
export const PLATFORM_TASK_COMPLETION_MESSAGE =
|
||||
'生成任务已完成,可以继续查看草稿。';
|
||||
|
||||
/** 收口平台弹窗候选的纯状态规则,壳层只负责副作用清理。 */
|
||||
export function normalizePlatformDialogMessage(
|
||||
message: string | null | undefined,
|
||||
) {
|
||||
const normalized = message?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export function formatPlatformDialogSource(label: string, id?: string | null) {
|
||||
const normalizedId = id?.trim();
|
||||
return normalizedId ? `${label} ${normalizedId}` : label;
|
||||
}
|
||||
|
||||
export function isBackgroundGenerationStillRunningMessage(message: string) {
|
||||
return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message);
|
||||
}
|
||||
|
||||
export function resolvePlatformErrorDialog(
|
||||
candidates: readonly PlatformDialogCandidate[],
|
||||
): PlatformErrorDialogState | null {
|
||||
for (const candidate of candidates) {
|
||||
const message = normalizePlatformDialogMessage(candidate.message);
|
||||
if (message) {
|
||||
return {
|
||||
key: candidate.key,
|
||||
source: candidate.source,
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPlatformErrorDialogDismissKey(
|
||||
error: PlatformErrorDialogState | null,
|
||||
) {
|
||||
return error ? `${error.key}:${error.source}:${error.message}` : null;
|
||||
}
|
||||
|
||||
export function buildPlatformTaskCompletionDialogDismissKey(
|
||||
completion: PlatformTaskCompletionDialogState | null,
|
||||
) {
|
||||
return completion
|
||||
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveActivePlatformDialog<TDialog>(
|
||||
currentDialog: TDialog | null,
|
||||
dismissedDialogKey: string | null,
|
||||
buildDismissKey: (dialog: TDialog | null) => string | null,
|
||||
): TDialog | null {
|
||||
const currentDialogDismissKey = buildDismissKey(currentDialog);
|
||||
return currentDialogDismissKey &&
|
||||
currentDialogDismissKey === dismissedDialogKey
|
||||
? null
|
||||
: currentDialog;
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
buildCreationWorkShelfRuntimeState,
|
||||
buildPendingPuzzleWorks,
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
collectVisibleDraftNoticeKeys,
|
||||
createPendingDraftShelfState,
|
||||
type DraftGenerationNoticeMap,
|
||||
getGenerationNoticeShelfKeys,
|
||||
hasUnreadDraftGenerationUpdates,
|
||||
mergeBigFishWorkSummary,
|
||||
mergePuzzleWorkSummary,
|
||||
resolveBigFishDraftOpenIntent,
|
||||
resolveJumpHopDraftOpenIntent,
|
||||
resolveMatch3DDraftOpenIntent,
|
||||
resolvePuzzleDraftOpenIntent,
|
||||
resolveSquareHoleDraftOpenIntent,
|
||||
resolveVisualNovelDraftOpenIntent,
|
||||
resolveWoodenFishDraftOpenIntent,
|
||||
} from './platformDraftGenerationShelfModel';
|
||||
|
||||
describe('platformDraftGenerationShelfModel', () => {
|
||||
test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
sourceSessionId: null,
|
||||
publicationStatus: 'published',
|
||||
}),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork(),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
message: '首图生成失败。',
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'failed-generation',
|
||||
source: 'restored',
|
||||
errorMessage: '首图生成失败。',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork(),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'puzzle-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
coverImageSrc: '/media/puzzle-cover.png',
|
||||
}),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'generating',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
|
||||
const item = buildMatch3DWork({
|
||||
publicationStatus: 'published',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item,
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item,
|
||||
notices: {},
|
||||
forceDraft: true,
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => {
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item: buildMatch3DWork(),
|
||||
notices: {
|
||||
'match3d:match3d-session-base': {
|
||||
status: 'ready',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
hasBackgroundGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'ready-unread',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => {
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item: buildMatch3DWork({
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveBigFishDraftOpenIntent reopens active generating session before restoring draft', () => {
|
||||
expect(
|
||||
resolveBigFishDraftOpenIntent({
|
||||
item: buildBigFishWork(),
|
||||
activeSessionId: 'big-fish-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveBigFishDraftOpenIntent({
|
||||
item: buildBigFishWork(),
|
||||
activeSessionId: 'other-session',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSquareHoleDraftOpenIntent handles published, missing, active and restore states', () => {
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork({ publicationStatus: 'published' }),
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork({ sourceSessionId: null }),
|
||||
forceDraft: true,
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'missing-session',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork(),
|
||||
activeSessionId: 'square-hole-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
sourceSessionId: 'square-hole-session-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork(),
|
||||
activeSessionId: 'other-session',
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
shouldClearGenerationState: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveVisualNovelDraftOpenIntent handles published, active, current result and load detail states', () => {
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork({ publishStatus: 'published' }),
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'visual-novel-profile-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'visual-novel-profile-base',
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'current-result',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'other-profile',
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => {
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ publicationStatus: 'published' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork(),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'jump-hop-session-base',
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-failed-generation',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ sourceSessionId: null }),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-work-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => {
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork({
|
||||
sourceSessionId: null,
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
notices: {
|
||||
'wooden-fish:wooden-fish-profile-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'wooden-fish-profile-base',
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-failed-generation',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork({ generationStatus: 'generating' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork(),
|
||||
notices: {
|
||||
'wooden-fish:wooden-fish-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
failureFallbackStage: 'wooden-fish-workspace',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork(),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
failureFallbackStage: 'wooden-fish-generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => {
|
||||
const pending = buildPendingPuzzleWorks(
|
||||
{
|
||||
'puzzle-session-ocean': createPendingDraftShelfState(
|
||||
'failed',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
),
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]).toMatchObject({
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
workTitle: '拼图草稿',
|
||||
summary: '拼图草稿生成失败,可重新打开处理。',
|
||||
generationStatus: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => {
|
||||
const pending = buildPendingPuzzleWorks(
|
||||
{
|
||||
'puzzle-session-ocean': createPendingDraftShelfState(
|
||||
'generating',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
),
|
||||
},
|
||||
[buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })],
|
||||
);
|
||||
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
|
||||
test('mergePuzzleWorkSummary only replaces the matching profile', () => {
|
||||
const current = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-1',
|
||||
workTitle: '旧拼图',
|
||||
});
|
||||
const updated = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-1',
|
||||
workTitle: '新拼图',
|
||||
});
|
||||
const other = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-2',
|
||||
workTitle: '别的拼图',
|
||||
});
|
||||
|
||||
expect(mergePuzzleWorkSummary(current, updated)).toBe(updated);
|
||||
expect(mergePuzzleWorkSummary(current, other)).toBe(current);
|
||||
});
|
||||
|
||||
test('mergeBigFishWorkSummary only replaces the matching source session', () => {
|
||||
const current = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
title: '旧大鱼',
|
||||
});
|
||||
const updated = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
title: '新大鱼',
|
||||
});
|
||||
const other = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-2',
|
||||
title: '别的大鱼',
|
||||
});
|
||||
|
||||
expect(mergeBigFishWorkSummary(current, updated)).toBe(updated);
|
||||
expect(mergeBigFishWorkSummary(current, other)).toBe(current);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => {
|
||||
const [item] = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
buildPuzzleWork({
|
||||
workId: 'puzzle-work-empty',
|
||||
profileId: 'puzzle-profile-empty',
|
||||
sourceSessionId: 'puzzle-session-empty',
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
levelName: '',
|
||||
summary: '正在生成拼图草稿。',
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(item).toBeTruthy();
|
||||
|
||||
const noticeKeys = getGenerationNoticeShelfKeys(item!);
|
||||
const notices = Object.fromEntries(
|
||||
noticeKeys.map((key) => [
|
||||
key,
|
||||
{ status: 'failed', seen: false },
|
||||
]),
|
||||
) as DraftGenerationNoticeMap;
|
||||
|
||||
const state = buildCreationWorkShelfRuntimeState({
|
||||
item: item!,
|
||||
notices,
|
||||
pendingShelfItems: {
|
||||
puzzle: {
|
||||
'puzzle-session-empty': createPendingDraftShelfState(
|
||||
'failed',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
{ summary: '图片生成超时,可重新打开处理。' },
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
isGenerating: false,
|
||||
hasGenerationFailure: true,
|
||||
generationFailureSummary: '拼图草稿生成失败,可重新打开处理。',
|
||||
hasUnreadUpdate: false,
|
||||
suppressPersistedGenerating: true,
|
||||
titleOverride: '拼图草稿',
|
||||
summaryOverride: '图片生成超时,可重新打开处理。',
|
||||
});
|
||||
});
|
||||
|
||||
test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => {
|
||||
const puzzle = buildPuzzleWork({
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
});
|
||||
const visibleKeys = collectVisibleDraftNoticeKeys({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
jumpHopItems: [],
|
||||
woodenFishItems: [],
|
||||
match3dItems: [],
|
||||
squareHoleItems: [],
|
||||
puzzleItems: [puzzle],
|
||||
visualNovelItems: [],
|
||||
barkBattleItems: [],
|
||||
babyObjectMatchItems: [],
|
||||
});
|
||||
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-work-ocean');
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean');
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-session-ocean');
|
||||
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-work-ocean',
|
||||
);
|
||||
expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-profile-ocean',
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnreadDraftGenerationUpdates(
|
||||
{
|
||||
'puzzle:puzzle-profile-ocean': {
|
||||
status: 'ready',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
visibleKeys,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasUnreadDraftGenerationUpdates(
|
||||
{
|
||||
'puzzle:puzzle-profile-ocean': {
|
||||
status: 'ready',
|
||||
seen: true,
|
||||
},
|
||||
},
|
||||
visibleKeys,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function emptyGenerationFacts(
|
||||
overrides: Partial<Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation']> = {},
|
||||
): Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation'] {
|
||||
return {
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationFailure: false,
|
||||
hasActiveGenerationRunning: false,
|
||||
hasBackgroundGenerationFailure: false,
|
||||
hasBackgroundGenerationRunning: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-base',
|
||||
profileId: 'puzzle-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-base',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾港口拼图。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾港口拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-base',
|
||||
profileId: 'match3d-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-base',
|
||||
gameName: '潮雾抓大鹅',
|
||||
themeText: '潮雾港口',
|
||||
summary: '潮雾港口抓大鹅。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work-base',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾大鱼',
|
||||
subtitle: '潮雾港口',
|
||||
summary: '潮雾港口大鱼吃小鱼。',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work-base',
|
||||
profileId: 'square-hole-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session-base',
|
||||
gameName: '潮雾方洞',
|
||||
themeText: '潮雾港口',
|
||||
twistRule: '避开雾门',
|
||||
summary: '潮雾港口方洞挑战。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '潮雾港口',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 1,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
title: '潮雾视觉小说',
|
||||
description: '潮雾港口视觉小说。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'draft',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopWork(
|
||||
overrides: Partial<JumpHopWorkSummaryResponse> = {},
|
||||
): JumpHopWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-base',
|
||||
profileId: 'jump-hop-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-base',
|
||||
workTitle: '潮雾跳一跳',
|
||||
workDescription: '潮雾港口跳一跳。',
|
||||
themeTags: [],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishWork(
|
||||
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-base',
|
||||
profileId: 'wooden-fish-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-base',
|
||||
workTitle: '潮雾敲木鱼',
|
||||
workDescription: '潮雾港口敲木鱼。',
|
||||
themeTags: ['敲木鱼'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
1490
src/components/platform-entry/platformDraftGenerationShelfModel.ts
Normal file
1490
src/components/platform-entry/platformDraftGenerationShelfModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
MiniGameDraftGenerationKind,
|
||||
MiniGameDraftGenerationPhase,
|
||||
MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
|
||||
|
||||
function buildGenerationState(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
phase: MiniGameDraftGenerationPhase = 'compile',
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind,
|
||||
phase,
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 1,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformGenerationProgressTickModel', () => {
|
||||
test('ticks while a mini-game generation stage has a running state', () => {
|
||||
const cases: Array<
|
||||
[stage: SelectionStage, kind: MiniGameDraftGenerationKind]
|
||||
> = [
|
||||
['puzzle-generating', 'puzzle'],
|
||||
['match3d-generating', 'match3d'],
|
||||
['big-fish-generating', 'big-fish'],
|
||||
['square-hole-generating', 'square-hole'],
|
||||
['jump-hop-generating', 'jump-hop'],
|
||||
['wooden-fish-generating', 'wooden-fish'],
|
||||
['baby-object-match-generating', 'baby-object-match'],
|
||||
];
|
||||
|
||||
for (const [selectionStage, kind] of cases) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
[kind]: buildGenerationState(kind),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: kind,
|
||||
shouldTick: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick mini-game generation when state is missing or terminal', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
|
||||
for (const phase of ['ready', 'failed'] as const) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {
|
||||
puzzle: buildGenerationState('puzzle', phase),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick when stage and mini-game state do not match', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {
|
||||
match3d: buildGenerationState('match3d'),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('ticks visual novel generation only after it has started and before terminal phases', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: false,
|
||||
});
|
||||
|
||||
for (const phase of ['ready', 'failed'] as const) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick non-generation stages even when states are present', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'platform',
|
||||
miniGameStates: {
|
||||
puzzle: buildGenerationState('puzzle'),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: null,
|
||||
shouldTick: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
MiniGameDraftGenerationKind,
|
||||
MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
export type PlatformVisualNovelGenerationPhase =
|
||||
| 'generating'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
export type PlatformGenerationProgressTickKind =
|
||||
| MiniGameDraftGenerationKind
|
||||
| 'visual-novel';
|
||||
|
||||
export type PlatformGenerationProgressTickInput = {
|
||||
selectionStage: SelectionStage;
|
||||
miniGameStates: Partial<
|
||||
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
|
||||
>;
|
||||
visualNovel: {
|
||||
startedAtMs: number | null;
|
||||
phase: PlatformVisualNovelGenerationPhase;
|
||||
};
|
||||
};
|
||||
|
||||
export type PlatformGenerationProgressTickDecision = {
|
||||
activeKind: PlatformGenerationProgressTickKind | null;
|
||||
shouldTick: boolean;
|
||||
};
|
||||
|
||||
const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial<
|
||||
Record<SelectionStage, MiniGameDraftGenerationKind>
|
||||
> = {
|
||||
'puzzle-generating': 'puzzle',
|
||||
'match3d-generating': 'match3d',
|
||||
'big-fish-generating': 'big-fish',
|
||||
'square-hole-generating': 'square-hole',
|
||||
'jump-hop-generating': 'jump-hop',
|
||||
'wooden-fish-generating': 'wooden-fish',
|
||||
'baby-object-match-generating': 'baby-object-match',
|
||||
};
|
||||
|
||||
function shouldTickMiniGameGenerationState(
|
||||
state: MiniGameDraftGenerationState | null | undefined,
|
||||
) {
|
||||
return state != null && state.phase !== 'ready' && state.phase !== 'failed';
|
||||
}
|
||||
|
||||
/** 收口生成页进度 tick 判定,壳层只保留 interval 副作用。 */
|
||||
export function resolvePlatformGenerationProgressTickDecision(
|
||||
input: PlatformGenerationProgressTickInput,
|
||||
): PlatformGenerationProgressTickDecision {
|
||||
if (input.selectionStage === 'visual-novel-generating') {
|
||||
return {
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick:
|
||||
input.visualNovel.startedAtMs != null &&
|
||||
input.visualNovel.phase !== 'ready' &&
|
||||
input.visualNovel.phase !== 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
const activeKind =
|
||||
MINI_GAME_GENERATION_STAGE_TO_KIND[input.selectionStage] ?? null;
|
||||
if (!activeKind) {
|
||||
return {
|
||||
activeKind: null,
|
||||
shouldTick: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeKind,
|
||||
shouldTick: shouldTickMiniGameGenerationState(
|
||||
input.miniGameStates[activeKind],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildMatch3DProfileFromSession,
|
||||
mapMatch3DWorkToPublicWorkDetail,
|
||||
mapPublicWorkDetailToMatch3DWork,
|
||||
resolveActiveMatch3DRuntimeProfile,
|
||||
resolveMatch3DRuntimeBackgroundImageSrc,
|
||||
resolveMatch3DRuntimeGeneratedBackgroundAsset,
|
||||
resolveMatch3DRuntimeGeneratedItemAssets,
|
||||
} from './platformMatch3DRuntimeProfile';
|
||||
|
||||
function buildBackgroundAsset(
|
||||
overrides: Partial<Match3DGeneratedBackgroundAsset> = {},
|
||||
): Match3DGeneratedBackgroundAsset {
|
||||
return {
|
||||
prompt: '森林棋盘',
|
||||
imageSrc: '/generated/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
status: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildItemAsset(
|
||||
overrides: Partial<Match3DGeneratedItemAsset> = {},
|
||||
): Match3DGeneratedItemAsset {
|
||||
return {
|
||||
itemId: 'item-1',
|
||||
itemName: '蘑菇',
|
||||
imageSrc: '/generated/match3d/item.png',
|
||||
imageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfile(
|
||||
overrides: Partial<Match3DWorkProfile> = {},
|
||||
): Match3DWorkProfile {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '森林抓鹅',
|
||||
themeText: '森林',
|
||||
summary: '找出蘑菇。',
|
||||
tags: ['森林', '蘑菇'],
|
||||
coverImageSrc: '/cover.png',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: 1,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [buildItemAsset()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRun(overrides: Partial<Match3DRunSnapshot> = {}): Match3DRunSnapshot {
|
||||
return {
|
||||
runId: 'match3d-run-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
status: 'running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1000,
|
||||
durationLimitMs: 60000,
|
||||
remainingMs: 55000,
|
||||
clearCount: 12,
|
||||
totalItemCount: 12,
|
||||
clearedItemCount: 0,
|
||||
items: [],
|
||||
traySlots: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublicWork(
|
||||
overrides: Partial<PlatformMatch3DGalleryCard> = {},
|
||||
): PlatformMatch3DGalleryCard {
|
||||
return {
|
||||
sourceType: 'match3d',
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
publicWorkCode: 'M3D-00000001',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '森林抓鹅',
|
||||
subtitle: '抓大鹅',
|
||||
summaryText: '找出蘑菇。',
|
||||
coverImageSrc: '/cover.png',
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [buildItemAsset()],
|
||||
themeTags: ['森林', '蘑菇'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('Match3D runtime profile maps public detail and promotes item background asset', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/background-from-item.png',
|
||||
imageObjectKey: 'oss/background-from-item.png',
|
||||
});
|
||||
const work = mapPublicWorkDetailToMatch3DWork(
|
||||
buildPublicWork({
|
||||
generatedBackgroundAsset: null,
|
||||
backgroundImageSrc: null,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset);
|
||||
expect(work?.backgroundImageSrc).toBe(
|
||||
'/generated/match3d/background-from-item.png',
|
||||
);
|
||||
expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png');
|
||||
});
|
||||
|
||||
test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/detail-background.png',
|
||||
});
|
||||
const detail = mapMatch3DWorkToPublicWorkDetail(
|
||||
buildProfile({
|
||||
generatedBackgroundAsset: null,
|
||||
backgroundImageSrc: null,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
sourceType: 'match3d',
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
backgroundImageSrc: '/generated/match3d/detail-background.png',
|
||||
generatedBackgroundAsset: backgroundAsset,
|
||||
});
|
||||
});
|
||||
|
||||
test('Match3D runtime profile builds draft profile from session snapshot', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/draft-background.png',
|
||||
});
|
||||
const session: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-draft',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_compiled',
|
||||
anchorPack: {
|
||||
theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' },
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除数',
|
||||
value: '12',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '4',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
draft: {
|
||||
profileId: 'match3d-draft-profile',
|
||||
gameName: '草稿抓鹅',
|
||||
themeText: '森林',
|
||||
summaryText: '草稿摘要',
|
||||
tags: ['森林'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: '/reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publishReady: true,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const profile = buildMatch3DProfileFromSession(session);
|
||||
|
||||
expect(profile?.profileId).toBe('match3d-draft-profile');
|
||||
expect(profile?.sourceSessionId).toBe('match3d-session-draft');
|
||||
expect(profile?.publicationStatus).toBe('draft');
|
||||
expect(profile?.coverImageSrc).toBe('/reference.png');
|
||||
expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset);
|
||||
expect(profile?.backgroundImageSrc).toBe(
|
||||
'/generated/match3d/draft-background.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('Match3D runtime profile selects active profile by run profile id', () => {
|
||||
const runtimeProfile = buildProfile({
|
||||
profileId: 'runtime-profile',
|
||||
gameName: '运行态抓鹅',
|
||||
});
|
||||
const draftProfile = buildProfile({
|
||||
profileId: 'draft-profile',
|
||||
gameName: '旧草稿抓鹅',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
buildRun({ profileId: 'runtime-profile' }),
|
||||
runtimeProfile,
|
||||
draftProfile,
|
||||
),
|
||||
).toBe(runtimeProfile);
|
||||
expect(
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
buildRun({ profileId: 'draft-profile' }),
|
||||
runtimeProfile,
|
||||
draftProfile,
|
||||
),
|
||||
).toBe(draftProfile);
|
||||
});
|
||||
|
||||
test('Match3D runtime profile resolves generated assets from matching public detail', () => {
|
||||
const staleProfile = buildProfile({
|
||||
profileId: 'stale-profile',
|
||||
generatedBackgroundAsset: buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/stale-background.png',
|
||||
}),
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
itemId: 'stale-item',
|
||||
imageSrc: '/generated/match3d/stale-item.png',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const publicBackground = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/public-background.png',
|
||||
});
|
||||
const publicWork = buildPublicWork({
|
||||
profileId: 'public-profile',
|
||||
generatedBackgroundAsset: publicBackground,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
itemId: 'public-item',
|
||||
imageSrc: '/generated/match3d/public-item.png',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const run = buildRun({ profileId: 'public-profile' });
|
||||
|
||||
expect(
|
||||
resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some(
|
||||
(asset) => asset.imageSrc === '/generated/match3d/public-item.png',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork),
|
||||
).toEqual(publicBackground);
|
||||
expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe(
|
||||
'/generated/match3d/public-background.png',
|
||||
);
|
||||
});
|
||||
335
src/components/platform-entry/platformMatch3DRuntimeProfile.ts
Normal file
335
src/components/platform-entry/platformMatch3DRuntimeProfile.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
Match3DWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import {
|
||||
hasMatch3DGeneratedImageAsset,
|
||||
mergeMatch3DGeneratedItemAssetsForRuntime,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
isMatch3DGalleryEntry,
|
||||
mapMatch3DWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export function mapMatch3DWorkToPublicWorkDetail(
|
||||
item: Match3DWorkSummary,
|
||||
): PlatformPublicGalleryCard {
|
||||
return mapMatch3DWorkToPlatformGalleryCard(
|
||||
normalizeMatch3DWorkForRuntimeUi(item),
|
||||
);
|
||||
}
|
||||
|
||||
export function mapPublicWorkDetailToMatch3DWork(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): Match3DWorkSummary | null {
|
||||
if (!isMatch3DGalleryEntry(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: entry.workId,
|
||||
profileId: entry.profileId,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
sourceSessionId:
|
||||
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
|
||||
? entry.sourceSessionId
|
||||
: null,
|
||||
gameName: entry.worldName,
|
||||
themeText: entry.themeTags[0] ?? '经典消除',
|
||||
summary: entry.summaryText,
|
||||
tags: entry.themeTags,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: entry.playCount ?? 0,
|
||||
updatedAt: entry.updatedAt,
|
||||
publishedAt: entry.publishedAt,
|
||||
publishReady: true,
|
||||
backgroundPrompt: entry.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: entry.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset:
|
||||
entry.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ??
|
||||
null,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
entry.generatedItemAssets ?? [],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function findMatch3DGeneratedBackgroundAsset(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
): Match3DGeneratedBackgroundAsset | null {
|
||||
return (
|
||||
generatedItemAssets
|
||||
?.map((asset) => asset.backgroundAsset ?? null)
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function promoteMatch3DGeneratedBackgroundAsset<
|
||||
T extends Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundPrompt'
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
>(profile: T): T {
|
||||
const backgroundAsset =
|
||||
profile.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
|
||||
if (!backgroundAsset) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
backgroundPrompt:
|
||||
profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
|
||||
backgroundImageSrc:
|
||||
profile.backgroundImageSrc ??
|
||||
backgroundAsset.imageSrc ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
null,
|
||||
backgroundImageObjectKey:
|
||||
profile.backgroundImageObjectKey ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
backgroundAsset.imageSrc ??
|
||||
null,
|
||||
generatedBackgroundAsset:
|
||||
profile.generatedBackgroundAsset ?? backgroundAsset,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMatch3DWorkForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profile: T,
|
||||
): T {
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
...profile,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
profile.generatedItemAssets,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function mapMatch3DWorksForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profiles: readonly T[],
|
||||
): T[] {
|
||||
return profiles.map(normalizeMatch3DWorkForRuntimeUi);
|
||||
}
|
||||
|
||||
export function buildMatch3DProfileFromSession(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): Match3DWorkProfile | null {
|
||||
const draft = session?.draft;
|
||||
if (!session || !draft?.profileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
draft.generatedItemAssets,
|
||||
);
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
gameName: draft.gameName,
|
||||
themeText: draft.themeText,
|
||||
summary: draft.summary ?? draft.summaryText ?? '',
|
||||
tags: draft.tags,
|
||||
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
|
||||
referenceImageSrc: draft.referenceImageSrc ?? null,
|
||||
clearCount: draft.clearCount,
|
||||
difficulty: draft.difficulty,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
backgroundPrompt: draft.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasMatch3DRuntimeAsset(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
return hasMatch3DGeneratedImageAsset(assets);
|
||||
}
|
||||
|
||||
export function hasMatch3DRuntimeBackgroundAsset(
|
||||
profile: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
) {
|
||||
return Boolean(
|
||||
profile.backgroundImageSrc?.trim() ||
|
||||
profile.backgroundImageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
profile.generatedItemAssets?.some(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.imageSrc?.trim() ||
|
||||
asset.backgroundAsset?.imageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileAssets = profile?.generatedItemAssets ?? [];
|
||||
const publicDetailAssets =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (publicWorkDetail.generatedItemAssets ?? [])
|
||||
: [];
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
if (hasMatch3DRuntimeAsset(profileAssets)) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
if (
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return hasMatch3DRuntimeAsset(publicDetailAssets)
|
||||
? mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
publicDetailAssets,
|
||||
profileAssets,
|
||||
)
|
||||
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
|
||||
}
|
||||
|
||||
if (hasMatch3DRuntimeAsset(profileAssets)) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
return publicDetailAssets.length > 0
|
||||
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
|
||||
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeGeneratedBackgroundAsset(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileBackground = profile
|
||||
? (promoteMatch3DGeneratedBackgroundAsset(profile)
|
||||
.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground ?? profileBackground;
|
||||
}
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
|
||||
export function resolveActiveMatch3DRuntimeProfile(
|
||||
run: Match3DRunSnapshot | null,
|
||||
runtimeProfile: Match3DWorkProfile | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
|
||||
return runtimeProfile;
|
||||
}
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profile;
|
||||
}
|
||||
return runtimeProfile ?? profile;
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const resolvedProfile = profile
|
||||
? promoteMatch3DGeneratedBackgroundAsset(profile)
|
||||
: null;
|
||||
const resolvedPublicWork =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
: null;
|
||||
const profileBackground =
|
||||
resolvedProfile?.backgroundImageSrc?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
const publicBackground =
|
||||
resolvedPublicWork?.backgroundImageSrc?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground || profileBackground || null;
|
||||
}
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
import {
|
||||
createFailedMiniGameDraftGenerationStateForRestoredDraft,
|
||||
createMiniGameDraftGenerationStateForRestoredDraft,
|
||||
createPuzzleDraftGenerationStateFromPayload,
|
||||
isMiniGameDraftGenerating,
|
||||
isMiniGameDraftReady,
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState,
|
||||
mergePuzzleSessionProgressIntoGenerationState,
|
||||
rebaseMiniGameDraftBackgroundCompileTaskForDisplay,
|
||||
rebaseMiniGameDraftGenerationStateForDisplay,
|
||||
resolveFinishedMiniGameDraftGenerationState,
|
||||
resolvePuzzlePhaseFromSessionProgress,
|
||||
} from './platformMiniGameDraftGenerationStateModel';
|
||||
|
||||
const NOW = Date.parse('2026-06-04T03:00:00.000Z');
|
||||
const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z';
|
||||
const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT);
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 90,
|
||||
stage: 'draft_ready',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把星桥碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [],
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: SESSION_UPDATED_AT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildState(
|
||||
overrides: Partial<MiniGameDraftGenerationState> = {},
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: 100,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
metadata: {
|
||||
puzzleAiRedraw: true,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DAsset(
|
||||
overrides: Partial<Match3DGeneratedItemAsset> = {},
|
||||
): Match3DGeneratedItemAsset {
|
||||
return {
|
||||
itemId: 'item-1',
|
||||
itemName: '红宝石',
|
||||
status: 'pending',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('platformMiniGameDraftGenerationStateModel', () => {
|
||||
test('creates restored generation state with metadata and explicit start time', () => {
|
||||
expect(
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'match3d',
|
||||
{ puzzleAiRedraw: false },
|
||||
123,
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: 'match3d',
|
||||
phase: 'match3d-work-title',
|
||||
startedAtMs: 123,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('creates failed restored state from backend updated time', () => {
|
||||
expect(
|
||||
createFailedMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'puzzle',
|
||||
SESSION_UPDATED_AT,
|
||||
'生成失败',
|
||||
{ puzzleAiRedraw: true },
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'failed',
|
||||
startedAtMs: SESSION_UPDATED_AT_MS,
|
||||
finishedAtMs: NOW,
|
||||
error: '生成失败',
|
||||
metadata: {
|
||||
puzzleAiRedraw: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('rebases finished state for display without changing other fields', () => {
|
||||
const state = buildState({
|
||||
phase: 'ready',
|
||||
finishedAtMs: 300,
|
||||
completedAssetCount: 2,
|
||||
totalAssetCount: 3,
|
||||
});
|
||||
|
||||
expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
});
|
||||
expect(
|
||||
rebaseMiniGameDraftBackgroundCompileTaskForDisplay({
|
||||
sessionId: 'task-1',
|
||||
generationState: state,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'task-1',
|
||||
generationState: {
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('creates puzzle generation state from payload and compiled session', () => {
|
||||
const payload: CreatePuzzleAgentSessionRequest = {
|
||||
seedText: '星桥',
|
||||
aiRedraw: false,
|
||||
};
|
||||
|
||||
expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: NOW,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: undefined,
|
||||
puzzleActiveStepStartedAtMs: undefined,
|
||||
puzzleProgressPercent: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()),
|
||||
).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: SESSION_UPDATED_AT_MS,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: NOW,
|
||||
puzzleProgressPercent: 90,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves puzzle phase from backend progress thresholds', () => {
|
||||
const state = buildState();
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 96 }),
|
||||
),
|
||||
).toBe('puzzle-select-image');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 94 }),
|
||||
),
|
||||
).toBe('puzzle-ui-assets');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
buildState({ metadata: { puzzleAiRedraw: false } }),
|
||||
buildPuzzleSession({ progressPercent: 88 }),
|
||||
),
|
||||
).toBe('puzzle-level-scene');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 88 }),
|
||||
),
|
||||
).toBe('puzzle-cover-image');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 20 }),
|
||||
),
|
||||
).toBe('compile');
|
||||
});
|
||||
|
||||
test('merges compiled puzzle session progress into generation state', () => {
|
||||
expect(
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
buildState({
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
},
|
||||
}),
|
||||
buildPuzzleSession({ progressPercent: 90 }),
|
||||
),
|
||||
).toMatchObject({
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'puzzle-level-scene',
|
||||
puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS,
|
||||
puzzleProgressPercent: 90,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
buildState(),
|
||||
buildPuzzleSession({
|
||||
draft: {
|
||||
...buildPuzzleSession().draft!,
|
||||
formDraft: {
|
||||
pictureDescription: '星桥',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).metadata,
|
||||
).toMatchObject({
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test('merges match3d generated assets into active generation state', () => {
|
||||
const state = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'match3d-material-sheet',
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: '旧错误',
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(state, [
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-view',
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'front',
|
||||
viewIndex: 0,
|
||||
imageObjectKey: 'objects/front.png',
|
||||
},
|
||||
],
|
||||
}),
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-src',
|
||||
imageSrc: '/generated/item.png',
|
||||
}),
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-error',
|
||||
error: '切图失败',
|
||||
}),
|
||||
]),
|
||||
).toMatchObject({
|
||||
phase: 'match3d-generate-views',
|
||||
completedAssetCount: 2,
|
||||
totalAssetCount: 5,
|
||||
error: '切图失败',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps match3d generated asset merge away from finished states', () => {
|
||||
const readyState = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'ready',
|
||||
completedAssetCount: 5,
|
||||
totalAssetCount: 5,
|
||||
});
|
||||
const failedState = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'failed',
|
||||
error: '已失败',
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBe(readyState);
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBe(failedState);
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(null, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('finishes generation state and resolves ready/generating flags', () => {
|
||||
const failedState = resolveFinishedMiniGameDraftGenerationState(
|
||||
buildState({ error: '旧错误' }),
|
||||
'failed',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 2,
|
||||
},
|
||||
);
|
||||
|
||||
expect(failedState).toMatchObject({
|
||||
phase: 'failed',
|
||||
finishedAtMs: NOW,
|
||||
error: '旧错误',
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 2,
|
||||
});
|
||||
expect(isMiniGameDraftReady(failedState)).toBe(false);
|
||||
expect(isMiniGameDraftGenerating(failedState)).toBe(false);
|
||||
expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true);
|
||||
expect(isMiniGameDraftGenerating(buildState())).toBe(true);
|
||||
expect(isMiniGameDraftGenerating(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
createMiniGameDraftGenerationState,
|
||||
type MiniGameDraftGenerationKind,
|
||||
type MiniGameDraftGenerationPhase,
|
||||
type MiniGameDraftGenerationState,
|
||||
resolveMiniGameDraftGenerationStartedAtMs,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
|
||||
export function createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
startedAtMs = Date.now(),
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(kind, startedAtMs),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFailedMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
updatedAt: string | null | undefined,
|
||||
error: string,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
): MiniGameDraftGenerationState {
|
||||
return resolveFinishedMiniGameDraftGenerationState(
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind,
|
||||
metadata,
|
||||
resolveMiniGameDraftGenerationStartedAtMs(updatedAt),
|
||||
),
|
||||
'failed',
|
||||
{ error },
|
||||
);
|
||||
}
|
||||
|
||||
/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */
|
||||
export function rebaseMiniGameDraftGenerationStateForDisplay(
|
||||
state: MiniGameDraftGenerationState,
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function rebaseMiniGameDraftBackgroundCompileTaskForDisplay<
|
||||
T extends { generationState: MiniGameDraftGenerationState },
|
||||
>(task: T): T {
|
||||
return {
|
||||
...task,
|
||||
generationState: rebaseMiniGameDraftGenerationStateForDisplay(
|
||||
task.generationState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPuzzleDraftGenerationStateFromPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null | undefined,
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined = null,
|
||||
): MiniGameDraftGenerationState {
|
||||
const puzzleProgressPercent =
|
||||
session?.draft && !session.draft.formDraft
|
||||
? session.progressPercent
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(
|
||||
'puzzle',
|
||||
resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt),
|
||||
),
|
||||
metadata: {
|
||||
puzzleAiRedraw: payload?.aiRedraw ?? true,
|
||||
puzzleActivePhaseId:
|
||||
typeof puzzleProgressPercent === 'number' ? 'compile' : undefined,
|
||||
puzzleActiveStepStartedAtMs:
|
||||
typeof puzzleProgressPercent === 'number' ? Date.now() : undefined,
|
||||
puzzleProgressPercent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePuzzlePhaseFromSessionProgress(
|
||||
state: MiniGameDraftGenerationState,
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (session.progressPercent >= 96) {
|
||||
return 'puzzle-select-image';
|
||||
}
|
||||
if (session.progressPercent >= 94) {
|
||||
return 'puzzle-ui-assets';
|
||||
}
|
||||
if (session.progressPercent >= 88) {
|
||||
return state.metadata?.puzzleAiRedraw === false
|
||||
? 'puzzle-level-scene'
|
||||
: 'puzzle-cover-image';
|
||||
}
|
||||
|
||||
return 'compile';
|
||||
}
|
||||
|
||||
export function mergePuzzleSessionProgressIntoGenerationState(
|
||||
state: MiniGameDraftGenerationState,
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): MiniGameDraftGenerationState {
|
||||
const isCompiledGenerationSession = Boolean(
|
||||
session.draft && !session.draft.formDraft,
|
||||
);
|
||||
|
||||
const nextPhaseId = isCompiledGenerationSession
|
||||
? resolvePuzzlePhaseFromSessionProgress(state, session)
|
||||
: state.metadata?.puzzleActivePhaseId;
|
||||
const shouldResetActiveStepStart =
|
||||
isCompiledGenerationSession &&
|
||||
nextPhaseId != null &&
|
||||
nextPhaseId !== state.metadata?.puzzleActivePhaseId;
|
||||
|
||||
return {
|
||||
...state,
|
||||
metadata: {
|
||||
...state.metadata,
|
||||
puzzleActivePhaseId: nextPhaseId,
|
||||
puzzleActiveStepStartedAtMs: shouldResetActiveStepStart
|
||||
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
|
||||
: state.metadata?.puzzleActiveStepStartedAtMs,
|
||||
puzzleProgressPercent: isCompiledGenerationSession
|
||||
? session.progressPercent
|
||||
: state.metadata?.puzzleProgressPercent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeMatch3DGeneratedAssetsIntoGenerationState(
|
||||
current: MiniGameDraftGenerationState | null,
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
): MiniGameDraftGenerationState | null {
|
||||
if (!current || current.phase === 'ready' || current.phase === 'failed') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const assetList = assets ?? [];
|
||||
const imageReadyCount = assetList.filter(
|
||||
(asset) =>
|
||||
asset.imageViews?.some(
|
||||
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
|
||||
) ||
|
||||
asset.imageObjectKey?.trim() ||
|
||||
asset.imageSrc?.trim(),
|
||||
).length;
|
||||
const totalAssetCount = Math.max(5, assetList.length);
|
||||
const failedAsset = assetList.find((asset) => asset.error?.trim());
|
||||
|
||||
return {
|
||||
...current,
|
||||
phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase,
|
||||
completedAssetCount: imageReadyCount,
|
||||
totalAssetCount,
|
||||
error: failedAsset?.error?.trim() || current.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveFinishedMiniGameDraftGenerationState(
|
||||
state: MiniGameDraftGenerationState,
|
||||
phase: 'ready' | 'failed',
|
||||
options: {
|
||||
error?: string | null;
|
||||
completedAssetCount?: number;
|
||||
totalAssetCount?: number;
|
||||
} = {},
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...state,
|
||||
phase,
|
||||
finishedAtMs: Date.now(),
|
||||
error: options.error ?? state.error,
|
||||
completedAssetCount:
|
||||
options.completedAssetCount ?? state.completedAssetCount,
|
||||
totalAssetCount: options.totalAssetCount ?? state.totalAssetCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function isMiniGameDraftReady(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
) {
|
||||
return state?.phase === 'ready';
|
||||
}
|
||||
|
||||
export function isMiniGameDraftGenerating(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
) {
|
||||
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorPackResponse,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleDraftLevel,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildJumpHopDraftActionPayload,
|
||||
buildMatch3DFormPayloadFromSession,
|
||||
buildMatch3DFormPayloadFromWork,
|
||||
buildPendingMatch3DDraftMetadata,
|
||||
buildPendingPuzzleDraftMetadata,
|
||||
buildPuzzleCompileActionFromFormPayload,
|
||||
buildPuzzleFormPayloadFromAction,
|
||||
buildPuzzleFormPayloadFromSession,
|
||||
buildPuzzleFormPayloadFromWork,
|
||||
buildPuzzleWorkUpdatePayloadFromDraft,
|
||||
buildWoodenFishDraftActionPayload,
|
||||
isEmptyPuzzleFormOnlyDraft,
|
||||
isPuzzleFormOnlyDraft,
|
||||
} from './platformMiniGameDraftPayloadModel';
|
||||
|
||||
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleLevel(
|
||||
overrides: Partial<PuzzleDraftLevel> = {},
|
||||
): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '关卡画面描述',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: ' 星桥拼图 ',
|
||||
workDescription: ' 修复星桥机关。 ',
|
||||
levelName: '星桥机关',
|
||||
summary: '把碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: '/cover.png',
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
levels: [buildPuzzleLevel()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildPuzzleAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '种子描述',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '会话标题',
|
||||
workDescription: '会话描述',
|
||||
levelName: '星桥机关',
|
||||
summary: '会话摘要',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle',
|
||||
levels: [buildPuzzleLevel()],
|
||||
formDraft: {
|
||||
workTitle: '表单标题',
|
||||
workDescription: '表单描述',
|
||||
pictureDescription: '表单画面',
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DAnchorPack(
|
||||
overrides: Partial<Match3DAnchorPackResponse> = {},
|
||||
): Match3DAnchorPackResponse {
|
||||
return {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '海岛玩具',
|
||||
status: 'confirmed',
|
||||
},
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除次数',
|
||||
value: '12',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DSession(
|
||||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||||
): Match3DAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'match3d-session-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting',
|
||||
anchorPack: buildMatch3DAnchorPack(),
|
||||
config: null,
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '海岛抓大鹅',
|
||||
themeText: ' 海岛玩具 ',
|
||||
summary: '收集海岛玩具。',
|
||||
tags: ['海岛'],
|
||||
coverImageSrc: '/match3d-cover.png',
|
||||
referenceImageSrc: '/match3d-reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 3,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopDraft(
|
||||
overrides: Partial<NonNullable<JumpHopSessionSnapshotResponse['draft']>> = {},
|
||||
): NonNullable<JumpHopSessionSnapshotResponse['draft']> {
|
||||
return {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
workTitle: '草稿跳一跳',
|
||||
workDescription: '从草稿恢复。',
|
||||
themeTags: ['草稿'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '草稿角色',
|
||||
tilePrompt: '草稿平台',
|
||||
endMoodPrompt: '草稿终点',
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
generationStatus: 'draft',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopPayload(
|
||||
overrides: Partial<JumpHopWorkspaceCreateRequest> = {},
|
||||
): JumpHopWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '表单跳一跳',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
difficulty: 'advanced',
|
||||
stylePreset: 'neon-glass',
|
||||
characterPrompt: '表单角色',
|
||||
tilePrompt: '表单平台',
|
||||
endMoodPrompt: '表单终点',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishDraft(
|
||||
overrides: Partial<
|
||||
NonNullable<WoodenFishSessionSnapshotResponse['draft']>
|
||||
> = {},
|
||||
): NonNullable<WoodenFishSessionSnapshotResponse['draft']> {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '草稿木鱼',
|
||||
workDescription: '从草稿恢复。',
|
||||
themeTags: ['草稿'],
|
||||
hitObjectPrompt: '草稿敲击物',
|
||||
hitObjectReferenceImageSrc: '/draft-hit-ref.png',
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['草稿 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: null,
|
||||
generationStatus: 'draft',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishPayload(
|
||||
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
|
||||
): WoodenFishWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '表单木鱼',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
hitObjectPrompt: '表单敲击物',
|
||||
hitObjectReferenceImageSrc: '/form-hit-ref.png',
|
||||
hitSoundPrompt: null,
|
||||
hitSoundAsset: null,
|
||||
floatingWords: ['表单 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformMiniGameDraftPayloadModel', () => {
|
||||
test('builds puzzle form payload from work with fallback description priority', () => {
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromWork(
|
||||
buildPuzzleWork({
|
||||
workDescription: ' ',
|
||||
summary: ' 摘要描述 ',
|
||||
levelName: ' 关卡标题 ',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '摘要描述',
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '摘要描述',
|
||||
pictureDescription: '摘要描述',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle work update payload from result draft', () => {
|
||||
const draft = buildPuzzleSession().draft!;
|
||||
|
||||
expect(buildPuzzleWorkUpdatePayloadFromDraft(draft)).toEqual({
|
||||
workTitle: '会话标题',
|
||||
workDescription: '会话描述',
|
||||
levelName: '星桥机关',
|
||||
summary: '会话摘要',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
levels: [buildPuzzleLevel()],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPuzzleWorkUpdatePayloadFromDraft({
|
||||
...draft,
|
||||
levels: undefined,
|
||||
}).levels,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('builds jump hop draft action payload from payload or draft', () => {
|
||||
expect(
|
||||
buildJumpHopDraftActionPayload('compile-draft', {
|
||||
payload: buildJumpHopPayload(),
|
||||
draft: buildJumpHopDraft(),
|
||||
}),
|
||||
).toEqual({
|
||||
actionType: 'compile-draft',
|
||||
workTitle: '表单跳一跳',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
difficulty: 'advanced',
|
||||
stylePreset: 'neon-glass',
|
||||
characterPrompt: '表单角色',
|
||||
tilePrompt: '表单平台',
|
||||
endMoodPrompt: '表单终点',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildJumpHopDraftActionPayload('regenerate-tiles', {
|
||||
draft: buildJumpHopDraft(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
actionType: 'regenerate-tiles',
|
||||
workTitle: '草稿跳一跳',
|
||||
tilePrompt: '草稿平台',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish draft action payload from payload or draft', () => {
|
||||
expect(
|
||||
buildWoodenFishDraftActionPayload('compile-draft', {
|
||||
payload: buildWoodenFishPayload(),
|
||||
draft: buildWoodenFishDraft(),
|
||||
}),
|
||||
).toEqual({
|
||||
actionType: 'compile-draft',
|
||||
workTitle: '表单木鱼',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
hitObjectPrompt: '表单敲击物',
|
||||
hitObjectReferenceImageSrc: '/form-hit-ref.png',
|
||||
hitSoundAsset: null,
|
||||
floatingWords: ['表单 +1'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishDraftActionPayload('regenerate-hit-object', {
|
||||
draft: buildWoodenFishDraft(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
actionType: 'regenerate-hit-object',
|
||||
workTitle: '草稿木鱼',
|
||||
hitObjectPrompt: '草稿敲击物',
|
||||
floatingWords: ['草稿 +1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle form payload from session form draft and fallbacks', () => {
|
||||
expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({
|
||||
seedText: '表单画面',
|
||||
workTitle: '表单标题',
|
||||
workDescription: '表单描述',
|
||||
pictureDescription: '表单画面',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: {
|
||||
...buildPuzzleSession().draft!,
|
||||
formDraft: null,
|
||||
levels: [buildPuzzleLevel({ pictureDescription: '关卡优先' })],
|
||||
},
|
||||
}),
|
||||
).pictureDescription,
|
||||
).toBe('关卡优先');
|
||||
});
|
||||
|
||||
test('resolves puzzle form-only draft state for empty and filled forms', () => {
|
||||
const baseDraft = buildPuzzleSession().draft!;
|
||||
const emptySession = buildPuzzleSession({
|
||||
seedText: ' ',
|
||||
draft: {
|
||||
...baseDraft,
|
||||
formDraft: {
|
||||
workTitle: ' ',
|
||||
workDescription: ' ',
|
||||
pictureDescription: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(isPuzzleFormOnlyDraft(emptySession)).toBe(true);
|
||||
expect(isEmptyPuzzleFormOnlyDraft(emptySession)).toBe(true);
|
||||
expect(isPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(true);
|
||||
expect(isEmptyPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(false);
|
||||
expect(
|
||||
isPuzzleFormOnlyDraft(buildPuzzleSession({ stage: 'ready_to_publish' })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('builds puzzle compile action and restores form payload from action', () => {
|
||||
const payload: CreatePuzzleAgentSessionRequest = {
|
||||
seedText: '种子',
|
||||
workTitle: ' 标题 ',
|
||||
workDescription: '',
|
||||
pictureDescription: ' 画面 ',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
};
|
||||
const action = buildPuzzleCompileActionFromFormPayload(payload);
|
||||
|
||||
expect(action).toEqual({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: '画面',
|
||||
workTitle: '标题',
|
||||
workDescription: '画面',
|
||||
pictureDescription: '画面',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
candidateCount: 1,
|
||||
});
|
||||
expect(buildPuzzleFormPayloadFromAction(action)).toEqual({
|
||||
seedText: '画面',
|
||||
workTitle: '标题',
|
||||
workDescription: '画面',
|
||||
pictureDescription: '画面',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
});
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromAction({
|
||||
action: 'publish_puzzle_work',
|
||||
} as PuzzleAgentActionRequest),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds pending puzzle metadata from non-empty payload fields', () => {
|
||||
expect(
|
||||
buildPendingPuzzleDraftMetadata({
|
||||
workTitle: ' 标题 ',
|
||||
workDescription: ' ',
|
||||
pictureDescription: ' 画面 ',
|
||||
seedText: '种子',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '标题',
|
||||
summary: '画面',
|
||||
});
|
||||
expect(buildPendingPuzzleDraftMetadata(null)).toEqual({});
|
||||
});
|
||||
|
||||
test('builds match3d form payload from session config, draft and anchors', () => {
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromSession(
|
||||
buildMatch3DSession({
|
||||
config: {
|
||||
themeText: ' 配置主题 ',
|
||||
referenceImageSrc: '/config-ref.png',
|
||||
clearCount: 9,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'style-1',
|
||||
assetStyleLabel: '手办',
|
||||
assetStylePrompt: '软陶手办',
|
||||
generateClickSound: true,
|
||||
},
|
||||
draft: {
|
||||
profileId: 'profile-1',
|
||||
gameName: '草稿标题',
|
||||
themeText: '草稿主题',
|
||||
tags: [],
|
||||
referenceImageSrc: '/draft-ref.png',
|
||||
clearCount: 6,
|
||||
difficulty: 2,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '配置主题',
|
||||
themeText: '配置主题',
|
||||
referenceImageSrc: '/config-ref.png',
|
||||
clearCount: 9,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'style-1',
|
||||
assetStyleLabel: '手办',
|
||||
assetStylePrompt: '软陶手办',
|
||||
generateClickSound: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromSession(
|
||||
buildMatch3DSession({
|
||||
anchorPack: buildMatch3DAnchorPack({
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除次数',
|
||||
value: 'not-number',
|
||||
status: 'confirmed',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
seedText: '海岛玩具',
|
||||
clearCount: undefined,
|
||||
difficulty: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('builds match3d form payload from work and pending metadata', () => {
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromWork(
|
||||
buildMatch3DWork({
|
||||
themeText: ' ',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '海岛抓大鹅',
|
||||
themeText: '海岛抓大鹅',
|
||||
referenceImageSrc: '/match3d-reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPendingMatch3DDraftMetadata({
|
||||
themeText: ' ',
|
||||
seedText: ' 海岛抓大鹅 ',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '海岛抓大鹅',
|
||||
summary: '海岛抓大鹅',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
JumpHopActionRequest,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleDraftLevel,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
WoodenFishActionRequest,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
|
||||
export type PuzzleWorkUpdatePayload = {
|
||||
workTitle?: string;
|
||||
workDescription?: string;
|
||||
levelName: string;
|
||||
summary: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc?: string | null;
|
||||
coverAssetId?: string | null;
|
||||
levels: PuzzleDraftLevel[];
|
||||
};
|
||||
|
||||
export function buildPuzzleFormPayloadFromWork(
|
||||
item: PuzzleWorkSummary,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const pictureDescription =
|
||||
item.workDescription?.trim() ||
|
||||
item.summary?.trim() ||
|
||||
item.levels?.[0]?.pictureDescription?.trim() ||
|
||||
item.levelName?.trim() ||
|
||||
item.workTitle?.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined,
|
||||
workDescription: item.workDescription?.trim() || item.summary?.trim(),
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleWorkUpdatePayloadFromDraft(
|
||||
draft: PuzzleResultDraft,
|
||||
): PuzzleWorkUpdatePayload {
|
||||
return {
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
levels: draft.levels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopDraftActionPayload(
|
||||
actionType: 'compile-draft' | 'regenerate-character' | 'regenerate-tiles',
|
||||
input: {
|
||||
payload?: JumpHopWorkspaceCreateRequest | null;
|
||||
draft?: JumpHopSessionSnapshotResponse['draft'] | null;
|
||||
},
|
||||
): JumpHopActionRequest {
|
||||
const { payload, draft } = input;
|
||||
return {
|
||||
actionType,
|
||||
workTitle: payload?.workTitle ?? draft?.workTitle,
|
||||
workDescription: payload?.workDescription ?? draft?.workDescription,
|
||||
themeTags: payload?.themeTags ?? draft?.themeTags,
|
||||
difficulty: payload?.difficulty ?? draft?.difficulty,
|
||||
stylePreset: payload?.stylePreset ?? draft?.stylePreset,
|
||||
characterPrompt: payload?.characterPrompt ?? draft?.characterPrompt,
|
||||
tilePrompt: payload?.tilePrompt ?? draft?.tilePrompt,
|
||||
endMoodPrompt: payload?.endMoodPrompt ?? draft?.endMoodPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishDraftActionPayload(
|
||||
actionType: 'compile-draft' | 'regenerate-hit-object',
|
||||
input: {
|
||||
payload?: WoodenFishWorkspaceCreateRequest | null;
|
||||
draft?: WoodenFishSessionSnapshotResponse['draft'] | null;
|
||||
},
|
||||
): WoodenFishActionRequest {
|
||||
const { payload, draft } = input;
|
||||
return {
|
||||
actionType,
|
||||
workTitle: payload?.workTitle ?? draft?.workTitle,
|
||||
workDescription: payload?.workDescription ?? draft?.workDescription,
|
||||
themeTags: payload?.themeTags ?? draft?.themeTags,
|
||||
hitObjectPrompt: payload?.hitObjectPrompt ?? draft?.hitObjectPrompt,
|
||||
hitObjectReferenceImageSrc:
|
||||
payload?.hitObjectReferenceImageSrc ??
|
||||
draft?.hitObjectReferenceImageSrc,
|
||||
hitSoundAsset: payload?.hitSoundAsset ?? draft?.hitSoundAsset,
|
||||
floatingWords: payload?.floatingWords ?? draft?.floatingWords,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalFiniteNumber(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
const normalizedValue = value?.trim();
|
||||
if (!normalizedValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsedValue = Number(normalizedValue);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : undefined;
|
||||
}
|
||||
|
||||
export function buildMatch3DFormPayloadFromSession(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
): CreateMatch3DSessionRequest {
|
||||
const themeText =
|
||||
session.config?.themeText?.trim() ||
|
||||
session.draft?.themeText?.trim() ||
|
||||
session.anchorPack.theme.value.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: themeText,
|
||||
themeText,
|
||||
referenceImageSrc:
|
||||
session.config?.referenceImageSrc ??
|
||||
session.draft?.referenceImageSrc ??
|
||||
null,
|
||||
clearCount:
|
||||
session.config?.clearCount ??
|
||||
session.draft?.clearCount ??
|
||||
parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ??
|
||||
undefined,
|
||||
difficulty:
|
||||
session.config?.difficulty ??
|
||||
session.draft?.difficulty ??
|
||||
parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ??
|
||||
undefined,
|
||||
assetStyleId: session.config?.assetStyleId ?? null,
|
||||
assetStyleLabel: session.config?.assetStyleLabel ?? null,
|
||||
assetStylePrompt: session.config?.assetStylePrompt ?? null,
|
||||
generateClickSound: session.config?.generateClickSound,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DFormPayloadFromWork(
|
||||
item: Match3DWorkSummary,
|
||||
): CreateMatch3DSessionRequest {
|
||||
const themeText = item.themeText?.trim() || item.gameName?.trim() || '';
|
||||
return {
|
||||
seedText: themeText,
|
||||
themeText,
|
||||
referenceImageSrc: item.referenceImageSrc ?? null,
|
||||
clearCount: item.clearCount,
|
||||
difficulty: item.difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleCompileActionFromFormPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null,
|
||||
): PuzzleAgentActionRequest {
|
||||
const pictureDescription =
|
||||
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
|
||||
const workTitle = payload?.workTitle?.trim();
|
||||
const workDescription = payload?.workDescription?.trim() || pictureDescription;
|
||||
|
||||
return {
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
aiRedraw: payload?.aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleFormPayloadFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const formDraft = session.draft?.formDraft;
|
||||
const pictureDescription =
|
||||
formDraft?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
||||
session.anchorPack.visualSubject.value.trim() ||
|
||||
session.seedText?.trim() ||
|
||||
'';
|
||||
const workTitle =
|
||||
formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim();
|
||||
const workDescription =
|
||||
formDraft?.workDescription?.trim() ||
|
||||
session.draft?.workDescription?.trim() ||
|
||||
session.draft?.summary?.trim() ||
|
||||
pictureDescription;
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPuzzleFormOnlyDraft(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
return Boolean(
|
||||
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmptyPuzzleFormOnlyDraft(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
if (!isPuzzleFormOnlyDraft(session)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const formDraft = session?.draft?.formDraft;
|
||||
return !(
|
||||
session?.seedText?.trim() ||
|
||||
formDraft?.workTitle?.trim() ||
|
||||
formDraft?.workDescription?.trim() ||
|
||||
formDraft?.pictureDescription?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPendingPuzzleDraftMetadata(
|
||||
payload: CreatePuzzleAgentSessionRequest | null | undefined,
|
||||
) {
|
||||
const title = payload?.workTitle?.trim();
|
||||
const summary =
|
||||
payload?.workDescription?.trim() ||
|
||||
payload?.pictureDescription?.trim() ||
|
||||
payload?.seedText?.trim();
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPendingMatch3DDraftMetadata(
|
||||
payload: CreateMatch3DSessionRequest | null | undefined,
|
||||
) {
|
||||
const themeText = payload?.themeText?.trim() || payload?.seedText?.trim();
|
||||
return {
|
||||
...(themeText ? { title: themeText, summary: themeText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleFormPayloadFromAction(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
): CreatePuzzleAgentSessionRequest | null {
|
||||
if (
|
||||
payload.action !== 'compile_puzzle_draft' &&
|
||||
payload.action !== 'save_puzzle_form_draft'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workTitle = payload.workTitle?.trim() ?? '';
|
||||
const workDescription = payload.workDescription?.trim() ?? '';
|
||||
const pictureDescription =
|
||||
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.referenceImageSrc ?? null)
|
||||
: (payload.referenceImageSrc ?? null),
|
||||
referenceImageSrcs: payload.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
|
||||
imageModel:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.imageModel ?? null)
|
||||
: (payload.imageModel ?? null),
|
||||
aiRedraw:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.aiRedraw ?? true)
|
||||
: (payload.aiRedraw ?? true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
SquareHoleResultDraft,
|
||||
SquareHoleSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type {
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelWorkDetail,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
WoodenFishAudioAsset,
|
||||
WoodenFishImageAsset,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildJumpHopPendingSession,
|
||||
buildPuzzleRuntimeWorkFromSession,
|
||||
buildSquareHoleProfileFromSession,
|
||||
buildVisualNovelSessionFromWorkDetail,
|
||||
buildWoodenFishGeneratingWorkSummary,
|
||||
buildWoodenFishPendingSession,
|
||||
buildWoodenFishSessionFromWorkDetail,
|
||||
} from './platformMiniGameSessionMappingModel';
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleDraft(
|
||||
overrides: Partial<PuzzleResultDraft> = {},
|
||||
): PuzzleResultDraft {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把星桥碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
coverAssetId: 'asset-cover',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/puzzle-level-cover.png',
|
||||
coverAssetId: 'asset-level-cover',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = buildPuzzleDraft();
|
||||
return {
|
||||
sessionId: 'puzzle-session-12345678',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: draft.anchorPack,
|
||||
draft,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: {
|
||||
draft,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
publishReady: true,
|
||||
},
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopSummary(
|
||||
overrides: Partial<JumpHopWorkSummaryResponse> = {},
|
||||
): JumpHopWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: ' jump-hop-session-1 ',
|
||||
workTitle: '云阶跳跃',
|
||||
workDescription: '越过云阶。',
|
||||
themeTags: ['云阶'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: '/jump-hop-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleDraft(
|
||||
overrides: Partial<SquareHoleResultDraft> = {},
|
||||
): SquareHoleResultDraft {
|
||||
return {
|
||||
profileId: 'square-hole-profile-1',
|
||||
gameName: '星桥方洞',
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
summary: '把星桥机关里的形状送入正确孔洞。',
|
||||
tags: ['星桥', '机关'],
|
||||
coverImageSrc: '/square-hole-cover.png',
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
backgroundImageSrc: '/square-hole-background.png',
|
||||
shapeOptions: [
|
||||
{
|
||||
optionId: 'shape-1',
|
||||
shapeKind: 'star',
|
||||
label: '星形',
|
||||
targetHoleId: 'hole-1',
|
||||
imagePrompt: '星形积木',
|
||||
imageSrc: '/shape-star.png',
|
||||
},
|
||||
],
|
||||
holeOptions: [
|
||||
{
|
||||
holeId: 'hole-1',
|
||||
holeKind: 'star-hole',
|
||||
label: '星形洞',
|
||||
imagePrompt: '星形洞口',
|
||||
imageSrc: '/hole-star.png',
|
||||
},
|
||||
],
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleSession(
|
||||
overrides: Partial<SquareHoleSessionSnapshot> = {},
|
||||
): SquareHoleSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'square-hole-session-1',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed',
|
||||
},
|
||||
twistRule: {
|
||||
key: 'twistRule',
|
||||
label: '扭转规则',
|
||||
value: '只允许相同颜色形状入洞',
|
||||
status: 'confirmed',
|
||||
},
|
||||
shapeCount: {
|
||||
key: 'shapeCount',
|
||||
label: '形状数量',
|
||||
value: '6',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
coverImageSrc: null,
|
||||
backgroundImageSrc: null,
|
||||
},
|
||||
draft: buildSquareHoleDraft(),
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-06-01T12:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelDraft(
|
||||
overrides: Partial<VisualNovelResultDraft> = {},
|
||||
): VisualNovelResultDraft {
|
||||
return {
|
||||
profileId: 'visual-novel-profile-1',
|
||||
workTitle: '雪线电台',
|
||||
workDescription: '旧电台牵出雪夜列车谜案。',
|
||||
workTags: ['雪夜', '电台'],
|
||||
coverImageSrc: '/visual-novel-cover.png',
|
||||
sourceMode: 'idea',
|
||||
sourceAssetIds: ['asset-source-1'],
|
||||
world: {
|
||||
title: '北境终点线',
|
||||
summary: '边境小城与旧电台。',
|
||||
background: '十二年前的雪崩留下夜间广播。',
|
||||
premise: '玩家需要在日出前找出列车停摆的原因。',
|
||||
literaryStyle: '克制冷光感。',
|
||||
playerRole: '临时广播员',
|
||||
defaultTone: '安静紧张',
|
||||
},
|
||||
characters: [
|
||||
{
|
||||
characterId: 'vn-char-1',
|
||||
name: '林遥',
|
||||
gender: '女',
|
||||
role: 'main',
|
||||
appearance: '灰色长外套。',
|
||||
personality: '谨慎敏锐。',
|
||||
tone: '短句多。',
|
||||
background: '旧电台夜班实习生。',
|
||||
relationshipToPlayer: '临时搭档',
|
||||
imageAssets: [],
|
||||
defaultExpression: 'calm',
|
||||
isPlayerVisible: false,
|
||||
},
|
||||
],
|
||||
scenes: [
|
||||
{
|
||||
sceneId: 'vn-scene-1',
|
||||
name: '风雪站台',
|
||||
description: '站灯忽明忽暗。',
|
||||
backgroundImageSrc: null,
|
||||
musicSrc: null,
|
||||
ambientSoundSrc: null,
|
||||
availability: 'opening',
|
||||
phaseIds: ['vn-phase-1'],
|
||||
},
|
||||
],
|
||||
storyPhases: [
|
||||
{
|
||||
phaseId: 'vn-phase-1',
|
||||
title: '重启站台',
|
||||
goal: '确认列车为何停在废弃站台。',
|
||||
summary: '玩家抵达风雪站台。',
|
||||
entryCondition: '开场进入',
|
||||
exitCondition: '找到车长日志',
|
||||
sceneIds: ['vn-scene-1'],
|
||||
characterIds: ['vn-char-1'],
|
||||
suggestedChoices: ['检查广播柜'],
|
||||
},
|
||||
],
|
||||
opening: {
|
||||
sceneId: 'vn-scene-1',
|
||||
narration: '雪落得很慢。',
|
||||
speakerCharacterId: 'vn-char-1',
|
||||
firstDialogue: '你听见了吗?',
|
||||
initialChoices: [
|
||||
{
|
||||
choiceId: 'vn-choice-1',
|
||||
text: '靠近广播柜。',
|
||||
actionHint: 'inspect_radio',
|
||||
},
|
||||
],
|
||||
},
|
||||
runtimeConfig: {
|
||||
textModeEnabled: true,
|
||||
defaultTextMode: false,
|
||||
maxHistoryEntries: 80,
|
||||
maxAssistantStepCountPerTurn: 8,
|
||||
allowFreeTextAction: true,
|
||||
allowHistoryRegeneration: true,
|
||||
attributePanelMode: 'template_config',
|
||||
saveArchiveEnabled: true,
|
||||
},
|
||||
publishReady: true,
|
||||
validationIssues: [],
|
||||
updatedAt: '2026-06-01T13:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWorkDetail(
|
||||
overrides: Partial<VisualNovelWorkDetail> = {},
|
||||
): VisualNovelWorkDetail {
|
||||
const draft = buildVisualNovelDraft();
|
||||
return {
|
||||
workId: 'visual-novel-work-1',
|
||||
summary: {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-1',
|
||||
ownerUserId: 'user-visual-novel-1',
|
||||
title: draft.workTitle,
|
||||
description: draft.workDescription,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
tags: draft.workTags,
|
||||
publishStatus: 'draft',
|
||||
publishReady: draft.publishReady,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T13:30:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
sourceSessionId: ' visual-novel-session-1 ',
|
||||
authorDisplayName: '视觉小说作者',
|
||||
sourceAssetIds: draft.sourceAssetIds,
|
||||
draft,
|
||||
createdAt: '2026-06-01T12:50:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const woodenFishImageAsset: WoodenFishImageAsset = {
|
||||
assetId: 'asset-hit',
|
||||
imageSrc: '/hit.png',
|
||||
imageObjectKey: 'hit.png',
|
||||
assetObjectId: 'asset-object-hit',
|
||||
generationProvider: 'test',
|
||||
prompt: '木鱼',
|
||||
width: 512,
|
||||
height: 512,
|
||||
};
|
||||
|
||||
const woodenFishAudioAsset: WoodenFishAudioAsset = {
|
||||
assetId: 'asset-sound',
|
||||
audioSrc: '/hit.mp3',
|
||||
audioObjectKey: 'hit.mp3',
|
||||
assetObjectId: 'asset-object-sound',
|
||||
source: 'test',
|
||||
};
|
||||
|
||||
function buildWoodenFishSummary(
|
||||
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: ' wooden-fish-session-1 ',
|
||||
workTitle: '星灯木鱼',
|
||||
workDescription: '敲亮星灯。',
|
||||
themeTags: ['星灯'],
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishWorkProfile(
|
||||
overrides: Partial<WoodenFishWorkProfileResponse> = {},
|
||||
): WoodenFishWorkProfileResponse {
|
||||
const summary = buildWoodenFishSummary();
|
||||
const draft = {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: summary.profileId,
|
||||
workTitle: summary.workTitle,
|
||||
workDescription: summary.workDescription,
|
||||
themeTags: summary.themeTags,
|
||||
hitObjectPrompt: '星灯',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: woodenFishImageAsset,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: woodenFishAudioAsset,
|
||||
coverImageSrc: summary.coverImageSrc,
|
||||
generationStatus: summary.generationStatus,
|
||||
};
|
||||
return {
|
||||
summary,
|
||||
draft,
|
||||
hitObjectAsset: woodenFishImageAsset,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: woodenFishAudioAsset,
|
||||
floatingWords: ['功德 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishSession(
|
||||
overrides: Partial<WoodenFishSessionSnapshotResponse> = {},
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const summary = buildWoodenFishSummary();
|
||||
return {
|
||||
sessionId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: buildWoodenFishWorkProfile({ summary }).draft,
|
||||
createdAt: '2026-06-01T11:59:00.000Z',
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishCreatePayload(
|
||||
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
|
||||
): WoodenFishWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '表单星灯木鱼',
|
||||
workDescription: '表单里敲亮星灯。',
|
||||
themeTags: ['表单星灯'],
|
||||
hitObjectPrompt: '星灯',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformMiniGameSessionMappingModel', () => {
|
||||
test('builds a draft puzzle runtime work from a session', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), {
|
||||
userId: 'user-1',
|
||||
displayName: '玩家一号',
|
||||
}),
|
||||
).toMatchObject({
|
||||
workId: 'puzzle-work-12345678',
|
||||
profileId: 'puzzle-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-12345678',
|
||||
authorDisplayName: '玩家一号',
|
||||
workTitle: '星桥拼图',
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('prefers published puzzle profile id when present', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
publishedProfileId: 'published-puzzle-profile',
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
profileId: 'published-puzzle-profile',
|
||||
workId: 'puzzle-work-12345678',
|
||||
ownerUserId: 'current-user',
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for puzzle runtime work without draft or cover', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: null,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: buildPuzzleDraft({ coverImageSrc: ' ' }),
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds jump hop pending session from work summary', () => {
|
||||
expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({
|
||||
sessionId: 'jump-hop-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
workTitle: '云阶跳跃',
|
||||
workDescription: '越过云阶。',
|
||||
themeTags: ['云阶'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: '/jump-hop-cover.png',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
createdAt: '2026-06-01T11:00:00.000Z',
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds square hole draft profile from session', () => {
|
||||
expect(buildSquareHoleProfileFromSession(buildSquareHoleSession())).toEqual({
|
||||
workId: 'square-hole-profile-1',
|
||||
profileId: 'square-hole-profile-1',
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: 'square-hole-session-1',
|
||||
gameName: '星桥方洞',
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
summary: '把星桥机关里的形状送入正确孔洞。',
|
||||
tags: ['星桥', '机关'],
|
||||
coverImageSrc: '/square-hole-cover.png',
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
backgroundImageSrc: '/square-hole-background.png',
|
||||
shapeOptions: buildSquareHoleDraft().shapeOptions,
|
||||
holeOptions: buildSquareHoleDraft().holeOptions,
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:30:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for square hole profile without session draft or profile id', () => {
|
||||
expect(buildSquareHoleProfileFromSession(null)).toBeNull();
|
||||
expect(
|
||||
buildSquareHoleProfileFromSession(
|
||||
buildSquareHoleSession({
|
||||
draft: null,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildSquareHoleProfileFromSession(
|
||||
buildSquareHoleSession({
|
||||
draft: buildSquareHoleDraft({ profileId: '' }),
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds visual novel recovered session from work detail', () => {
|
||||
const work = buildVisualNovelWorkDetail();
|
||||
|
||||
expect(buildVisualNovelSessionFromWorkDetail(work)).toEqual({
|
||||
sessionId: 'visual-novel-session-1',
|
||||
ownerUserId: 'user-visual-novel-1',
|
||||
sourceMode: 'idea',
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
draft: work.draft,
|
||||
pendingAction: null,
|
||||
createdAt: '2026-06-01T12:50:00.000Z',
|
||||
updatedAt: '2026-06-01T13:30:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back visual novel recovered session id to work id', () => {
|
||||
expect(
|
||||
buildVisualNovelSessionFromWorkDetail(
|
||||
buildVisualNovelWorkDetail({
|
||||
sourceSessionId: ' ',
|
||||
workId: 'visual-novel-work-fallback',
|
||||
}),
|
||||
).sessionId,
|
||||
).toBe('visual-novel-work-fallback');
|
||||
});
|
||||
|
||||
test('builds wooden fish pending session from work summary', () => {
|
||||
expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({
|
||||
sessionId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '星灯木鱼',
|
||||
workDescription: '敲亮星灯。',
|
||||
themeTags: ['星灯'],
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
createdAt: '2026-06-01T12:00:00.000Z',
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish generating work summary from session and payload', () => {
|
||||
expect(
|
||||
buildWoodenFishGeneratingWorkSummary(
|
||||
buildWoodenFishSession(),
|
||||
buildWoodenFishCreatePayload(),
|
||||
),
|
||||
).toEqual({
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-session-1',
|
||||
profileId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-1',
|
||||
workTitle: '表单星灯木鱼',
|
||||
workDescription: '表单里敲亮星灯。',
|
||||
themeTags: ['表单星灯'],
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishGeneratingWorkSummary(
|
||||
buildWoodenFishSession({
|
||||
draft: null,
|
||||
createdAt: '2026-06-01T11:59:00.000Z',
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toMatchObject({
|
||||
workTitle: '敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: ['敲木鱼'],
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish recovered session with summary, fallback and profile id priority', () => {
|
||||
expect(
|
||||
buildWoodenFishSessionFromWorkDetail(
|
||||
buildWoodenFishWorkProfile({
|
||||
summary: buildWoodenFishSummary({
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
}),
|
||||
buildWoodenFishSummary({
|
||||
sourceSessionId: ' fallback-session ',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
sessionId: 'fallback-session',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishSessionFromWorkDetail(
|
||||
buildWoodenFishWorkProfile({
|
||||
summary: buildWoodenFishSummary({
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
).sessionId,
|
||||
).toBe('wooden-fish-profile-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { JumpHopSessionSnapshotResponse, JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type {
|
||||
VisualNovelAgentSessionSnapshot,
|
||||
VisualNovelWorkDetail,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { normalizeCreationUrlValue } from './platformCreationUrlStateModel';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
export type PlatformMiniGameSessionOwner = {
|
||||
userId?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
export function buildPuzzleRuntimeWorkFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
owner: PlatformMiniGameSessionOwner,
|
||||
): PuzzleWorkSummary | null {
|
||||
const draft = session.draft;
|
||||
const profileId =
|
||||
session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId);
|
||||
if (!draft || !profileId || !draft.coverImageSrc?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId,
|
||||
profileId,
|
||||
ownerUserId: owner.userId ?? 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
authorDisplayName: owner.displayName ?? '玩家',
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: session.updatedAt,
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: Boolean(session.resultPreview?.publishReady),
|
||||
levels: draft.levels,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSquareHoleProfileFromSession(
|
||||
session: SquareHoleSessionSnapshot | null,
|
||||
): SquareHoleWorkProfile | null {
|
||||
const draft = session?.draft;
|
||||
if (!session || !draft?.profileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
return {
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
gameName: draft.gameName,
|
||||
themeText: draft.themeText,
|
||||
twistRule: draft.twistRule,
|
||||
summary: draft.summary,
|
||||
tags: draft.tags,
|
||||
coverImageSrc: draft.coverImageSrc ?? null,
|
||||
backgroundPrompt: draft.backgroundPrompt,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
shapeOptions: draft.shapeOptions,
|
||||
holeOptions: draft.holeOptions,
|
||||
shapeCount: draft.shapeCount,
|
||||
difficulty: draft.difficulty,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVisualNovelSessionFromWorkDetail(
|
||||
work: VisualNovelWorkDetail,
|
||||
): VisualNovelAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: normalizeCreationUrlValue(work.sourceSessionId) ?? work.workId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
sourceMode: work.draft.sourceMode,
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
draft: work.draft,
|
||||
pendingAction: null,
|
||||
createdAt: work.createdAt,
|
||||
updatedAt: work.summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopPendingSession(
|
||||
item: JumpHopWorkSummaryResponse,
|
||||
): JumpHopSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: item.ownerUserId,
|
||||
status: item.generationStatus,
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: item.profileId,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themeTags: item.themeTags,
|
||||
difficulty: item.difficulty,
|
||||
stylePreset: item.stylePreset,
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: item.coverImageSrc,
|
||||
generationStatus: item.generationStatus,
|
||||
},
|
||||
createdAt: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishSessionFromWorkDetail(
|
||||
work: WoodenFishWorkProfileResponse,
|
||||
fallbackItem?: WoodenFishWorkSummaryResponse | null,
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
|
||||
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
|
||||
work.summary.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
status: work.summary.generationStatus,
|
||||
draft: work.draft,
|
||||
createdAt: work.summary.updatedAt,
|
||||
updatedAt: work.summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishGeneratingWorkSummary(
|
||||
session: WoodenFishSessionSnapshotResponse,
|
||||
payload?: WoodenFishWorkspaceCreateRequest | null,
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
const updatedAt = session.updatedAt ?? session.createdAt;
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: session.sessionId,
|
||||
profileId: session.sessionId,
|
||||
ownerUserId: session.ownerUserId,
|
||||
sourceSessionId: session.sessionId,
|
||||
workTitle: payload?.workTitle ?? session.draft?.workTitle ?? '敲木鱼',
|
||||
workDescription:
|
||||
payload?.workDescription ?? session.draft?.workDescription ?? '',
|
||||
themeTags: payload?.themeTags ?? session.draft?.themeTags ?? ['敲木鱼'],
|
||||
coverImageSrc: session.draft?.coverImageSrc ?? null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt,
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishPendingSession(
|
||||
item: WoodenFishWorkSummaryResponse,
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: item.ownerUserId,
|
||||
status: item.generationStatus,
|
||||
draft: {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: item.profileId,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themeTags: item.themeTags,
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
generationStatus: item.generationStatus,
|
||||
},
|
||||
createdAt: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||
|
||||
function buildPlayedWork(
|
||||
overrides: Partial<ProfilePlayedWorkSummary> = {},
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldTitle: '潮雾列岛',
|
||||
worldSubtitle: '旧灯塔与失控航路',
|
||||
firstPlayedAt: '2026-04-18T12:00:00.000Z',
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
lastObservedPlayTimeMs: 12_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformPlayedWorkOpenModel', () => {
|
||||
test('opens puzzle played works with profile tab context', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'PUZZLE',
|
||||
profileId: 'puzzle-profile-1',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-puzzle',
|
||||
profileId: 'puzzle-profile-1',
|
||||
tab: 'profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to worldKey prefixes when profile id is absent', () => {
|
||||
const cases = [
|
||||
['puzzle:profile-1', 'open-puzzle', 'profile-1'],
|
||||
['match3d:profile-2', 'open-match3d', 'profile-2'],
|
||||
['square-hole:profile-3', 'open-square-hole', 'profile-3'],
|
||||
['jump-hop:profile-4', 'open-jump-hop', 'profile-4'],
|
||||
['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'],
|
||||
] as const;
|
||||
|
||||
for (const [worldKey, type, profileId] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey,
|
||||
profileId: null,
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ type, profileId });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps explicit profile id ahead of worldKey fallback', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'jump-hop:key-profile',
|
||||
profileId: 'explicit-profile',
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: 'open-jump-hop',
|
||||
profileId: 'explicit-profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('supports played work type aliases for mini-games', () => {
|
||||
const cases = [
|
||||
['match_3d', 'open-match3d'],
|
||||
['square_hole', 'open-square-hole'],
|
||||
['jump_hop', 'open-jump-hop'],
|
||||
['wooden_fish', 'open-wooden-fish'],
|
||||
] as const;
|
||||
|
||||
for (const [worldType, type] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType,
|
||||
profileId: `${worldType}-profile`,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type,
|
||||
profileId: `${worldType}-profile`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('returns noop when a mini-game target is empty', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'puzzle:key-profile',
|
||||
profileId: '',
|
||||
worldType: 'puzzle',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds big fish intent and fallback work for gallery misses', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'big-fish:big-fish-session-1',
|
||||
ownerUserId: null,
|
||||
profileId: null,
|
||||
worldType: 'big_fish',
|
||||
worldTitle: '机械深海',
|
||||
worldSubtitle: '',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-big-fish',
|
||||
sessionId: 'big-fish-session-1',
|
||||
fallbackWork: {
|
||||
workId: 'big-fish:big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: '',
|
||||
authorDisplayName: '玩家',
|
||||
title: '机械深海',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('opens unknown played work types as RPG detail when identity is complete', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'CUSTOM',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-rpg',
|
||||
detail: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'custom:world-1',
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-18T12:00:00.000Z',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
authorDisplayName: '旧灯塔与失控航路',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('returns noop for RPG fallback when owner or profile is missing', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
ownerUserId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: '',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
});
|
||||
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
ProfilePlayedWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type PlatformPlayedWorkOpenIntent =
|
||||
| {
|
||||
type: 'noop';
|
||||
reason: 'missing-target';
|
||||
}
|
||||
| {
|
||||
type: 'open-puzzle';
|
||||
profileId: string;
|
||||
tab: 'profile';
|
||||
}
|
||||
| {
|
||||
type: 'open-match3d';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-square-hole';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-jump-hop';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-wooden-fish';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-big-fish';
|
||||
sessionId: string;
|
||||
fallbackWork: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
type: 'open-rpg';
|
||||
detail: CustomWorldGalleryCard;
|
||||
};
|
||||
|
||||
function normalizePlayedWorkWorldType(worldType: string | null) {
|
||||
return (worldType ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function resolvePlayedWorkTargetId(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
worldKeyPrefix: string,
|
||||
) {
|
||||
const prefixedWorldKey = `${worldKeyPrefix}:`;
|
||||
return (
|
||||
work.profileId ??
|
||||
(work.worldKey.startsWith(prefixedWorldKey)
|
||||
? work.worldKey.slice(prefixedWorldKey.length)
|
||||
: work.worldKey)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlayedWorkProfileIntent<TIntent extends PlatformPlayedWorkOpenIntent>(
|
||||
profileId: string,
|
||||
intent: (profileId: string) => TIntent,
|
||||
) {
|
||||
return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent {
|
||||
return {
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedBigFishFallbackWork(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
sessionId: string,
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
authorDisplayName: work.worldSubtitle || '玩家',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: work.lastPlayedAt,
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedRpgDetail(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
profileId: string,
|
||||
ownerUserId: string,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: work.firstPlayedAt,
|
||||
updatedAt: work.lastPlayedAt,
|
||||
authorDisplayName: work.worldSubtitle,
|
||||
worldName: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */
|
||||
export function resolvePlatformPlayedWorkOpenIntent(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
): PlatformPlayedWorkOpenIntent {
|
||||
const worldType = normalizePlayedWorkWorldType(work.worldType);
|
||||
|
||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'puzzle');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-puzzle',
|
||||
profileId: resolvedProfileId,
|
||||
tab: 'profile',
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'match3d' ||
|
||||
worldType === 'match_3d' ||
|
||||
work.worldKey.startsWith('match3d:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'match3d');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-match3d',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'square-hole' ||
|
||||
worldType === 'square_hole' ||
|
||||
work.worldKey.startsWith('square-hole:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'square-hole');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-square-hole',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'jump-hop' ||
|
||||
worldType === 'jump_hop' ||
|
||||
work.worldKey.startsWith('jump-hop:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'jump-hop');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-jump-hop',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'wooden-fish' ||
|
||||
worldType === 'wooden_fish' ||
|
||||
work.worldKey.startsWith('wooden-fish:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-wooden-fish',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
work.worldKey.startsWith('big-fish:')
|
||||
) {
|
||||
const sessionId = resolvePlayedWorkTargetId(work, 'big-fish');
|
||||
return sessionId
|
||||
? {
|
||||
type: 'open-big-fish',
|
||||
sessionId,
|
||||
fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId),
|
||||
}
|
||||
: buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
const profileId = work.profileId ?? work.worldKey;
|
||||
const ownerUserId = work.ownerUserId;
|
||||
if (!ownerUserId || !profileId) {
|
||||
return buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'open-rpg',
|
||||
detail: buildPlayedRpgDetail(work, profileId, ownerUserId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
adjustProfileDashboardWalletBalance,
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard,
|
||||
resolveProfileWalletBalance,
|
||||
} from './platformProfileWalletDeltaModel';
|
||||
|
||||
const NOW = Date.parse('2026-06-04T04:30:00.000Z');
|
||||
|
||||
function buildDashboard(
|
||||
overrides: Partial<ProfileDashboardSummary> = {},
|
||||
): ProfileDashboardSummary {
|
||||
return {
|
||||
walletBalance: 100,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('platformProfileWalletDeltaModel', () => {
|
||||
test('normalizes wallet balance to a non-negative integer', () => {
|
||||
expect(resolveProfileWalletBalance(buildDashboard({ walletBalance: 12.8 }))).toBe(
|
||||
12,
|
||||
);
|
||||
expect(
|
||||
resolveProfileWalletBalance(buildDashboard({ walletBalance: -4 })),
|
||||
).toBe(0);
|
||||
expect(resolveProfileWalletBalance({ walletBalance: Number.NaN })).toBe(0);
|
||||
expect(resolveProfileWalletBalance(null)).toBe(0);
|
||||
});
|
||||
|
||||
test('applies local delta and refreshes dashboard timestamp', () => {
|
||||
expect(
|
||||
adjustProfileDashboardWalletBalance(buildDashboard(), -3.8),
|
||||
).toMatchObject({
|
||||
walletBalance: 97,
|
||||
updatedAt: '2026-06-04T04:30:00.000Z',
|
||||
});
|
||||
expect(
|
||||
adjustProfileDashboardWalletBalance(buildDashboard({ walletBalance: 2 }), -10),
|
||||
).toMatchObject({
|
||||
walletBalance: 0,
|
||||
});
|
||||
expect(adjustProfileDashboardWalletBalance(null, 5)).toBeNull();
|
||||
const dashboard = buildDashboard();
|
||||
expect(adjustProfileDashboardWalletBalance(dashboard, Number.POSITIVE_INFINITY)).toBe(
|
||||
dashboard,
|
||||
);
|
||||
});
|
||||
|
||||
test('reconciles debit delta already reflected by latest server dashboard', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 98 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(-3);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 92 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('reconciles credit delta already reflected by latest server dashboard', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 103 }),
|
||||
8,
|
||||
),
|
||||
).toBe(5);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 120 }),
|
||||
8,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('does not reconcile when server balance moves against local delta', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 104 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(-5);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 96 }),
|
||||
8,
|
||||
),
|
||||
).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type ProfileWalletBalanceSource =
|
||||
| Pick<ProfileDashboardSummary, 'walletBalance'>
|
||||
| { walletBalance?: number | null }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export function resolveProfileWalletBalance(
|
||||
dashboard: ProfileWalletBalanceSource,
|
||||
) {
|
||||
const walletBalance = dashboard?.walletBalance;
|
||||
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
|
||||
? Math.max(0, Math.floor(walletBalance))
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function adjustProfileDashboardWalletBalance(
|
||||
dashboard: ProfileDashboardSummary | null,
|
||||
delta: number,
|
||||
): ProfileDashboardSummary | null {
|
||||
if (!dashboard || !Number.isFinite(delta) || delta === 0) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
return {
|
||||
...dashboard,
|
||||
walletBalance: Math.max(
|
||||
0,
|
||||
resolveProfileWalletBalance(dashboard) + Math.trunc(delta),
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previousDashboard: ProfileDashboardSummary | null,
|
||||
latestDashboard: ProfileDashboardSummary | null,
|
||||
localDelta: number,
|
||||
) {
|
||||
if (
|
||||
!previousDashboard ||
|
||||
!latestDashboard ||
|
||||
!Number.isFinite(localDelta) ||
|
||||
localDelta === 0
|
||||
) {
|
||||
return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0;
|
||||
}
|
||||
|
||||
const previousBalance = resolveProfileWalletBalance(previousDashboard);
|
||||
const latestBalance = resolveProfileWalletBalance(latestDashboard);
|
||||
const normalizedDelta = Math.trunc(localDelta);
|
||||
|
||||
if (normalizedDelta < 0) {
|
||||
const reflectedDebit = Math.max(0, previousBalance - latestBalance);
|
||||
return Math.min(0, normalizedDelta + reflectedDebit);
|
||||
}
|
||||
|
||||
const reflectedCredit = Math.max(0, latestBalance - previousBalance);
|
||||
return Math.max(0, normalizedDelta - reflectedCredit);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
mapRpgPublicCodeSearchDetailToGalleryCard,
|
||||
type PlatformPublicCodeSearchStep,
|
||||
resolveBabyObjectMatchPublicCodeSearchMatch,
|
||||
resolveBarkBattlePublicCodeSearchMatch,
|
||||
resolveBigFishPublicCodeSearchMatch,
|
||||
resolveJumpHopPublicCodeSearchMatch,
|
||||
resolveMatch3DPublicCodeSearchMatch,
|
||||
resolvePlatformPublicCodeSearchPlan,
|
||||
resolvePuzzlePublicCodeSearchMatch,
|
||||
resolveSquareHolePublicCodeSearchMatch,
|
||||
resolveVisualNovelPublicCodeSearchMatch,
|
||||
resolveWoodenFishPublicCodeSearchMatch,
|
||||
} from './platformPublicCodeSearchModel';
|
||||
|
||||
function expectSearchSteps(
|
||||
keyword: string,
|
||||
steps: readonly PlatformPublicCodeSearchStep[],
|
||||
) {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(keyword)?.steps).toEqual(steps);
|
||||
}
|
||||
|
||||
describe('platformPublicCodeSearchModel', () => {
|
||||
test('ignores empty public code search input', () => {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('normalizes public code search keyword before planning', () => {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(' PZ-00000001 ')).toEqual({
|
||||
normalizedKeyword: 'PZ-00000001',
|
||||
steps: ['puzzle-work'],
|
||||
});
|
||||
});
|
||||
|
||||
test('searches internal user ids directly without work fallback', () => {
|
||||
expectSearchSteps('user_00000001', ['user-id']);
|
||||
expectSearchSteps('USER-profile-1', ['user-id']);
|
||||
});
|
||||
|
||||
test('routes known public work prefixes to their play-specific lookup', () => {
|
||||
const cases: Array<
|
||||
[keyword: string, step: PlatformPublicCodeSearchStep]
|
||||
> = [
|
||||
['PZ-EPUBLIC1', 'puzzle-work'],
|
||||
['BF-NPUBLIC1', 'big-fish-work'],
|
||||
['JH-EPUBLIC1', 'jump-hop-work'],
|
||||
['WF-EPUBLIC1', 'wooden-fish-work'],
|
||||
['BO-EPUBLIC1', 'baby-object-match-work'],
|
||||
['M3-EPUBLIC1', 'match3d-work'],
|
||||
['M3D-LEGACY1', 'match3d-work'],
|
||||
['SH-EPUBLIC1', 'square-hole-work'],
|
||||
['VN-EPUBLIC1', 'visual-novel-work'],
|
||||
['BB-EPUBLIC1', 'bark-battle-work'],
|
||||
];
|
||||
|
||||
for (const [keyword, step] of cases) {
|
||||
expectSearchSteps(keyword, [step]);
|
||||
}
|
||||
});
|
||||
|
||||
test('searches RPG public works before public user codes for CW and numeric codes', () => {
|
||||
expectSearchSteps('CW-00000001', ['rpg-work', 'public-user-code']);
|
||||
expectSearchSteps('12345678', ['rpg-work', 'public-user-code']);
|
||||
});
|
||||
|
||||
test('keeps legacy user-code-first fallback for SY and ordinary keywords', () => {
|
||||
const legacyFallbackSteps = [
|
||||
'public-user-code',
|
||||
'rpg-work',
|
||||
'bark-battle-work',
|
||||
'public-user-code',
|
||||
] as const;
|
||||
|
||||
expectSearchSteps('SY-00000001', legacyFallbackSteps);
|
||||
expectSearchSteps('月井守望', legacyFallbackSteps);
|
||||
});
|
||||
|
||||
test('maps RPG detail responses to gallery cards with count defaults', () => {
|
||||
expect(
|
||||
mapRpgPublicCodeSearchDetailToGalleryCard(
|
||||
buildRpgDetailEntry({
|
||||
playCount: undefined,
|
||||
remixCount: undefined,
|
||||
likeCount: undefined,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
profileId: 'rpg-profile-1',
|
||||
visibility: 'published',
|
||||
worldName: '潮雾世界',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves public code matches for every play-specific gallery type', () => {
|
||||
const puzzle = buildPuzzleWork({ profileId: 'puzzle-profile-12345678' });
|
||||
const bigFish = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-12345678',
|
||||
});
|
||||
const jumpHop = buildJumpHopCard({ profileId: 'jump-hop-profile-12345678' });
|
||||
const woodenFish = buildWoodenFishCard({
|
||||
profileId: 'wooden-fish-profile-12345678',
|
||||
});
|
||||
const babyObjectMatch = buildBabyObjectMatchDraft({
|
||||
profileId: 'baby-object-profile-12345678',
|
||||
});
|
||||
const match3d = buildMatch3DWork({ profileId: 'match3d-profile-12345678' });
|
||||
const squareHole = buildSquareHoleWork({
|
||||
profileId: 'square-hole-profile-12345678',
|
||||
});
|
||||
const visualNovel = buildVisualNovelWork({
|
||||
profileId: 'visual-novel-profile-12345678',
|
||||
});
|
||||
const barkBattle = buildBarkBattleWork({
|
||||
workId: 'bark-battle-work-12345678',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePuzzlePublicCodeSearchMatch(
|
||||
[puzzle],
|
||||
buildPuzzlePublicWorkCode(puzzle.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'puzzle' });
|
||||
expect(
|
||||
resolveBigFishPublicCodeSearchMatch(
|
||||
[bigFish],
|
||||
buildBigFishPublicWorkCode(bigFish.sourceSessionId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'big-fish' });
|
||||
expect(
|
||||
resolveJumpHopPublicCodeSearchMatch(
|
||||
[jumpHop],
|
||||
buildJumpHopPublicWorkCode(jumpHop.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'jump-hop' });
|
||||
expect(
|
||||
resolveWoodenFishPublicCodeSearchMatch(
|
||||
[woodenFish],
|
||||
buildWoodenFishPublicWorkCode(woodenFish.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'wooden-fish' });
|
||||
expect(
|
||||
resolveBabyObjectMatchPublicCodeSearchMatch(
|
||||
[babyObjectMatch],
|
||||
`BO-${babyObjectMatch.profileId.slice(-8)}`,
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'edutainment' });
|
||||
expect(
|
||||
resolveMatch3DPublicCodeSearchMatch(
|
||||
[match3d],
|
||||
buildMatch3DPublicWorkCode(match3d.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'match3d' });
|
||||
expect(
|
||||
resolveSquareHolePublicCodeSearchMatch(
|
||||
[squareHole],
|
||||
buildSquareHolePublicWorkCode(squareHole.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'square-hole' });
|
||||
expect(
|
||||
resolveVisualNovelPublicCodeSearchMatch(
|
||||
[visualNovel],
|
||||
buildVisualNovelPublicWorkCode(visualNovel.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'visual-novel' });
|
||||
expect(
|
||||
resolveBarkBattlePublicCodeSearchMatch(
|
||||
[barkBattle],
|
||||
buildBarkBattlePublicWorkCode(barkBattle.workId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'bark-battle' });
|
||||
});
|
||||
|
||||
test('public code search matchers skip entries hidden by visibility policy', () => {
|
||||
const hiddenPuzzle = buildPuzzleWork({
|
||||
profileId: 'hidden-profile-12345678',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePuzzlePublicCodeSearchMatch(
|
||||
[hiddenPuzzle],
|
||||
buildPuzzlePublicWorkCode(hiddenPuzzle.profileId),
|
||||
() => false,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function buildRpgDetailEntry(
|
||||
overrides: Partial<CustomWorldLibraryEntry<CustomWorldProfile>> = {},
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return {
|
||||
ownerUserId: 'rpg-owner-1',
|
||||
profileId: 'rpg-profile-1',
|
||||
publicWorkCode: 'CW-00000001',
|
||||
authorPublicUserCode: 'SY-00000001',
|
||||
profile: {} as CustomWorldProfile,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
authorDisplayName: '测试作者',
|
||||
worldName: '潮雾世界',
|
||||
subtitle: '潮雾港',
|
||||
summaryText: '潮雾世界说明。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
playCount: 1,
|
||||
remixCount: 1,
|
||||
likeCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾拼图说明。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾拼图说明。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾大鱼',
|
||||
subtitle: '潮雾港',
|
||||
summary: '潮雾大鱼说明。',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 1,
|
||||
backgroundReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopCard(
|
||||
overrides: Partial<JumpHopGalleryCardResponse> = {},
|
||||
): JumpHopGalleryCardResponse {
|
||||
const profileId = overrides.profileId ?? 'jump-hop-profile-1';
|
||||
return {
|
||||
publicWorkCode: buildJumpHopPublicWorkCode(profileId),
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾跳一跳',
|
||||
workDescription: '潮雾跳一跳说明。',
|
||||
coverImageSrc: null,
|
||||
themeTags: [],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishCard(
|
||||
overrides: Partial<WoodenFishGalleryCardResponse> = {},
|
||||
): WoodenFishGalleryCardResponse {
|
||||
const profileId = overrides.profileId ?? 'wooden-fish-profile-1';
|
||||
return {
|
||||
publicWorkCode: buildWoodenFishPublicWorkCode(profileId),
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾木鱼',
|
||||
workDescription: '潮雾木鱼说明。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['敲木鱼'],
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-draft-1',
|
||||
profileId: 'baby-object-profile-1',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '潮雾识物',
|
||||
workDescription: '潮雾识物说明。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
buildBabyObjectMatchItemAsset('item-a', '苹果'),
|
||||
buildBabyObjectMatchItemAsset('item-b', '香蕉'),
|
||||
],
|
||||
visualPackage: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchItemAsset(itemId: string, itemName: string) {
|
||||
return {
|
||||
itemId,
|
||||
itemName,
|
||||
imageSrc: `/media/${itemId}.png`,
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder' as const,
|
||||
prompt: itemName,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '潮雾抓大鹅',
|
||||
themeText: '潮雾港',
|
||||
summary: '潮雾抓大鹅说明。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work-1',
|
||||
profileId: 'square-hole-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session-1',
|
||||
gameName: '潮雾方洞',
|
||||
themeText: '潮雾港',
|
||||
twistRule: '避开雾门',
|
||||
summary: '潮雾方洞说明。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '潮雾港',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 1,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '潮雾视觉小说',
|
||||
description: '潮雾视觉小说说明。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'published',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'bark-battle-work-1',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾声浪',
|
||||
summary: '潮雾声浪说明。',
|
||||
themeDescription: '潮雾港',
|
||||
playerImageDescription: '小狗',
|
||||
opponentImageDescription: '对手',
|
||||
onomatopoeia: ['汪'],
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
313
src/components/platform-entry/platformPublicCodeSearchModel.ts
Normal file
313
src/components/platform-entry/platformPublicCodeSearchModel.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
isSameBabyObjectMatchPublicWorkCode,
|
||||
isSameBarkBattlePublicWorkCode,
|
||||
isSameBigFishPublicWorkCode,
|
||||
isSameJumpHopPublicWorkCode,
|
||||
isSameMatch3DPublicWorkCode,
|
||||
isSamePuzzlePublicWorkCode,
|
||||
isSameSquareHolePublicWorkCode,
|
||||
isSameVisualNovelPublicWorkCode,
|
||||
isSameWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { mapBabyObjectMatchDraftToPlatformGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { canExposePublicWork } from './platformEdutainmentVisibility';
|
||||
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
|
||||
import {
|
||||
mapBarkBattleWorkToPublicWorkDetail,
|
||||
mapBigFishWorkToPublicWorkDetail,
|
||||
mapJumpHopWorkToPublicWorkDetail,
|
||||
mapPuzzleWorkToPublicWorkDetail,
|
||||
mapSquareHoleWorkToPublicWorkDetail,
|
||||
mapVisualNovelWorkToPublicWorkDetail,
|
||||
mapWoodenFishWorkToPublicWorkDetail,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
export type PlatformPublicCodeSearchStep =
|
||||
| 'user-id'
|
||||
| 'public-user-code'
|
||||
| 'rpg-work'
|
||||
| 'puzzle-work'
|
||||
| 'big-fish-work'
|
||||
| 'jump-hop-work'
|
||||
| 'wooden-fish-work'
|
||||
| 'baby-object-match-work'
|
||||
| 'match3d-work'
|
||||
| 'square-hole-work'
|
||||
| 'visual-novel-work'
|
||||
| 'bark-battle-work';
|
||||
|
||||
export type PlatformPublicCodeSearchPlan = {
|
||||
normalizedKeyword: string;
|
||||
steps: readonly PlatformPublicCodeSearchStep[];
|
||||
};
|
||||
|
||||
export type PlatformPublicCodeSearchMatch<TItem> = {
|
||||
item: TItem;
|
||||
detail: PlatformPublicGalleryCard;
|
||||
};
|
||||
|
||||
type PlatformPublicCodeSearchMatcherInput<TItem> = {
|
||||
keyword: string;
|
||||
entries: readonly TItem[];
|
||||
mapEntry: (item: TItem) => PlatformPublicGalleryCard;
|
||||
matchesEntry: (keyword: string, item: TItem) => boolean;
|
||||
canExposeEntry?: (entry: PlatformPublicGalleryCard) => boolean;
|
||||
};
|
||||
|
||||
const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu;
|
||||
const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u;
|
||||
|
||||
const DIRECT_WORK_PREFIX_STEPS: ReadonlyArray<
|
||||
readonly [prefix: string, step: PlatformPublicCodeSearchStep]
|
||||
> = [
|
||||
['PZ', 'puzzle-work'],
|
||||
['BF', 'big-fish-work'],
|
||||
['JH', 'jump-hop-work'],
|
||||
['WF', 'wooden-fish-work'],
|
||||
['BO', 'baby-object-match-work'],
|
||||
['M3', 'match3d-work'],
|
||||
['SH', 'square-hole-work'],
|
||||
['VN', 'visual-novel-work'],
|
||||
['BB', 'bark-battle-work'],
|
||||
];
|
||||
|
||||
/** 收口公开码搜索顺序,壳层只按步骤执行网络读取与打开副作用。 */
|
||||
export function resolvePlatformPublicCodeSearchPlan(
|
||||
keyword: string,
|
||||
): PlatformPublicCodeSearchPlan | null {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PLATFORM_PUBLIC_USER_ID_PATTERN.test(normalizedKeyword)) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: ['user-id'],
|
||||
};
|
||||
}
|
||||
|
||||
const upperKeyword = normalizedKeyword.toUpperCase();
|
||||
const directWorkStep = DIRECT_WORK_PREFIX_STEPS.find(([prefix]) =>
|
||||
upperKeyword.startsWith(prefix),
|
||||
)?.[1];
|
||||
if (directWorkStep) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: [directWorkStep],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
upperKeyword.startsWith('CW') ||
|
||||
PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN.test(normalizedKeyword)
|
||||
) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: ['rpg-work', 'public-user-code'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: [
|
||||
'public-user-code',
|
||||
'rpg-work',
|
||||
'bark-battle-work',
|
||||
'public-user-code',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapRpgPublicCodeSearchDetailToGalleryCard(
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
playCount: entry.playCount ?? 0,
|
||||
remixCount: entry.remixCount ?? 0,
|
||||
likeCount: entry.likeCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePuzzlePublicCodeSearchMatch(
|
||||
entries: readonly PuzzleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapPuzzleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSamePuzzlePublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBigFishPublicCodeSearchMatch(
|
||||
entries: readonly BigFishWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBigFishWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBigFishPublicWorkCode(searchKeyword, item.sourceSessionId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveJumpHopPublicCodeSearchMatch(
|
||||
entries: readonly JumpHopGalleryCardResponse[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapJumpHopWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameJumpHopPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWoodenFishPublicCodeSearchMatch(
|
||||
entries: readonly WoodenFishGalleryCardResponse[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapWoodenFishWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameWoodenFishPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBabyObjectMatchPublicCodeSearchMatch(
|
||||
entries: readonly BabyObjectMatchDraft[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBabyObjectMatchPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatch3DPublicCodeSearchMatch(
|
||||
entries: readonly Match3DWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapMatch3DWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameMatch3DPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSquareHolePublicCodeSearchMatch(
|
||||
entries: readonly SquareHoleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapSquareHoleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameSquareHolePublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVisualNovelPublicCodeSearchMatch(
|
||||
entries: readonly VisualNovelWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapVisualNovelWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameVisualNovelPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBarkBattlePublicCodeSearchMatch(
|
||||
entries: readonly BarkBattleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBarkBattleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBarkBattlePublicWorkCode(searchKeyword, item.workId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMappedPublicCodeSearchMatch<TItem>({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry,
|
||||
matchesEntry,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
}: PlatformPublicCodeSearchMatcherInput<TItem>):
|
||||
| PlatformPublicCodeSearchMatch<TItem>
|
||||
| null {
|
||||
for (const item of entries) {
|
||||
const detail = mapEntry(item);
|
||||
if (canExposeEntry(detail) && matchesEntry(keyword, item)) {
|
||||
return { item, detail };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
883
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
883
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
@@ -0,0 +1,883 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildPlatformPublicGalleryFeeds,
|
||||
getPlatformPublicGalleryEntryKey,
|
||||
getPlatformPublicGalleryEntryTime,
|
||||
getPlatformRecommendRuntimeKind,
|
||||
isPlatformRecommendRuntimeReadyForEntry,
|
||||
isSamePlatformPublicGalleryEntry,
|
||||
mergePlatformPublicGalleryEntries,
|
||||
type PlatformRecommendRuntimeStartIntentDeps,
|
||||
type RecommendRuntimeKind,
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision,
|
||||
resolvePlatformRecommendRuntimeStartIntent,
|
||||
} from './platformPublicGalleryFlow';
|
||||
import {
|
||||
mapBarkBattlePublicDetailToWorkSummary,
|
||||
mapPublicWorkDetailToBigFishWork,
|
||||
mapPublicWorkDetailToPuzzleWork,
|
||||
mapPublicWorkDetailToSquareHoleWork,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
type TypedPlatformPublicGalleryCard = Extract<
|
||||
PlatformPublicGalleryCard,
|
||||
{ sourceType: string }
|
||||
>;
|
||||
type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType'];
|
||||
type TypedPlatformPublicGalleryCardOverrides = Partial<
|
||||
Omit<TypedPlatformPublicGalleryCard, 'sourceType'>
|
||||
>;
|
||||
|
||||
function buildRpgEntry(
|
||||
overrides: Partial<CustomWorldGalleryCard> = {},
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'rpg-profile',
|
||||
publicWorkCode: 'CW-RPG',
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: 'RPG 世界',
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work',
|
||||
sourceSessionId: 'big-fish-session',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '海湾',
|
||||
summary: '一路长大。',
|
||||
coverImageSrc: '/big-fish-cover.png',
|
||||
status: 'published',
|
||||
updatedAt: '2026-06-01T02:00:00.000Z',
|
||||
publishedAt: '2026-06-01T02:00:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 1,
|
||||
backgroundReady: true,
|
||||
playCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
const itemAsset = {
|
||||
itemId: 'item-a',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder' as const,
|
||||
prompt: '苹果',
|
||||
};
|
||||
return {
|
||||
draftId: 'baby-draft',
|
||||
profileId: 'baby-profile',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '认识水果。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [itemAsset, { ...itemAsset, itemId: 'item-b', itemName: '香蕉' }],
|
||||
visualPackage: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T03:00:00.000Z',
|
||||
publishedAt: '2026-06-01T03:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopEntry(
|
||||
overrides: Partial<JumpHopGalleryCardResponse> = {},
|
||||
): JumpHopGalleryCardResponse {
|
||||
return {
|
||||
publicWorkCode: 'JH-JUMP',
|
||||
workId: 'jump-hop-work',
|
||||
profileId: 'jump-hop-profile',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: '跳一跳',
|
||||
workDescription: '一路向前。',
|
||||
coverImageSrc: '/jump-hop-cover.png',
|
||||
themeTags: ['跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
publicationStatus: 'published',
|
||||
playCount: 1,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypedEntry(
|
||||
sourceType: PlatformGallerySourceType,
|
||||
overrides: TypedPlatformPublicGalleryCardOverrides = {},
|
||||
): PlatformPublicGalleryCard {
|
||||
const common = {
|
||||
workId: `${sourceType}-work`,
|
||||
profileId: `${sourceType}-profile`,
|
||||
publicWorkCode: `${sourceType}-code`,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: `${sourceType} 作品`,
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: [sourceType],
|
||||
visibility: 'published' as const,
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
};
|
||||
|
||||
switch (sourceType) {
|
||||
case 'puzzle':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'big-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'match3d':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'square-hole':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'visual-novel':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'jump-hop':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'wooden-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'edutainment':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
};
|
||||
case 'bark-battle':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
authorPublicUserCode: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = sourceType;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work',
|
||||
profileId: 'puzzle-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session',
|
||||
authorDisplayName: '玩家',
|
||||
levelName: '拼图作品',
|
||||
summary: '拼图摘要',
|
||||
themeTags: ['拼图'],
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
playCount: 3,
|
||||
remixCount: 2,
|
||||
likeCount: 1,
|
||||
pointIncentiveTotalHalfPoints: 0,
|
||||
pointIncentiveClaimedPoints: 0,
|
||||
pointIncentiveTotalPoints: 0,
|
||||
pointIncentiveClaimablePoints: 0,
|
||||
publishReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work',
|
||||
profileId: 'match3d-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session',
|
||||
gameName: '抓大鹅作品',
|
||||
themeText: '经典消除',
|
||||
summary: '抓大鹅摘要',
|
||||
tags: ['抓大鹅'],
|
||||
coverImageSrc: '/match3d-cover.png',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: 10,
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'bark-battle-work',
|
||||
draftId: 'bark-battle-draft',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '汪汪声浪作品',
|
||||
summary: '汪汪摘要',
|
||||
themeDescription: '森林擂台',
|
||||
playerImageDescription: '小狗',
|
||||
opponentImageDescription: '对手',
|
||||
playerCharacterImageSrc: '/player.png',
|
||||
opponentCharacterImageSrc: '/opponent.png',
|
||||
uiBackgroundImageSrc: '/bark-bg.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 9,
|
||||
recentPlayCount7d: 2,
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecommendRuntimeStartDeps(
|
||||
overrides: Partial<PlatformRecommendRuntimeStartIntentDeps> = {},
|
||||
): PlatformRecommendRuntimeStartIntentDeps {
|
||||
return {
|
||||
selectedPuzzleDetail: null,
|
||||
barkBattleGalleryEntries: [],
|
||||
mapMatch3DWork: () => buildMatch3DWork(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => {
|
||||
const cases: Array<
|
||||
[sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind]
|
||||
> = [
|
||||
['big-fish', 'big-fish', 'big-fish'],
|
||||
['puzzle', 'puzzle', 'puzzle'],
|
||||
['jump-hop', 'jump-hop', 'jump-hop'],
|
||||
['wooden-fish', 'wooden-fish', 'wooden-fish'],
|
||||
['match3d', 'match3d', 'match3d'],
|
||||
['square-hole', 'square-hole', 'square-hole'],
|
||||
['visual-novel', 'visual-novel', 'visual-novel'],
|
||||
['bark-battle', 'bark-battle', 'bark-battle'],
|
||||
[
|
||||
'edutainment',
|
||||
`edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`,
|
||||
'edutainment',
|
||||
],
|
||||
];
|
||||
|
||||
cases.forEach(([sourceType, keyKind, kind]) => {
|
||||
const entry = buildTypedEntry(sourceType);
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(entry)).toBe(
|
||||
`${keyKind}:user-1:${sourceType}-profile`,
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind);
|
||||
});
|
||||
|
||||
const rpgEntry = buildRpgEntry();
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe(
|
||||
'rpg:user-1:rpg-profile',
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg');
|
||||
});
|
||||
|
||||
test('platform public gallery flow compares entries by resolved identity', () => {
|
||||
const left = buildTypedEntry('puzzle');
|
||||
const sameIdentity = buildTypedEntry('puzzle', {
|
||||
workId: 'other-work',
|
||||
worldName: '新标题',
|
||||
});
|
||||
const otherKind = buildTypedEntry('match3d', {
|
||||
ownerUserId: left.ownerUserId,
|
||||
profileId: left.profileId,
|
||||
});
|
||||
|
||||
expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true);
|
||||
expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime start intent', () => {
|
||||
const bigFishEntry = buildTypedEntry('big-fish');
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
bigFishEntry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
const selectedPuzzleDetail = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('puzzle'),
|
||||
buildRecommendRuntimeStartDeps({ selectedPuzzleDetail }),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-puzzle',
|
||||
work: selectedPuzzleDetail,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
const puzzleEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'fallback-puzzle-profile',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
puzzleEntry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }),
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-puzzle',
|
||||
work: mapPublicWorkDetailToPuzzleWork(puzzleEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('jump-hop'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-jump-hop',
|
||||
profileId: 'jump-hop-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('wooden-fish'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-wooden-fish',
|
||||
profileId: 'wooden-fish-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('visual-novel'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-visual-novel',
|
||||
profileId: 'visual-novel-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('edutainment'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-edutainment',
|
||||
entry: buildTypedEntry('edutainment'),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildRpgEntry(),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'mark-ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime mapper-backed start intent', () => {
|
||||
const match3DEntry = buildTypedEntry('match3d');
|
||||
const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
match3DEntry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
mapMatch3DWork: (entry) =>
|
||||
entry === match3DEntry ? match3DWork : null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-match3d',
|
||||
work: match3DWork,
|
||||
returnStage: 'work-detail',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
match3DEntry,
|
||||
buildRecommendRuntimeStartDeps({ mapMatch3DWork: () => null }),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'blocked',
|
||||
errorTarget: 'match3d',
|
||||
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
|
||||
});
|
||||
|
||||
const squareHoleEntry = buildTypedEntry('square-hole');
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
squareHoleEntry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-square-hole',
|
||||
work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime bark battle priority', () => {
|
||||
const entry = buildTypedEntry('bark-battle');
|
||||
const galleryWork = buildBarkBattleWork({
|
||||
workId: 'bark-battle-work',
|
||||
title: '推荐缓存',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
barkBattleGalleryEntries: [galleryWork],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-bark-battle',
|
||||
work: galleryWork,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-bark-battle',
|
||||
work: mapBarkBattlePublicDetailToWorkSummary(entry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime readiness', () => {
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
|
||||
activeKind: 'puzzle',
|
||||
hasBigFishRun: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
|
||||
activeKind: 'big-fish',
|
||||
hasBigFishRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('jump-hop'), {
|
||||
activeKind: 'jump-hop',
|
||||
hasJumpHopRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('wooden-fish'), {
|
||||
activeKind: 'wooden-fish',
|
||||
hasWoodenFishRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('match3d'), {
|
||||
activeKind: 'match3d',
|
||||
hasMatch3DRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('square-hole'), {
|
||||
activeKind: 'square-hole',
|
||||
hasSquareHoleRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('visual-novel'), {
|
||||
activeKind: 'visual-novel',
|
||||
hasVisualNovelRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('bark-battle'), {
|
||||
activeKind: 'bark-battle',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildRpgEntry(), {
|
||||
activeKind: 'rpg',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves puzzle and edutainment readiness details', () => {
|
||||
const puzzleEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'puzzle-profile',
|
||||
});
|
||||
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'other-profile',
|
||||
puzzleRunCurrentLevelProfileId: 'puzzle-profile',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'other-profile',
|
||||
puzzleRunCurrentLevelProfileId: 'another-profile',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
|
||||
activeKind: 'edutainment',
|
||||
hasBabyObjectMatchDraft: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
|
||||
activeKind: 'edutainment',
|
||||
hasBabyObjectMatchDraft: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start gates', () => {
|
||||
const entry = buildTypedEntry('big-fish');
|
||||
const baseInput: Parameters<
|
||||
typeof resolvePlatformRecommendRuntimeAutoStartDecision
|
||||
>[0] = {
|
||||
isDesktopLayout: false,
|
||||
selectionStage: 'platform',
|
||||
platformTab: 'home',
|
||||
isLoadingPlatform: false,
|
||||
entries: [entry],
|
||||
activeEntryKey: null,
|
||||
isStarting: false,
|
||||
readyState: { activeKind: null },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
isDesktopLayout: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
platformTab: 'discover',
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
entries: [],
|
||||
}),
|
||||
).toEqual({ type: 'clear' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
isStarting: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
|
||||
const firstEntry = buildTypedEntry('big-fish', {
|
||||
profileId: 'big-fish-first',
|
||||
});
|
||||
const activeEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'puzzle-active',
|
||||
});
|
||||
const activeEntryKey = getPlatformPublicGalleryEntryKey(activeEntry);
|
||||
const baseInput: Parameters<
|
||||
typeof resolvePlatformRecommendRuntimeAutoStartDecision
|
||||
>[0] = {
|
||||
isDesktopLayout: false,
|
||||
selectionStage: 'platform',
|
||||
platformTab: 'home',
|
||||
isLoadingPlatform: false,
|
||||
entries: [firstEntry, activeEntry],
|
||||
activeEntryKey,
|
||||
isStarting: false,
|
||||
readyState: { activeKind: 'puzzle' },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
readyState: {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'puzzle-active',
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(resolvePlatformRecommendRuntimeAutoStartDecision(baseInput)).toEqual({
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
activeEntryKey: 'missing-entry',
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'start',
|
||||
entry: firstEntry,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow merges duplicate identities and sorts newest first', () => {
|
||||
const staleRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '旧版 RPG',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
});
|
||||
const freshRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '新版 RPG',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
});
|
||||
const middleRpgEntry = buildRpgEntry({
|
||||
profileId: 'middle-rpg',
|
||||
worldName: '中间 RPG',
|
||||
publishedAt: '2026-06-02T00:00:00.000Z',
|
||||
});
|
||||
const updatedOnlyEntry = buildTypedEntry('big-fish', {
|
||||
profileId: 'updated-only',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
});
|
||||
const invalidTimeEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'invalid-time',
|
||||
publishedAt: 'not-a-date',
|
||||
updatedAt: 'still-not-a-date',
|
||||
});
|
||||
|
||||
const merged = mergePlatformPublicGalleryEntries(
|
||||
[staleRpgEntry, middleRpgEntry],
|
||||
[invalidTimeEntry, updatedOnlyEntry, freshRpgEntry],
|
||||
);
|
||||
|
||||
expect(merged).toHaveLength(4);
|
||||
expect(merged.map((entry) => entry.profileId)).toEqual([
|
||||
'shared-rpg',
|
||||
'updated-only',
|
||||
'middle-rpg',
|
||||
'invalid-time',
|
||||
]);
|
||||
expect(merged[0]?.worldName).toBe('新版 RPG');
|
||||
expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0);
|
||||
});
|
||||
|
||||
test('platform public gallery flow builds feeds with visibility gates and bark battle fallback', () => {
|
||||
const hiddenBigFish = buildBigFishWork({
|
||||
workId: 'hidden-big-fish',
|
||||
sourceSessionId: 'hidden-big-fish-session',
|
||||
});
|
||||
const hiddenBabyDraft = buildBabyObjectMatchDraft({
|
||||
profileId: 'hidden-baby',
|
||||
});
|
||||
const publishedBarkFallback = buildBarkBattleWork({
|
||||
workId: 'fallback-bark',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
});
|
||||
const draftBarkFallback = buildBarkBattleWork({
|
||||
workId: 'draft-bark',
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
const hiddenFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [
|
||||
buildRpgEntry({
|
||||
profileId: 'rpg-visible',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
bigFishEntries: [hiddenBigFish],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [draftBarkFallback, publishedBarkFallback],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [hiddenBabyDraft],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
hiddenFeeds.latestEntries.map((entry) =>
|
||||
'sourceType' in entry ? entry.sourceType : 'rpg',
|
||||
),
|
||||
).toEqual(['bark-battle', 'rpg']);
|
||||
expect(hiddenFeeds.latestEntries[0]?.profileId).toBe('fallback-bark');
|
||||
|
||||
const visibleFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [],
|
||||
bigFishEntries: [hiddenBigFish],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [
|
||||
buildBarkBattleWork({
|
||||
workId: 'gallery-bark',
|
||||
publishedAt: '2026-06-05T00:00:00.000Z',
|
||||
updatedAt: '2026-06-05T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
barkBattleWorks: [publishedBarkFallback],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [hiddenBabyDraft],
|
||||
isBigFishCreationVisible: true,
|
||||
isBabyObjectMatchVisible: true,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(visibleFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
|
||||
'gallery-bark',
|
||||
'hidden-baby',
|
||||
'hidden-big-fish-session',
|
||||
]);
|
||||
expect(visibleFeeds.featuredEntries).toEqual(
|
||||
visibleFeeds.latestEntries.slice(0, 6),
|
||||
);
|
||||
});
|
||||
|
||||
test('platform public gallery flow preserves feed tie order and featured slice', () => {
|
||||
const sameTime = '2026-06-04T00:00:00.000Z';
|
||||
const tieFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [],
|
||||
bigFishEntries: [],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [
|
||||
buildBarkBattleWork({
|
||||
workId: 'fallback-bark',
|
||||
publishedAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
}),
|
||||
],
|
||||
jumpHopEntries: [
|
||||
buildJumpHopEntry({
|
||||
profileId: 'jump-hop',
|
||||
publishedAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
}),
|
||||
],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(tieFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
|
||||
'jump-hop',
|
||||
'fallback-bark',
|
||||
]);
|
||||
|
||||
const sliceFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: Array.from({ length: 7 }, (_, index) =>
|
||||
buildRpgEntry({
|
||||
profileId: `rpg-${index}`,
|
||||
publishedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
|
||||
updatedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
|
||||
}),
|
||||
),
|
||||
bigFishEntries: [],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
expect(sliceFeeds.featuredEntries).toHaveLength(6);
|
||||
});
|
||||
556
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
556
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
mapJumpHopWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
mapSquareHoleWorkToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
mapWoodenFishWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
|
||||
import {
|
||||
mapBarkBattlePublicDetailToWorkSummary,
|
||||
mapPublicWorkDetailToBigFishWork,
|
||||
mapPublicWorkDetailToPuzzleWork,
|
||||
mapPublicWorkDetailToSquareHoleWork,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
export type RecommendRuntimeKind =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'edutainment'
|
||||
| 'jump-hop'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'square-hole'
|
||||
| 'wooden-fish'
|
||||
| 'visual-novel'
|
||||
| 'rpg';
|
||||
|
||||
export type PlatformRecommendRuntimeStartErrorTarget =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'square-hole';
|
||||
|
||||
export type PlatformRecommendRuntimeStartIntent =
|
||||
| {
|
||||
type: 'blocked';
|
||||
errorTarget: PlatformRecommendRuntimeStartErrorTarget;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: 'start-big-fish';
|
||||
work: BigFishWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-puzzle';
|
||||
work: PuzzleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-jump-hop';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-wooden-fish';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-match3d';
|
||||
work: Match3DWorkSummary;
|
||||
returnStage: 'work-detail';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-square-hole';
|
||||
work: SquareHoleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-visual-novel';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-bark-battle';
|
||||
work: BarkBattleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-edutainment';
|
||||
entry: PlatformPublicGalleryCard;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'mark-ready';
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeStartIntentDeps = {
|
||||
selectedPuzzleDetail?: PuzzleWorkSummary | null;
|
||||
barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[];
|
||||
mapMatch3DWork: (
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) => Match3DWorkSummary | null;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeReadyState = {
|
||||
activeKind: RecommendRuntimeKind | null;
|
||||
hasBabyObjectMatchDraft?: boolean;
|
||||
hasBigFishRun?: boolean;
|
||||
hasJumpHopRun?: boolean;
|
||||
hasMatch3DRun?: boolean;
|
||||
hasSquareHoleRun?: boolean;
|
||||
hasVisualNovelRun?: boolean;
|
||||
hasWoodenFishRun?: boolean;
|
||||
puzzleRunEntryProfileId?: string | null;
|
||||
puzzleRunCurrentLevelProfileId?: string | null;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeAutoStartDecision =
|
||||
| { type: 'noop' }
|
||||
| { type: 'clear' }
|
||||
| { type: 'start'; entry: PlatformPublicGalleryCard };
|
||||
|
||||
export type PlatformRecommendRuntimeAutoStartInput = {
|
||||
isDesktopLayout: boolean;
|
||||
selectionStage: string;
|
||||
platformTab: string;
|
||||
isLoadingPlatform: boolean;
|
||||
entries: readonly PlatformPublicGalleryCard[];
|
||||
activeEntryKey: string | null;
|
||||
isStarting: boolean;
|
||||
readyState: PlatformRecommendRuntimeReadyState;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryFeedsInput = {
|
||||
rpgEntries: readonly CustomWorldGalleryCard[];
|
||||
bigFishEntries: readonly BigFishWorkSummary[];
|
||||
match3dEntries: readonly Match3DWorkSummary[];
|
||||
puzzleEntries: readonly PuzzleWorkSummary[];
|
||||
barkBattleGalleryEntries: readonly BarkBattleWorkSummary[];
|
||||
barkBattleWorks: readonly BarkBattleWorkSummary[];
|
||||
jumpHopEntries: readonly JumpHopGalleryCardResponse[];
|
||||
woodenFishEntries: readonly WoodenFishGalleryCardResponse[];
|
||||
squareHoleEntries: readonly SquareHoleWorkSummary[];
|
||||
visualNovelEntries: readonly VisualNovelWorkSummary[];
|
||||
babyObjectMatchDrafts: readonly BabyObjectMatchDraft[];
|
||||
isBigFishCreationVisible: boolean;
|
||||
isBabyObjectMatchVisible: boolean;
|
||||
isVisualNovelCreationOpen: boolean;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryFeeds = {
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
export function getPlatformPublicGalleryEntryTime(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
const timestamp = new Date(rawTime).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
export function getPlatformPublicGalleryEntryKey(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
// 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
export function getPlatformRecommendRuntimeKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): RecommendRuntimeKind {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return 'jump-hop';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return 'wooden-fish';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return 'edutainment';
|
||||
}
|
||||
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
deps: PlatformRecommendRuntimeStartIntentDeps,
|
||||
): PlatformRecommendRuntimeStartIntent {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
const work = mapPublicWorkDetailToBigFishWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'big-fish',
|
||||
errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-big-fish',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.selectedPuzzleDetail?.profileId === entry.profileId
|
||||
? deps.selectedPuzzleDetail
|
||||
: mapPublicWorkDetailToPuzzleWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'puzzle',
|
||||
errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-puzzle',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-jump-hop',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-wooden-fish',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
// 中文注释:抓大鹅推荐 runtime 仍接 Match3D Module 的 Adapter,避免复制素材归一规则。
|
||||
const work = deps.mapMatch3DWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'match3d',
|
||||
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-match3d',
|
||||
work,
|
||||
returnStage: 'work-detail',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
const work = mapPublicWorkDetailToSquareHoleWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'square-hole',
|
||||
errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-square-hole',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-visual-novel',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.barkBattleGalleryEntries?.find(
|
||||
(item) => item.workId === entry.workId,
|
||||
) ?? mapBarkBattlePublicDetailToWorkSummary(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'bark-battle',
|
||||
errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-bark-battle',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-edutainment',
|
||||
entry,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'mark-ready',
|
||||
};
|
||||
}
|
||||
|
||||
export function isPlatformRecommendRuntimeReadyForEntry(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
state: PlatformRecommendRuntimeReadyState,
|
||||
) {
|
||||
const expectedKind = getPlatformRecommendRuntimeKind(entry);
|
||||
if (state.activeKind !== expectedKind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedKind === 'big-fish') {
|
||||
return Boolean(state.hasBigFishRun);
|
||||
}
|
||||
if (expectedKind === 'jump-hop') {
|
||||
return Boolean(state.hasJumpHopRun);
|
||||
}
|
||||
if (expectedKind === 'wooden-fish') {
|
||||
return Boolean(state.hasWoodenFishRun);
|
||||
}
|
||||
if (expectedKind === 'match3d') {
|
||||
return Boolean(state.hasMatch3DRun);
|
||||
}
|
||||
if (expectedKind === 'puzzle') {
|
||||
return (
|
||||
state.puzzleRunEntryProfileId === entry.profileId ||
|
||||
state.puzzleRunCurrentLevelProfileId === entry.profileId
|
||||
);
|
||||
}
|
||||
if (expectedKind === 'square-hole') {
|
||||
return Boolean(state.hasSquareHoleRun);
|
||||
}
|
||||
if (expectedKind === 'visual-novel') {
|
||||
return Boolean(state.hasVisualNovelRun);
|
||||
}
|
||||
if (expectedKind === 'bark-battle') {
|
||||
return true;
|
||||
}
|
||||
if (expectedKind === 'edutainment') {
|
||||
return Boolean(state.hasBabyObjectMatchDraft);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeAutoStartDecision(
|
||||
input: PlatformRecommendRuntimeAutoStartInput,
|
||||
): PlatformRecommendRuntimeAutoStartDecision {
|
||||
if (
|
||||
input.isDesktopLayout ||
|
||||
input.selectionStage !== 'platform' ||
|
||||
input.platformTab !== 'home' ||
|
||||
input.isLoadingPlatform
|
||||
) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
if (input.entries.length === 0) {
|
||||
return { type: 'clear' };
|
||||
}
|
||||
|
||||
const activeEntry = input.activeEntryKey
|
||||
? (input.entries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === input.activeEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRuntimeReady =
|
||||
activeEntry !== null &&
|
||||
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
|
||||
|
||||
if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
const nextEntry = activeEntry ?? input.entries[0];
|
||||
return nextEntry ? { type: 'start', entry: nextEntry } : { type: 'clear' };
|
||||
}
|
||||
|
||||
export function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return (
|
||||
getPlatformPublicGalleryEntryKey(left) ===
|
||||
getPlatformPublicGalleryEntryKey(right)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergePlatformPublicGalleryEntries(
|
||||
rpgEntries: readonly CustomWorldGalleryCard[],
|
||||
puzzleEntries: readonly PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
|
||||
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values()).sort(
|
||||
(left, right) =>
|
||||
getPlatformPublicGalleryEntryTime(right) -
|
||||
getPlatformPublicGalleryEntryTime(left),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPlatformPublicGalleryFeeds(
|
||||
input: PlatformPublicGalleryFeedsInput,
|
||||
): PlatformPublicGalleryFeeds {
|
||||
const bigFishEntries = input.isBigFishCreationVisible
|
||||
? input.bigFishEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const babyObjectMatchEntries = input.isBabyObjectMatchVisible
|
||||
? input.babyObjectMatchDrafts
|
||||
.filter((draft) => draft.publicationStatus === 'published')
|
||||
.map(mapBabyObjectMatchDraftToPlatformGalleryCard)
|
||||
: [];
|
||||
const barkBattleGalleryEntries = input.barkBattleGalleryEntries.map(
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
);
|
||||
const barkBattleFallbackEntries =
|
||||
input.barkBattleGalleryEntries.length === 0
|
||||
? input.barkBattleWorks
|
||||
.filter((work) => work.status === 'published')
|
||||
.map(mapBarkBattleWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const visualNovelEntries = input.isVisualNovelCreationOpen
|
||||
? input.visualNovelEntries.map(mapVisualNovelWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const latestEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
|
||||
...bigFishEntries,
|
||||
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
|
||||
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
...barkBattleGalleryEntries,
|
||||
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
|
||||
...barkBattleFallbackEntries,
|
||||
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
|
||||
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
|
||||
...visualNovelEntries,
|
||||
...babyObjectMatchEntries,
|
||||
]);
|
||||
const featuredEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
|
||||
...bigFishEntries,
|
||||
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
|
||||
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
...(barkBattleGalleryEntries.length > 0
|
||||
? barkBattleGalleryEntries
|
||||
: barkBattleFallbackEntries),
|
||||
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
|
||||
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
|
||||
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
|
||||
...visualNovelEntries,
|
||||
...babyObjectMatchEntries,
|
||||
]).slice(0, 6);
|
||||
|
||||
return {
|
||||
featuredEntries,
|
||||
latestEntries,
|
||||
};
|
||||
}
|
||||
1351
src/components/platform-entry/platformPublicWorkDetailFlow.test.ts
Normal file
1351
src/components/platform-entry/platformPublicWorkDetailFlow.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1102
src/components/platform-entry/platformPublicWorkDetailFlow.ts
Normal file
1102
src/components/platform-entry/platformPublicWorkDetailFlow.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleDraftLevel,
|
||||
PuzzleGeneratedImageCandidate,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
hasRecoverableGeneratedPuzzleDraft,
|
||||
normalizeRecoveredPuzzleDraftSession,
|
||||
} from './platformPuzzleDraftRecoveryModel';
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
overrides: Partial<PuzzleGeneratedImageCandidate> = {},
|
||||
): PuzzleGeneratedImageCandidate {
|
||||
return {
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/candidate-cover.png',
|
||||
assetId: 'asset-candidate-cover',
|
||||
prompt: '星桥机关',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLevel(overrides: Partial<PuzzleDraftLevel> = {}): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥机关画面',
|
||||
candidates: [buildCandidate()],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraft(overrides: Partial<PuzzleResultDraft> = {}): PuzzleResultDraft {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把碎片拼回原位。',
|
||||
themeTags: ['星桥', '机关', '修复'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [buildLevel()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
anchorPack,
|
||||
draft: buildDraft(),
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withCompleteLevelAssets(
|
||||
overrides: Partial<PuzzleDraftLevel> = {},
|
||||
): PuzzleDraftLevel {
|
||||
return buildLevel({
|
||||
levelSceneImageSrc: '/level-scene.png',
|
||||
uiSpritesheetImageSrc: '/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc: '/level-background.png',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('platformPuzzleDraftRecoveryModel', () => {
|
||||
test('normalizes and marks recovered puzzle draft ready when asset pack is complete', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
levels: [withCompleteLevelAssets()],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(true);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps half-finished draft generating when only cover candidate exists', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(buildSession());
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(false);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires level scene, ui spritesheet and level background assets together', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
withCompleteLevelAssets({
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts object keys as recovered asset references', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
buildLevel({
|
||||
levelSceneImageObjectKey: 'level-scene.png',
|
||||
uiSpritesheetImageObjectKey: 'ui-spritesheet.png',
|
||||
levelBackgroundImageObjectKey: 'level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('leaves sessions without draft unchanged and unrecoverable', () => {
|
||||
const session = buildSession({ draft: null });
|
||||
|
||||
expect(normalizeRecoveredPuzzleDraftSession(session)).toBe(session);
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
|
||||
function normalizeRecoveryText(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function hasPuzzleAssetReference(
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
return Boolean(normalizeRecoveryText(imageSrc) || normalizeRecoveryText(objectKey));
|
||||
}
|
||||
|
||||
function resolvePrimaryPuzzleLevel(session: PuzzleAgentSessionSnapshot) {
|
||||
return session.draft?.levels?.[0] ?? null;
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCandidate(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
primaryLevel: PuzzleDraftLevel | null,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
primaryLevel?.candidates.find((candidate) => candidate.selected) ??
|
||||
primaryLevel?.candidates[0] ??
|
||||
draft.candidates.find((candidate) => candidate.selected) ??
|
||||
draft.candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCoverFields(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const selectedCandidate = resolvePuzzleRecoveryCandidate(
|
||||
session,
|
||||
primaryLevel,
|
||||
);
|
||||
|
||||
return {
|
||||
coverImageSrc:
|
||||
normalizeRecoveryText(draft?.coverImageSrc) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverImageSrc) ??
|
||||
normalizeRecoveryText(selectedCandidate?.imageSrc),
|
||||
coverAssetId:
|
||||
normalizeRecoveryText(draft?.coverAssetId) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverAssetId) ??
|
||||
normalizeRecoveryText(selectedCandidate?.assetId),
|
||||
selectedCandidateId:
|
||||
draft?.selectedCandidateId ??
|
||||
primaryLevel?.selectedCandidateId ??
|
||||
selectedCandidate?.candidateId ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasCompleteGeneratedPuzzleLevelAssets(
|
||||
level: PuzzleDraftLevel | null,
|
||||
coverImageSrc: string | null,
|
||||
) {
|
||||
return Boolean(
|
||||
normalizeRecoveryText(coverImageSrc) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelSceneImageSrc,
|
||||
level?.levelSceneImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.uiSpritesheetImageSrc,
|
||||
level?.uiSpritesheetImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelBackgroundImageSrc,
|
||||
level?.levelBackgroundImageObjectKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRecoverableGeneratedPuzzleDraft(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const { coverImageSrc } = resolvePuzzleRecoveryCoverFields(session);
|
||||
return hasCompleteGeneratedPuzzleLevelAssets(primaryLevel, coverImageSrc);
|
||||
}
|
||||
|
||||
export function normalizeRecoveredPuzzleDraftSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const { coverImageSrc, coverAssetId, selectedCandidateId } =
|
||||
resolvePuzzleRecoveryCoverFields(session);
|
||||
const nextLevels = draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
coverImageSrc: normalizeRecoveryText(level.coverImageSrc)
|
||||
? level.coverImageSrc
|
||||
: coverImageSrc,
|
||||
coverAssetId: normalizeRecoveryText(level.coverAssetId)
|
||||
? level.coverAssetId
|
||||
: coverAssetId,
|
||||
selectedCandidateId:
|
||||
level.selectedCandidateId ?? selectedCandidateId,
|
||||
}
|
||||
: level,
|
||||
);
|
||||
const nextSession = {
|
||||
...session,
|
||||
draft: {
|
||||
...draft,
|
||||
coverImageSrc,
|
||||
coverAssetId,
|
||||
selectedCandidateId,
|
||||
levels: nextLevels,
|
||||
},
|
||||
} satisfies PuzzleAgentSessionSnapshot;
|
||||
const isRecoverable = hasRecoverableGeneratedPuzzleDraft(nextSession);
|
||||
|
||||
if (!isRecoverable) {
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextSession,
|
||||
draft: {
|
||||
...nextSession.draft,
|
||||
generationStatus: 'ready',
|
||||
levels: nextSession.draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
generationStatus: 'ready',
|
||||
}
|
||||
: level,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
buildPuzzleSessionIdFromProfileId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
describe('platformPuzzleIdentityModel', () => {
|
||||
test('builds stable puzzle result identities from a session id', () => {
|
||||
expect(buildPuzzleResultProfileId(' puzzle-session-ocean ')).toBe(
|
||||
'puzzle-profile-ocean',
|
||||
);
|
||||
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-work-ocean',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps legacy suffix inputs usable', () => {
|
||||
expect(buildPuzzleResultProfileId('ocean')).toBe('puzzle-profile-ocean');
|
||||
expect(buildPuzzleResultWorkId('ocean')).toBe('puzzle-work-ocean');
|
||||
});
|
||||
|
||||
test('builds draft runtime session ids from profile ids', () => {
|
||||
expect(buildPuzzleSessionIdFromProfileId(' puzzle-profile-ocean ')).toBe(
|
||||
'puzzle-session-ocean',
|
||||
);
|
||||
expect(buildPuzzleSessionIdFromProfileId('puzzle-work-ocean')).toBeNull();
|
||||
expect(buildPuzzleSessionIdFromProfileId('puzzle-profile-')).toBeNull();
|
||||
});
|
||||
});
|
||||
36
src/components/platform-entry/platformPuzzleIdentityModel.ts
Normal file
36
src/components/platform-entry/platformPuzzleIdentityModel.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** 收口拼图草稿在 session/profile/work 之间的稳定身份互推规则。 */
|
||||
export function buildPuzzleResultProfileId(
|
||||
sessionId: string | null | undefined,
|
||||
) {
|
||||
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
|
||||
return stableSuffix ? `puzzle-profile-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
export function buildPuzzleResultWorkId(sessionId: string | null | undefined) {
|
||||
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
|
||||
return stableSuffix ? `puzzle-work-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
export function buildPuzzleSessionIdFromProfileId(
|
||||
profileId: string | null | undefined,
|
||||
) {
|
||||
const normalizedProfileId = profileId?.trim();
|
||||
if (!normalizedProfileId?.startsWith('puzzle-profile-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length);
|
||||
return stableSuffix ? `puzzle-session-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
function resolvePuzzleSessionStableSuffix(
|
||||
sessionId: string | null | undefined,
|
||||
) {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
return normalizedSessionId.startsWith('puzzle-session-')
|
||||
? normalizedSessionId.slice('puzzle-session-'.length)
|
||||
: normalizedSessionId;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleRunSnapshot,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
|
||||
|
||||
const currentLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '本地玩家',
|
||||
elapsedMs: 12000,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
|
||||
const serviceLevelLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '服务端玩家',
|
||||
elapsedMs: 9000,
|
||||
},
|
||||
];
|
||||
|
||||
const serviceRunLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 2,
|
||||
nickname: '全局玩家',
|
||||
elapsedMs: 15000,
|
||||
},
|
||||
];
|
||||
|
||||
function buildPuzzleLevel(
|
||||
overrides: Partial<PuzzleRuntimeLevelSnapshot> = {},
|
||||
): PuzzleRuntimeLevelSnapshot {
|
||||
return {
|
||||
runId: 'run-current',
|
||||
levelIndex: 0,
|
||||
levelId: 'level-1',
|
||||
gridSize: 3,
|
||||
profileId: 'puzzle-profile-current',
|
||||
levelName: '星桥机关',
|
||||
authorDisplayName: '玩家',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: '/cover.png',
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
pieces: [],
|
||||
mergedGroups: [],
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: true,
|
||||
},
|
||||
status: 'cleared',
|
||||
startedAtMs: 1000,
|
||||
clearedAtMs: 13000,
|
||||
elapsedMs: 12000,
|
||||
timeLimitMs: 120000,
|
||||
remainingMs: 108000,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
leaderboardEntries: currentLeaderboard,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleRun(
|
||||
overrides: Partial<PuzzleRunSnapshot> = {},
|
||||
): PuzzleRunSnapshot {
|
||||
return {
|
||||
runId: 'run-current',
|
||||
entryProfileId: 'puzzle-profile-current',
|
||||
clearedLevelCount: 1,
|
||||
currentLevelIndex: 0,
|
||||
currentGridSize: 3,
|
||||
playedProfileIds: ['puzzle-profile-current'],
|
||||
previousLevelTags: ['星桥'],
|
||||
currentLevel: buildPuzzleLevel(),
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: null,
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: currentLeaderboard,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformPuzzleRuntimeStateModel', () => {
|
||||
test('keeps current run when either current level is missing', () => {
|
||||
const currentRun = buildPuzzleRun({ currentLevel: null });
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, buildPuzzleRun()),
|
||||
).toBe(currentRun);
|
||||
|
||||
const serviceRun = buildPuzzleRun({ currentLevel: null });
|
||||
const playableCurrentRun = buildPuzzleRun();
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(playableCurrentRun, serviceRun),
|
||||
).toBe(playableCurrentRun);
|
||||
});
|
||||
|
||||
test('merges service leaderboard and next-level handoff without replacing local level state', () => {
|
||||
const currentRun = buildPuzzleRun({
|
||||
clearedLevelCount: 2,
|
||||
currentLevel: buildPuzzleLevel({
|
||||
runId: 'run-current',
|
||||
status: 'cleared',
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
pieces: [
|
||||
{
|
||||
pieceId: 'piece-local',
|
||||
correctRow: 0,
|
||||
correctCol: 0,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
mergedGroupId: null,
|
||||
},
|
||||
],
|
||||
mergedGroups: [],
|
||||
selectedPieceId: 'piece-local',
|
||||
allTilesResolved: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const serviceRun = buildPuzzleRun({
|
||||
runId: 'run-service',
|
||||
entryProfileId: 'puzzle-profile-service',
|
||||
clearedLevelCount: 1,
|
||||
recommendedNextProfileId: 'next-recommended',
|
||||
nextLevelMode: 'similarWorks',
|
||||
nextLevelProfileId: 'next-profile',
|
||||
nextLevelId: 'next-level',
|
||||
recommendedNextWorks: [
|
||||
{
|
||||
profileId: 'next-profile',
|
||||
levelName: '月桥机关',
|
||||
authorDisplayName: '推荐作者',
|
||||
themeTags: ['月桥'],
|
||||
coverImageSrc: '/next-cover.png',
|
||||
similarityScore: 0.91,
|
||||
},
|
||||
],
|
||||
currentLevel: buildPuzzleLevel({
|
||||
runId: 'run-service-level',
|
||||
status: 'playing',
|
||||
leaderboardEntries: serviceLevelLeaderboard,
|
||||
}),
|
||||
});
|
||||
|
||||
const merged = mergePuzzleServiceRuntimeState(currentRun, serviceRun);
|
||||
|
||||
expect(merged.runId).toBe('run-service');
|
||||
expect(merged.entryProfileId).toBe('puzzle-profile-service');
|
||||
expect(merged.clearedLevelCount).toBe(2);
|
||||
expect(merged.recommendedNextProfileId).toBe('next-recommended');
|
||||
expect(merged.nextLevelMode).toBe('similarWorks');
|
||||
expect(merged.nextLevelProfileId).toBe('next-profile');
|
||||
expect(merged.nextLevelId).toBe('next-level');
|
||||
expect(merged.recommendedNextWorks).toEqual(serviceRun.recommendedNextWorks);
|
||||
expect(merged.leaderboardEntries).toEqual(serviceLevelLeaderboard);
|
||||
expect(merged.currentLevel?.status).toBe('cleared');
|
||||
expect(merged.currentLevel?.board.pieces).toEqual(
|
||||
currentRun.currentLevel?.board.pieces,
|
||||
);
|
||||
expect(merged.currentLevel?.leaderboardEntries).toEqual(
|
||||
serviceLevelLeaderboard,
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to service run leaderboard, then current level leaderboard', () => {
|
||||
const currentRun = buildPuzzleRun();
|
||||
const serviceRun = buildPuzzleRun({
|
||||
currentLevel: buildPuzzleLevel({ leaderboardEntries: [] }),
|
||||
leaderboardEntries: serviceRunLeaderboard,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, serviceRun).currentLevel
|
||||
?.leaderboardEntries,
|
||||
).toEqual(serviceRunLeaderboard);
|
||||
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, {
|
||||
...serviceRun,
|
||||
leaderboardEntries: [],
|
||||
}).currentLevel?.leaderboardEntries,
|
||||
).toEqual(currentLeaderboard);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
|
||||
export function mergePuzzleServiceRuntimeState(
|
||||
currentRun: PuzzleRunSnapshot,
|
||||
serviceRun: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!currentRun.currentLevel || !serviceRun.currentLevel) {
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
const serviceLevel = serviceRun.currentLevel;
|
||||
const leaderboardEntries =
|
||||
serviceLevel.leaderboardEntries.length > 0
|
||||
? serviceLevel.leaderboardEntries
|
||||
: serviceRun.leaderboardEntries;
|
||||
|
||||
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
|
||||
return {
|
||||
...currentRun,
|
||||
runId: serviceRun.runId,
|
||||
entryProfileId: serviceRun.entryProfileId,
|
||||
clearedLevelCount: Math.max(
|
||||
currentRun.clearedLevelCount,
|
||||
serviceRun.clearedLevelCount,
|
||||
),
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
nextLevelId: serviceRun.nextLevelId,
|
||||
recommendedNextWorks: serviceRun.recommendedNextWorks,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentRun.currentLevel,
|
||||
leaderboardEntries:
|
||||
leaderboardEntries.length > 0
|
||||
? leaderboardEntries
|
||||
: currentRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolvePlatformRecommendRuntimeAuthPlan,
|
||||
shouldUsePlatformRecommendRuntimeGuestAuth,
|
||||
} from './platformRecommendRuntimeAuthModel';
|
||||
|
||||
test('uses runtime guest auth for anonymous embedded recommendation runtime', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses background auth for signed-in embedded recommendation runtime', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: 'user-1',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'background',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses background auth when embedded runtime has only a stored access token', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: true,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'background',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not alter auth for non-embedded runtime launches by default', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses isolated guest auth for anonymous puzzle isolated launch', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to default puzzle auth when isolated launch has account auth', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: 'user-1',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('guest auth decision trims user id before treating account as signed in', () => {
|
||||
expect(
|
||||
shouldUsePlatformRecommendRuntimeGuestAuth({
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: ' ',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export type PlatformRecommendRuntimeRequestKind =
|
||||
| 'none'
|
||||
| 'background'
|
||||
| 'runtime-guest';
|
||||
|
||||
export type PlatformPuzzleRuntimeAuthMode = 'default' | 'isolated';
|
||||
|
||||
export type PlatformRecommendRuntimeAuthPlan = {
|
||||
requestKind: PlatformRecommendRuntimeRequestKind;
|
||||
puzzleRuntimeAuthMode: PlatformPuzzleRuntimeAuthMode;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeAuthInput = {
|
||||
embedded?: boolean;
|
||||
allowRuntimeGuestAuth?: boolean;
|
||||
authUserId?: string | null;
|
||||
hasStoredAccessToken?: boolean;
|
||||
};
|
||||
|
||||
function hasAccountAuth(input: {
|
||||
authUserId?: string | null;
|
||||
hasStoredAccessToken?: boolean;
|
||||
}) {
|
||||
return Boolean(input.authUserId?.trim() || input.hasStoredAccessToken);
|
||||
}
|
||||
|
||||
export function shouldUsePlatformRecommendRuntimeGuestAuth(
|
||||
input: Pick<
|
||||
PlatformRecommendRuntimeAuthInput,
|
||||
'allowRuntimeGuestAuth' | 'authUserId' | 'hasStoredAccessToken'
|
||||
>,
|
||||
) {
|
||||
return Boolean(input.allowRuntimeGuestAuth) && !hasAccountAuth(input);
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeAuthPlan(
|
||||
input: PlatformRecommendRuntimeAuthInput,
|
||||
): PlatformRecommendRuntimeAuthPlan {
|
||||
const embedded = Boolean(input.embedded);
|
||||
const allowRuntimeGuestAuth = input.allowRuntimeGuestAuth ?? embedded;
|
||||
const useRuntimeGuestAuth = shouldUsePlatformRecommendRuntimeGuestAuth({
|
||||
allowRuntimeGuestAuth,
|
||||
authUserId: input.authUserId,
|
||||
hasStoredAccessToken: input.hasStoredAccessToken,
|
||||
});
|
||||
|
||||
if (useRuntimeGuestAuth) {
|
||||
return {
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestKind: embedded ? 'background' : 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { createRpgCreationPublishedProfileFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
buildPlatformRpgAgentResultPublishGateView,
|
||||
type PlatformRpgAgentResultBlockerView,
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel,
|
||||
} from './platformRpgAgentResultPreviewModel';
|
||||
|
||||
function buildProfile(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): CustomWorldProfile {
|
||||
return {
|
||||
...createRpgCreationPublishedProfileFixture(),
|
||||
worldHook: '潮雾列岛旧灯塔重新点亮。',
|
||||
playerPremise: '玩家从回潮旧灯塔切入沉船旧案。',
|
||||
coreConflicts: ['守灯会与沉船旧案的冲突'],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
const missingWorldHookBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_world_hook',
|
||||
message: '缺少世界钩子',
|
||||
};
|
||||
const missingPlayerPremiseBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_player_premise',
|
||||
message: '缺少玩家前提',
|
||||
};
|
||||
const missingCoreConflictBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_core_conflict',
|
||||
message: '缺少核心冲突',
|
||||
};
|
||||
const missingMainChapterBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_main_chapter',
|
||||
message: '缺少主章节',
|
||||
};
|
||||
const missingFirstActBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_first_act',
|
||||
message: '缺少首幕',
|
||||
};
|
||||
const structuralBlockers: PlatformRpgAgentResultBlockerView[] = [
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingCoreConflictBlocker,
|
||||
missingMainChapterBlocker,
|
||||
missingFirstActBlocker,
|
||||
];
|
||||
|
||||
describe('platformRpgAgentResultPreviewModel', () => {
|
||||
test('uses fallback blockers and publish readiness without a profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
null,
|
||||
structuralBlockers.slice(0, 2),
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['缺少世界钩子', '缺少玩家前提'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('filters structural blockers already satisfied by the profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile(),
|
||||
[
|
||||
...structuralBlockers,
|
||||
{
|
||||
code: 'future_blocker',
|
||||
message: '未知服务端阻断',
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['未知服务端阻断'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unresolved structural blockers when profile fields are empty', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: null,
|
||||
anchorContent: null,
|
||||
coreConflicts: [],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: [],
|
||||
sceneChapters: [],
|
||||
}),
|
||||
structuralBlockers,
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: structuralBlockers.map((entry) => entry.message),
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves structural blockers from nested profile compatibility fields', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: {
|
||||
worldHook: '旧灯塔潮路重新开启。',
|
||||
},
|
||||
anchorContent: {
|
||||
playerEntryPoint: {
|
||||
openingProblem: '玩家被卷入沉船旧案。',
|
||||
},
|
||||
},
|
||||
coreConflicts: [''],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: null,
|
||||
sceneChapters: [
|
||||
{
|
||||
acts: [{}],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingFirstActBlocker,
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: [],
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps preview source to result label', () => {
|
||||
expect(resolvePlatformRpgAgentResultPreviewSourceLabel(null)).toBeNull();
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('published_profile'),
|
||||
).toBe('已发布世界');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('session_preview'),
|
||||
).toBe('会话预览');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('future_source'),
|
||||
).toBe(
|
||||
'服务端预览',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { RpgCreationPreviewSource } from '../../../packages/shared/src/contracts/rpgCreationPreview';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformRpgAgentResultBlockerView = {
|
||||
code?: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PlatformRpgAgentResultPublishGateView = {
|
||||
blockers: string[];
|
||||
publishReady: boolean;
|
||||
};
|
||||
|
||||
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_world_hook',
|
||||
'publish_missing_player_premise',
|
||||
'publish_missing_core_conflict',
|
||||
'publish_missing_main_chapter',
|
||||
'publish_missing_first_act',
|
||||
]);
|
||||
|
||||
function readProfileTextField(
|
||||
profile: CustomWorldProfile | null,
|
||||
paths: string[],
|
||||
) {
|
||||
for (const path of paths) {
|
||||
let current: unknown = profile;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
if (typeof current === 'string' && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) {
|
||||
const value = profile
|
||||
? (profile as unknown as Record<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value)
|
||||
? value.some((entry) => typeof entry === 'string' && entry.trim())
|
||||
: false;
|
||||
}
|
||||
|
||||
function hasProfileArray(profile: CustomWorldProfile | null, key: string) {
|
||||
const value = profile
|
||||
? (profile as unknown as Record<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
||||
function hasSceneAct(profile: CustomWorldProfile | null) {
|
||||
const rawProfile = profile as unknown as Record<string, unknown> | null;
|
||||
const chapters =
|
||||
rawProfile &&
|
||||
(Array.isArray(rawProfile.sceneChapterBlueprints)
|
||||
? rawProfile.sceneChapterBlueprints
|
||||
: Array.isArray(rawProfile.sceneChapters)
|
||||
? rawProfile.sceneChapters
|
||||
: []);
|
||||
return Array.isArray(chapters)
|
||||
? chapters.some((chapter) => {
|
||||
const acts =
|
||||
chapter && typeof chapter === 'object'
|
||||
? (chapter as Record<string, unknown>).acts
|
||||
: null;
|
||||
return Array.isArray(acts) && acts.length > 0;
|
||||
})
|
||||
: false;
|
||||
}
|
||||
|
||||
function isAgentResultStructuralBlockerResolved(
|
||||
profile: CustomWorldProfile,
|
||||
code: string | null | undefined,
|
||||
) {
|
||||
if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code === 'publish_missing_world_hook') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'worldHook',
|
||||
'creatorIntent.worldHook',
|
||||
'anchorContent.worldPromise',
|
||||
'anchorContent.worldPromise.hook',
|
||||
'settingText',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_player_premise') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'playerPremise',
|
||||
'creatorIntent.playerPremise',
|
||||
'anchorContent.playerEntryPoint',
|
||||
'anchorContent.playerEntryPoint.openingIdentity',
|
||||
'anchorContent.playerEntryPoint.openingProblem',
|
||||
'anchorContent.playerEntryPoint.entryMotivation',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_core_conflict') {
|
||||
return hasProfileTextArray(profile, 'coreConflicts');
|
||||
}
|
||||
if (code === 'publish_missing_main_chapter') {
|
||||
return (
|
||||
hasProfileArray(profile, 'chapters') ||
|
||||
hasProfileArray(profile, 'sceneChapterBlueprints') ||
|
||||
hasProfileArray(profile, 'sceneChapters')
|
||||
);
|
||||
}
|
||||
return hasSceneAct(profile);
|
||||
}
|
||||
|
||||
export function buildPlatformRpgAgentResultPublishGateView(
|
||||
profile: CustomWorldProfile | null,
|
||||
fallbackBlockers: PlatformRpgAgentResultBlockerView[],
|
||||
fallbackPublishReady: boolean,
|
||||
): PlatformRpgAgentResultPublishGateView {
|
||||
if (!profile) {
|
||||
return {
|
||||
blockers: fallbackBlockers.map((entry) => entry.message),
|
||||
publishReady: fallbackPublishReady,
|
||||
};
|
||||
}
|
||||
|
||||
const blockers = fallbackBlockers
|
||||
.filter(
|
||||
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
|
||||
return {
|
||||
blockers,
|
||||
publishReady: blockers.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformRpgAgentResultPreviewSourceLabel(
|
||||
source: RpgCreationPreviewSource | string | null | undefined,
|
||||
) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (source === 'published_profile') {
|
||||
return '已发布世界';
|
||||
}
|
||||
if (source === 'session_preview') {
|
||||
return '会话预览';
|
||||
}
|
||||
return '服务端预览';
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import {
|
||||
type MissingCreationStateParams,
|
||||
resolveSelectionStageAfterMissingCreationState,
|
||||
resolveSelectionStageAfterProtectedDataLoss,
|
||||
} from './platformSelectionStageModel';
|
||||
|
||||
describe('platformSelectionStageModel', () => {
|
||||
test('keeps public and workspace stages after protected data loss', () => {
|
||||
const stableStages: SelectionStage[] = [
|
||||
'platform',
|
||||
'work-detail',
|
||||
'detail',
|
||||
'agent-workspace',
|
||||
'big-fish-agent-workspace',
|
||||
'match3d-agent-workspace',
|
||||
'square-hole-agent-workspace',
|
||||
'jump-hop-workspace',
|
||||
'wooden-fish-workspace',
|
||||
'puzzle-agent-workspace',
|
||||
'bark-battle-workspace',
|
||||
'visual-novel-agent-workspace',
|
||||
'baby-object-match-workspace',
|
||||
'creative-agent-workspace',
|
||||
'puzzle-gallery-detail',
|
||||
];
|
||||
|
||||
stableStages.forEach((stage) => {
|
||||
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(stage);
|
||||
});
|
||||
});
|
||||
|
||||
test('resets private result, generating, runtime and profile stages to platform', () => {
|
||||
const resetStages: SelectionStage[] = [
|
||||
'profile-feedback',
|
||||
'big-fish-generating',
|
||||
'big-fish-result',
|
||||
'big-fish-runtime',
|
||||
'match3d-generating',
|
||||
'match3d-result',
|
||||
'match3d-runtime',
|
||||
'square-hole-generating',
|
||||
'square-hole-result',
|
||||
'square-hole-runtime',
|
||||
'jump-hop-generating',
|
||||
'jump-hop-result',
|
||||
'jump-hop-runtime',
|
||||
'jump-hop-gallery-detail',
|
||||
'wooden-fish-generating',
|
||||
'wooden-fish-result',
|
||||
'wooden-fish-runtime',
|
||||
'visual-novel-generating',
|
||||
'visual-novel-result',
|
||||
'visual-novel-gallery-detail',
|
||||
'visual-novel-runtime',
|
||||
'baby-object-match-generating',
|
||||
'baby-object-match-result',
|
||||
'baby-object-match-runtime',
|
||||
'baby-love-drawing-runtime',
|
||||
'puzzle-generating',
|
||||
'puzzle-onboarding',
|
||||
'puzzle-result',
|
||||
'puzzle-runtime',
|
||||
'custom-world-generating',
|
||||
'custom-world-result',
|
||||
'bark-battle-generating',
|
||||
'bark-battle-result',
|
||||
'bark-battle-runtime',
|
||||
];
|
||||
|
||||
resetStages.forEach((stage) => {
|
||||
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(
|
||||
'platform',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves missing session draft result stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'match3d-result',
|
||||
match3d: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('match3d-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'square-hole-result',
|
||||
squareHole: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('square-hole-agent-workspace');
|
||||
});
|
||||
|
||||
test('resolves missing session run stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'match3d-runtime',
|
||||
match3d: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('match3d-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'square-hole-runtime',
|
||||
squareHole: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: true,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('square-hole-result');
|
||||
});
|
||||
|
||||
test('resolves visual novel and baby object missing state stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-result',
|
||||
visualNovel: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-runtime',
|
||||
visualNovel: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasWork: true,
|
||||
hasWorkDraft: true,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-gallery-detail',
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'baby-object-match-result',
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
|
||||
}),
|
||||
),
|
||||
).toBe('baby-object-match-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'baby-object-match-runtime',
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
});
|
||||
|
||||
test('keeps stages when required creation state exists', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: true },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-runtime');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-gallery-detail',
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: true,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-gallery-detail');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'platform',
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
});
|
||||
});
|
||||
|
||||
function buildMissingCreationStateParams(
|
||||
overrides: Partial<MissingCreationStateParams> = {},
|
||||
): MissingCreationStateParams {
|
||||
return {
|
||||
stage: 'platform',
|
||||
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
match3d: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
squareHole: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
153
src/components/platform-entry/platformSelectionStageModel.ts
Normal file
153
src/components/platform-entry/platformSelectionStageModel.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
'profile-feedback': false,
|
||||
'work-detail': true,
|
||||
detail: true,
|
||||
'agent-workspace': true,
|
||||
'big-fish-agent-workspace': true,
|
||||
'big-fish-generating': false,
|
||||
'big-fish-result': false,
|
||||
'big-fish-runtime': false,
|
||||
'match3d-agent-workspace': true,
|
||||
'match3d-generating': false,
|
||||
'match3d-result': false,
|
||||
'match3d-runtime': false,
|
||||
'square-hole-agent-workspace': true,
|
||||
'square-hole-generating': false,
|
||||
'square-hole-result': false,
|
||||
'square-hole-runtime': false,
|
||||
'jump-hop-workspace': true,
|
||||
'jump-hop-generating': false,
|
||||
'jump-hop-result': false,
|
||||
'jump-hop-runtime': false,
|
||||
'jump-hop-gallery-detail': false,
|
||||
'bark-battle-workspace': true,
|
||||
'bark-battle-generating': false,
|
||||
'bark-battle-result': false,
|
||||
'bark-battle-runtime': false,
|
||||
'wooden-fish-workspace': true,
|
||||
'wooden-fish-generating': false,
|
||||
'wooden-fish-result': false,
|
||||
'wooden-fish-runtime': false,
|
||||
'creative-agent-workspace': true,
|
||||
'visual-novel-agent-workspace': true,
|
||||
'visual-novel-generating': false,
|
||||
'visual-novel-result': false,
|
||||
'visual-novel-gallery-detail': false,
|
||||
'visual-novel-runtime': false,
|
||||
'baby-object-match-workspace': true,
|
||||
'baby-object-match-generating': false,
|
||||
'baby-object-match-result': false,
|
||||
'baby-object-match-runtime': false,
|
||||
'baby-love-drawing-runtime': false,
|
||||
'puzzle-agent-workspace': true,
|
||||
'puzzle-generating': false,
|
||||
'puzzle-onboarding': false,
|
||||
'puzzle-result': false,
|
||||
'puzzle-gallery-detail': true,
|
||||
'puzzle-runtime': false,
|
||||
'custom-world-generating': false,
|
||||
'custom-world-result': false,
|
||||
} as const satisfies Record<SelectionStage, boolean>;
|
||||
|
||||
export function resolveSelectionStageAfterProtectedDataLoss(
|
||||
stage: SelectionStage,
|
||||
): SelectionStage {
|
||||
return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform';
|
||||
}
|
||||
|
||||
type SessionDraftRunState = {
|
||||
hasSession: boolean;
|
||||
hasSessionDraft: boolean;
|
||||
hasRun: boolean;
|
||||
};
|
||||
|
||||
type VisualNovelCreationState = {
|
||||
hasSession: boolean;
|
||||
hasSessionDraft: boolean;
|
||||
hasWork: boolean;
|
||||
hasWorkDraft: boolean;
|
||||
hasRun: boolean;
|
||||
};
|
||||
|
||||
type BabyObjectMatchCreationState = {
|
||||
hasDraft: boolean;
|
||||
hasFormPayload: boolean;
|
||||
};
|
||||
|
||||
export type MissingCreationStateParams = {
|
||||
stage: SelectionStage;
|
||||
bigFish: SessionDraftRunState;
|
||||
match3d: SessionDraftRunState;
|
||||
squareHole: SessionDraftRunState;
|
||||
visualNovel: VisualNovelCreationState;
|
||||
babyObjectMatch: BabyObjectMatchCreationState;
|
||||
};
|
||||
|
||||
export function resolveSelectionStageAfterMissingCreationState(
|
||||
params: MissingCreationStateParams,
|
||||
): SelectionStage {
|
||||
const { stage } = params;
|
||||
|
||||
if (stage === 'big-fish-result' && !params.bigFish.hasSessionDraft) {
|
||||
return params.bigFish.hasSession ? 'big-fish-agent-workspace' : 'platform';
|
||||
}
|
||||
if (stage === 'big-fish-runtime' && !params.bigFish.hasRun) {
|
||||
return params.bigFish.hasSessionDraft ? 'big-fish-result' : 'platform';
|
||||
}
|
||||
|
||||
if (stage === 'match3d-result' && !params.match3d.hasSessionDraft) {
|
||||
return params.match3d.hasSession ? 'match3d-agent-workspace' : 'platform';
|
||||
}
|
||||
if (stage === 'match3d-runtime' && !params.match3d.hasRun) {
|
||||
return params.match3d.hasSessionDraft ? 'match3d-result' : 'platform';
|
||||
}
|
||||
|
||||
if (stage === 'square-hole-result' && !params.squareHole.hasSessionDraft) {
|
||||
return params.squareHole.hasSession
|
||||
? 'square-hole-agent-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'square-hole-runtime' && !params.squareHole.hasRun) {
|
||||
return params.squareHole.hasSessionDraft
|
||||
? 'square-hole-result'
|
||||
: 'platform';
|
||||
}
|
||||
|
||||
if (
|
||||
stage === 'visual-novel-result' &&
|
||||
!params.visualNovel.hasSessionDraft &&
|
||||
!params.visualNovel.hasWorkDraft
|
||||
) {
|
||||
return params.visualNovel.hasSession
|
||||
? 'visual-novel-agent-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'visual-novel-runtime' && !params.visualNovel.hasRun) {
|
||||
return params.visualNovel.hasSessionDraft || params.visualNovel.hasWorkDraft
|
||||
? 'visual-novel-result'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'visual-novel-gallery-detail' && !params.visualNovel.hasWork) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
if (
|
||||
stage === 'baby-object-match-result' &&
|
||||
!params.babyObjectMatch.hasDraft
|
||||
) {
|
||||
return params.babyObjectMatch.hasFormPayload
|
||||
? 'baby-object-match-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (
|
||||
stage === 'baby-object-match-runtime' &&
|
||||
!params.babyObjectMatch.hasDraft
|
||||
) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
return stage;
|
||||
}
|
||||
Reference in New Issue
Block a user