Merge codex/sse-stream-architecture into architecture adjustment

This commit is contained in:
2026-06-07 00:23:42 +08:00
136 changed files with 22344 additions and 7543 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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: '海岛抓大鹅',
});
});
});

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
'服务端预览',
);
});
});

View File

@@ -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 '服务端预览';
}

View File

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

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