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
@@ -10,7 +10,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
const noopCreateType = () => {};
@@ -4,7 +4,7 @@ import { expect, test } from 'vitest';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
const noopCreateType = () => {};
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -0,0 +1,128 @@
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import {
CustomWorldCreationHub as CustomWorldCreationHubView,
} from './CustomWorldCreationHub';
type ShelfBuilderParams = Parameters<typeof buildCreationWorkShelfItems>[0];
type HubViewProps = Parameters<typeof CustomWorldCreationHubView>[0];
type LegacyCustomWorldCreationHubProps = Omit<HubViewProps, 'shelfItems'> &
Partial<
Omit<ShelfBuilderParams, 'rpgItems' | 'bigFishItems' | 'puzzleItems'>
> & {
shelfItems?: CreationWorkShelfItem[];
items?: ShelfBuilderParams['rpgItems'];
bigFishItems?: ShelfBuilderParams['bigFishItems'];
puzzleItems?: ShelfBuilderParams['puzzleItems'];
onOpenDraft?: ShelfBuilderParams['onOpenRpgDraft'];
onEnterPublished?: ShelfBuilderParams['onEnterRpgPublished'];
onDeletePublished?: ShelfBuilderParams['onDeleteRpg'] | null;
getWorkState?: ShelfBuilderParams['getItemState'];
};
/** 测试用 Adapter:旧 fixture 先转成 shelfItems,生产 Hub Interface 保持窄面。 */
export function CustomWorldCreationHub({
shelfItems,
items = [],
rpgLibraryEntries = [],
bigFishItems = [],
match3dItems = [],
squareHoleItems = [],
jumpHopItems = [],
woodenFishItems = [],
puzzleItems = [],
babyObjectMatchItems = [],
barkBattleItems = [],
visualNovelItems = [],
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
onOpenBigFishDetail,
onDeleteBigFish,
onOpenMatch3DDetail,
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
getWorkState,
creationTypes,
...props
}: LegacyCustomWorldCreationHubProps) {
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const resolvedShelfItems =
shelfItems ??
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish,
onOpenMatch3DDetail,
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState: getItemState ?? getWorkState,
});
return (
<CustomWorldCreationHubView
{...props}
creationTypes={creationTypes}
shelfItems={resolvedShelfItems}
/>
);
}
@@ -1,30 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
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 { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type { CustomWorldProfile } from '../../types';
import type {
PlatformCreationTypeCard,
PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
import {
CustomWorldCreationStartCard,
@@ -48,7 +32,7 @@ type WorkMetricSnapshot = Record<
>;
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
shelfItems: CreationWorkShelfItem[];
loading: boolean;
error: string | null;
onRetry: () => void;
@@ -56,48 +40,8 @@ type CustomWorldCreationHubProps = {
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onCreateType: (type: PlatformCreationTypeId) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
match3dItems?: Match3DWorkSummary[];
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
squareHoleItems?: SquareHoleWorkSummary[];
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
jumpHopItems?: JumpHopWorkSummaryResponse[];
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
woodenFishItems?: WoodenFishWorkSummaryResponse[];
onOpenWoodenFishDetail?:
| ((item: WoodenFishWorkSummaryResponse) => void)
| null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
onOpenPuzzleClearDetail?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
onDeletePuzzleClear?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
barkBattleItems?: BarkBattleWorkSummary[];
onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null;
onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
getWorkState?: (
item: CreationWorkShelfItem,
) => CreationWorkShelfRuntimeState | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
recentWorkItems?: CreationWorkShelfItem[];
@@ -169,7 +113,7 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
export function CustomWorldCreationHub({
items,
shelfItems,
loading,
error,
onRetry,
@@ -177,147 +121,14 @@ export function CustomWorldCreationHub({
entryConfig,
creationTypes,
onCreateType,
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onDeleteBigFish = null,
match3dItems = [],
onOpenMatch3DDetail,
onDeleteMatch3D = null,
squareHoleItems = [],
onOpenSquareHoleDetail,
onDeleteSquareHole = null,
jumpHopItems = [],
onOpenJumpHopDetail,
onDeleteJumpHop = null,
woodenFishItems = [],
onOpenWoodenFishDetail = null,
onDeleteWoodenFish = null,
puzzleClearItems = [],
onOpenPuzzleClearDetail = null,
onDeletePuzzleClear = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
barkBattleItems = [],
onOpenBarkBattleDetail = null,
onDeleteBarkBattle = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
getWorkState,
onOpenShelfItem,
recentWorkItems: recentWorkSourceItems,
mode = 'full',
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const shelfItems = useMemo(
() =>
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleClearItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzleClear: Boolean(onDeletePuzzleClear),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish: onDeleteBigFish ?? undefined,
onOpenMatch3DDetail,
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
onOpenSquareHoleDetail,
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
onOpenPuzzleClearDetail: onOpenPuzzleClearDetail ?? undefined,
onDeletePuzzleClear: onDeletePuzzleClear ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
}),
[
bigFishItems,
isSquareHoleCreationVisible,
babyObjectMatchItems,
barkBattleItems,
items,
match3dItems,
onDeleteBigFish,
onDeleteMatch3D,
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel,
onDeleteJumpHop,
onDeleteWoodenFish,
onDeletePuzzleClear,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail,
onOpenBarkBattleDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onOpenWoodenFishDetail,
onOpenPuzzleClearDetail,
onEnterPublished,
getWorkState,
puzzleClearItems,
puzzleItems,
rpgLibraryEntries,
onOpenJumpHopDetail,
jumpHopItems,
woodenFishItems,
visualNovelItems,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
readWorkMetricSnapshot(),
);
@@ -355,47 +166,8 @@ export function CustomWorldCreationHub({
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'baby-object-match':
onOpenBabyObjectMatchDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'bark-battle':
onOpenBarkBattleDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'jump-hop':
onOpenJumpHopDetail?.(item.source.item);
return;
case 'wooden-fish':
onOpenWoodenFishDetail?.(item.source.item);
return;
case 'puzzle-clear':
onOpenPuzzleClearDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
// 中文注释:玩法差异由 Work Shelf Adapter 承载,Hub 只负责响应卡片点击。
item.actions.open();
}
function buildDeleteAction(item: CreationWorkShelfItem) {
@@ -5,10 +5,11 @@ import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
buildCreationWorkShelfItems,
buildCreationWorkShelfItemsFromSources,
type CreationWorkShelfItem,
getCreationWorkShelfItemTime,
hasBarkBattleRequiredImages,
isPersistedBarkBattleDraftGenerating,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
@@ -56,6 +57,86 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItemsFromSources flattens source adapters and applies runtime state', () => {
const [staleRpgItem] = buildCreationWorkShelfItems({
rpgItems: [
{
workId: 'draft:rpg-source-adapter',
sourceType: 'agent_session',
status: 'draft',
title: '旧 RPG 草稿',
subtitle: '待完善',
summary: '通过 source adapter 输入。',
coverImageSrc: null,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '待完善',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'rpg-source-adapter',
profileId: null,
canResume: true,
canEnterWorld: false,
},
],
bigFishItems: [],
puzzleItems: [],
});
const [freshPuzzleItem] = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:source-adapter',
profileId: 'puzzle-source-adapter',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '新拼图',
summary: '新近拼图。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-03T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
},
],
});
const items = buildCreationWorkShelfItemsFromSources({
sources: [
{
kind: 'rpg',
buildItems: () => (staleRpgItem ? [staleRpgItem] : []),
},
{
kind: 'puzzle',
buildItems: () => (freshPuzzleItem ? [freshPuzzleItem] : []),
},
],
getItemState: (item) =>
item.id === staleRpgItem?.id
? {
isGenerating: true,
hasUnreadUpdate: true,
titleOverride: '生成中 RPG 草稿',
}
: null,
});
expect(items.map((item) => item.id)).toEqual([
'puzzle:source-adapter',
'draft:rpg-source-adapter',
]);
expect(items[1]?.title).toBe('生成中 RPG 草稿');
expect(items[1]?.isGenerating).toBe(true);
expect(items[1]?.hasUnreadUpdate).toBe(true);
});
test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => {
const onOpenWoodenFishDetail = vi.fn();
const woodenFishWork = {
@@ -2,20 +2,20 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
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 { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzleClearPublicWorkCode,
@@ -164,6 +164,11 @@ export type CreationWorkShelfRuntimeState = {
summaryOverride?: string;
};
export type CreationWorkShelfSourceAdapter = {
kind: CreationWorkShelfKind;
buildItems: () => readonly CreationWorkShelfItem[];
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
@@ -267,76 +272,135 @@ export function buildCreationWorkShelfItems(params: {
getItemState,
} = params;
return [
...rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
onOpenDraft: onOpenRpgDraft,
onEnterPublished: onEnterRpgPublished,
onDelete: onDeleteRpg,
}),
),
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
onOpen: onOpenBigFishDetail,
onDelete: onDeleteBigFish,
}),
),
...match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
onOpen: onOpenMatch3DDetail,
onDelete: onDeleteMatch3D,
}),
),
...squareHoleItems.map((item) =>
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
onOpen: onOpenSquareHoleDetail,
onDelete: onDeleteSquareHole,
}),
),
...jumpHopItems.map((item) =>
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
onOpen: onOpenJumpHopDetail,
onDelete: onDeleteJumpHop,
}),
),
...woodenFishItems.map((item) =>
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
onOpen: onOpenWoodenFishDetail,
onDelete: onDeleteWoodenFish,
}),
),
...puzzleClearItems.map((item) =>
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
onOpen: onOpenPuzzleClearDetail,
onDelete: onDeletePuzzleClear,
}),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
onDelete: onDeletePuzzle,
onClaimPointIncentive: onClaimPuzzlePointIncentive,
}),
),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
}),
),
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
onDelete: onDeleteVisualNovel,
}),
),
]
return buildCreationWorkShelfItemsFromSources({
sources: [
{
kind: 'rpg',
buildItems: () =>
rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
onOpenDraft: onOpenRpgDraft,
onEnterPublished: onEnterRpgPublished,
onDelete: onDeleteRpg,
}),
),
},
{
kind: 'big-fish',
buildItems: () =>
bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
onOpen: onOpenBigFishDetail,
onDelete: onDeleteBigFish,
}),
),
},
{
kind: 'match3d',
buildItems: () =>
match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
onOpen: onOpenMatch3DDetail,
onDelete: onDeleteMatch3D,
}),
),
},
{
kind: 'square-hole',
buildItems: () =>
squareHoleItems.map((item) =>
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
onOpen: onOpenSquareHoleDetail,
onDelete: onDeleteSquareHole,
}),
),
},
{
kind: 'jump-hop',
buildItems: () =>
jumpHopItems.map((item) =>
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
onOpen: onOpenJumpHopDetail,
onDelete: onDeleteJumpHop,
}),
),
},
{
kind: 'wooden-fish',
buildItems: () =>
woodenFishItems.map((item) =>
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
onOpen: onOpenWoodenFishDetail,
onDelete: onDeleteWoodenFish,
}),
),
},
{
kind: 'puzzle',
buildItems: () =>
puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
onDelete: onDeletePuzzle,
onClaimPointIncentive: onClaimPuzzlePointIncentive,
}),
),
},
{
kind: 'baby-object-match',
buildItems: () =>
babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(
item,
canDeleteBabyObjectMatch,
{
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
},
),
),
},
{
kind: 'bark-battle',
buildItems: () =>
mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
},
{
kind: 'visual-novel',
buildItems: () =>
visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
onDelete: onDeleteVisualNovel,
}),
),
},
],
getItemState,
});
}
export function buildCreationWorkShelfItemsFromSources(params: {
sources: readonly CreationWorkShelfSourceAdapter[];
getItemState?: (
item: CreationWorkShelfItem,
) => CreationWorkShelfRuntimeState | null;
}) {
const { sources, getItemState } = params;
const sourceItems = sources.reduce<CreationWorkShelfItem[]>(
(items, source) => {
items.push(...source.buildItems());
return items;
},
[],
);
return sourceItems
.map((item) => {
const state = getItemState?.(item);
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,19 @@
import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type {
BarkBattleDraftConfig,
BarkBattlePublishedConfig,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
buildBarkBattleDraftConfigFromWorkSummary,
buildBarkBattlePublishedConfigFromDraft,
buildBarkBattlePublishedConfigFromWork,
buildBarkBattlePublishSnapshot,
mergeBarkBattlePublishedConfigAssets,
mergeBarkBattleWorksByWorkId,
mergeBarkBattleWorkSummary,
resolveBarkBattleDraftGenerationStatus,
shouldPreserveLocalBarkBattleWorkOnRefresh,
} from './barkBattleWorkCache';
@@ -20,6 +30,7 @@ function buildBarkBattleWork(
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
@@ -34,6 +45,29 @@ function buildBarkBattleWork(
};
}
function buildBarkBattleDraft(
overrides: Partial<BarkBattleDraftConfig> = {},
): BarkBattleDraftConfig {
return {
draftId: 'bark-battle-draft-1',
workId: 'BB-cache-race-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v2',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
updatedAt: '2026-05-21T10:00:00.000Z',
...overrides,
};
}
test('preserves local published bark battle when refresh only returns same work draft', () => {
const published = buildBarkBattleWork({
status: 'published',
@@ -106,3 +140,124 @@ test('preserves local ready bark battle draft when refresh has not returned it y
expect(merged[0]?.generationStatus).toBe('ready');
});
test('resolves bark battle draft generation status from required images', () => {
expect(
resolveBarkBattleDraftGenerationStatus(
buildBarkBattleDraft({ uiBackgroundImageSrc: undefined }),
false,
),
).toBe('pending_assets');
expect(
resolveBarkBattleDraftGenerationStatus(
buildBarkBattleDraft({ opponentCharacterImageSrc: '' }),
true,
),
).toBe('partial_failed');
expect(resolveBarkBattleDraftGenerationStatus(buildBarkBattleDraft(), true)).toBe(
'ready',
);
});
test('builds draft runtime config with stable defaults', () => {
const config = buildBarkBattlePublishedConfigFromDraft(
buildBarkBattleDraft({
workId: undefined,
configVersion: undefined,
rulesetVersion: undefined,
}),
);
expect(config.workId).toBe('bark-battle-draft-1');
expect(config.draftId).toBe('bark-battle-draft-1');
expect(config.configVersion).toBe(1);
expect(config.rulesetVersion).toBe('bark-battle-ruleset-v1');
expect(config.playTypeId).toBe('bark-battle');
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
});
test('builds work runtime config with publishedAt fallback', () => {
const config = buildBarkBattlePublishedConfigFromWork(
buildBarkBattleWork({ publishedAt: null }),
);
expect(config.workId).toBe('BB-cache-race-12345678');
expect(config.description).toBe('测试声浪赛');
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
expect(config.playerCharacterImageSrc).toBe('/generated-bark-battle/player.png');
});
test('builds draft config from work summary with stable defaults', () => {
const config = buildBarkBattleDraftConfigFromWorkSummary(
buildBarkBattleWork({
draftId: null,
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
}),
);
expect(config).toMatchObject({
draftId: 'BB-cache-race-12345678',
workId: 'BB-cache-race-12345678',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
difficultyPreset: 'normal',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-21T10:00:00.000Z',
});
expect(config.playerCharacterImageSrc).toBeUndefined();
expect(config.opponentCharacterImageSrc).toBeUndefined();
expect(config.uiBackgroundImageSrc).toBeUndefined();
});
test('builds publish snapshot without empty asset fields', () => {
const snapshot = buildBarkBattlePublishSnapshot(
buildBarkBattleDraft({
playerCharacterImageSrc: '',
opponentCharacterImageSrc: undefined,
}),
);
expect(snapshot).not.toHaveProperty('playerCharacterImageSrc');
expect(snapshot).not.toHaveProperty('opponentCharacterImageSrc');
expect(snapshot.uiBackgroundImageSrc).toBe(
'/generated-bark-battle/background.png',
);
});
test('merges draft assets into published config when publish response omits them', () => {
const draft = buildBarkBattleDraft();
const published: BarkBattlePublishedConfig = {
workId: 'BB-cache-race-12345678',
draftId: 'bark-battle-draft-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v2',
playTypeId: 'bark-battle',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
difficultyPreset: 'normal',
updatedAt: '2026-05-21T10:01:00.000Z',
publishedAt: '2026-05-21T10:01:00.000Z',
};
const merged = mergeBarkBattlePublishedConfigAssets(published, draft);
expect(merged.playerCharacterImageSrc).toBe(
'/generated-bark-battle/player.png',
);
expect(merged.opponentCharacterImageSrc).toBe(
'/generated-bark-battle/opponent.png',
);
expect(merged.uiBackgroundImageSrc).toBe(
'/generated-bark-battle/background.png',
);
});
@@ -1,6 +1,8 @@
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
BarkBattlePublishedConfig,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
@@ -36,6 +38,132 @@ export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary)
);
}
export function hasBarkBattleDraftRequiredImages(draft: BarkBattleDraftConfig) {
return Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
);
}
export function resolveBarkBattleDraftGenerationStatus(
draft: BarkBattleDraftConfig,
partialFailed: boolean,
): BarkBattleGenerationStatus {
if (hasBarkBattleDraftRequiredImages(draft)) {
return 'ready';
}
return partialFailed ? 'partial_failed' : 'pending_assets';
}
export function buildBarkBattlePublishedConfigFromDraft(
draft: BarkBattleDraftConfig,
): BarkBattlePublishedConfig {
return {
workId: draft.workId ?? draft.draftId,
draftId: draft.draftId,
configVersion: draft.configVersion ?? 1,
rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
playerCharacterImageSrc: draft.playerCharacterImageSrc,
opponentCharacterImageSrc: draft.opponentCharacterImageSrc,
uiBackgroundImageSrc: draft.uiBackgroundImageSrc,
difficultyPreset: draft.difficultyPreset,
updatedAt: draft.updatedAt,
publishedAt: draft.updatedAt,
};
}
export function buildBarkBattlePublishSnapshot(
draft: BarkBattleDraftConfig,
): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
...(draft.opponentCharacterImageSrc
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
: {}),
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
difficultyPreset: draft.difficultyPreset,
};
}
export function mergeBarkBattlePublishedConfigAssets(
published: BarkBattlePublishedConfig,
draft: BarkBattleDraftConfig,
): BarkBattlePublishedConfig {
return {
...published,
playerCharacterImageSrc:
published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc,
opponentCharacterImageSrc:
published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc,
uiBackgroundImageSrc:
published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc,
};
}
export function buildBarkBattlePublishedConfigFromWork(
work: BarkBattleWorkSummary,
): BarkBattlePublishedConfig {
return {
workId: work.workId,
draftId: work.draftId ?? null,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: work.title,
description: work.summary,
themeDescription: work.themeDescription,
playerImageDescription: work.playerImageDescription,
opponentImageDescription: work.opponentImageDescription,
onomatopoeia: work.onomatopoeia,
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
difficultyPreset: work.difficultyPreset,
updatedAt: work.updatedAt,
publishedAt: work.publishedAt ?? work.updatedAt,
};
}
export function buildBarkBattleDraftConfigFromWorkSummary(
work: BarkBattleWorkSummary,
): BarkBattleDraftConfig {
return {
draftId: work.draftId ?? work.workId,
workId: work.workId,
title: work.title,
description: work.summary,
themeDescription: work.themeDescription,
playerImageDescription: work.playerImageDescription,
opponentImageDescription: work.opponentImageDescription,
onomatopoeia: work.onomatopoeia,
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
difficultyPreset: work.difficultyPreset,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: work.updatedAt,
};
}
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
item: BarkBattleWorkSummary,
refreshed: readonly BarkBattleWorkSummary[],
@@ -85,11 +213,7 @@ export function buildBarkBattleWorkSummaryFromDraft(
difficultyPreset: draft.difficultyPreset,
status: 'draft',
generationStatus,
publishReady: Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
),
publishReady: hasBarkBattleDraftRequiredImages(draft),
playCount: 0,
updatedAt: draft.updatedAt,
publishedAt: null,
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';
import {
type PlatformCreationLaunchTarget,
resolvePlatformCreationLaunchIntent,
} from './platformCreationLaunchModel';
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
describe('platformCreationLaunchModel', () => {
test('keeps airp as a placeholder noop before prepare', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'airp',
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'noop',
shouldPrepare: false,
reason: 'placeholder',
});
});
test('blocks hidden baby object match after prepare', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'baby-object-match',
isBabyObjectMatchVisible: false,
}),
).toEqual({
type: 'blocked',
shouldPrepare: true,
message: EDUTAINMENT_HIDDEN_MESSAGE,
});
});
test('resolves known creation launch targets', () => {
const targets: PlatformCreationLaunchTarget[] = [
'rpg',
'big-fish',
'match3d',
'square-hole',
'jump-hop',
'wooden-fish',
'puzzle',
'bark-battle',
'visual-novel',
'baby-object-match',
];
targets.forEach((target) => {
expect(
resolvePlatformCreationLaunchIntent({
type: target,
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'launch',
shouldPrepare: true,
target,
});
});
});
test('keeps unknown creation type as a prepared noop', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'unknown-template',
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'noop',
shouldPrepare: true,
reason: 'unknown',
});
});
});
@@ -0,0 +1,87 @@
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
export type PlatformCreationLaunchTarget =
| 'rpg'
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'jump-hop'
| 'wooden-fish'
| 'puzzle'
| 'bark-battle'
| 'visual-novel'
| 'baby-object-match';
export type PlatformCreationLaunchIntent =
| {
type: 'noop';
shouldPrepare: false;
reason: 'placeholder';
}
| {
type: 'noop';
shouldPrepare: true;
reason: 'unknown';
}
| {
type: 'blocked';
shouldPrepare: true;
message: string;
}
| {
type: 'launch';
shouldPrepare: true;
target: PlatformCreationLaunchTarget;
};
const PLATFORM_CREATION_LAUNCH_TARGETS = new Set<PlatformCreationTypeId>([
'rpg',
'big-fish',
'match3d',
'square-hole',
'jump-hop',
'wooden-fish',
'puzzle',
'bark-battle',
'visual-novel',
'baby-object-match',
]);
export function resolvePlatformCreationLaunchIntent(params: {
type: PlatformCreationTypeId;
isBabyObjectMatchVisible: boolean;
}): PlatformCreationLaunchIntent {
if (params.type === 'airp') {
return {
type: 'noop',
shouldPrepare: false,
reason: 'placeholder',
};
}
if (
params.type === 'baby-object-match' &&
!params.isBabyObjectMatchVisible
) {
return {
type: 'blocked',
shouldPrepare: true,
message: EDUTAINMENT_HIDDEN_MESSAGE,
};
}
if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) {
return {
type: 'noop',
shouldPrepare: true,
reason: 'unknown',
};
}
return {
type: 'launch',
shouldPrepare: true,
target: params.type as PlatformCreationLaunchTarget,
};
}
@@ -0,0 +1,498 @@
import { describe, expect, test } from 'vitest';
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../services/jump-hop/jumpHopClient';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
} from '../../services/wooden-fish/woodenFishClient';
import {
buildBabyObjectMatchCreationUrlState,
buildBarkBattleCreationUrlState,
buildBigFishCreationUrlState,
buildJumpHopCreationUrlState,
buildMatch3DCreationUrlState,
buildPuzzleCreationUrlState,
buildPuzzleDraftRuntimeUrlState,
buildPuzzlePublishedRuntimeUrlState,
buildPuzzleRuntimeUrlStateKey,
buildSquareHoleCreationUrlState,
buildVisualNovelCreationUrlState,
buildWoodenFishCreationUrlState,
hasCreationUrlStateValue,
hasPuzzleRuntimeUrlStateValue,
matchesBabyObjectMatchCreationUrlRestoreTarget,
matchesBarkBattleCreationUrlRestoreTarget,
matchesBigFishCreationUrlRestoreTarget,
matchesSessionProfileWorkCreationUrlRestoreTarget,
matchesVisualNovelCreationUrlRestoreTarget,
normalizeCreationUrlValue,
resolveCreationUrlRestoreTarget,
resolveInitialCreationUrlRestoreDecision,
resolveJumpHopCreationUrlRestoreStage,
resolveWoodenFishCreationUrlRestoreStage,
} from './platformCreationUrlStateModel';
describe('platformCreationUrlStateModel', () => {
test('normalizes private creation url state values', () => {
expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1');
expect(normalizeCreationUrlValue(' ')).toBeNull();
expect(
hasCreationUrlStateValue({
sessionId: ' ',
profileId: null,
draftId: undefined,
workId: 'work-1',
}),
).toBe(true);
expect(hasCreationUrlStateValue({})).toBe(false);
});
test('resolves initial creation url restore readiness', () => {
const readyParams = {
handled: false,
pathname: '/creation/puzzle/result',
state: { sessionId: 'puzzle-session-1' },
isLoadingPlatform: false,
canReadProtectedData: true,
};
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
handled: true,
}),
).toEqual({ type: 'skip' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
pathname: '/works/detail',
}),
).toEqual({ type: 'mark-handled' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
state: {},
}),
).toEqual({ type: 'mark-handled' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
isLoadingPlatform: true,
}),
).toEqual({ type: 'wait' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
canReadProtectedData: false,
}),
).toEqual({ type: 'wait' });
expect(resolveInitialCreationUrlRestoreDecision(readyParams)).toEqual({
type: 'restore',
});
});
test('resolves supported creation url restore targets from paths', () => {
const state = {
sessionId: ' session-1 ',
profileId: ' profile-1 ',
draftId: ' draft-1 ',
workId: ' work-1 ',
};
const cases = [
['/creation/big-fish/result', 'big-fish'],
['/creation/match3d/result', 'match3d'],
['/creation/square-hole/result', 'square-hole'],
['/creation/puzzle/result', 'puzzle'],
['/creation/visual-novel/result', 'visual-novel'],
['/creation/bark-battle/result', 'bark-battle'],
['/creation/baby-object-match/result', 'baby-object-match'],
['/creation/jump-hop/result', 'jump-hop'],
['/creation/wooden-fish/result', 'wooden-fish'],
] as const;
cases.forEach(([pathname, kind]) => {
expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({
kind,
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
isGeneratingPath: false,
});
});
});
test('normalizes creation url restore target values and generating paths', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', {
sessionId: ' ',
profileId: ' jump-profile-1 ',
draftId: undefined,
workId: null,
}),
).toEqual({
kind: 'jump-hop',
sessionId: null,
profileId: 'jump-profile-1',
draftId: null,
workId: null,
isGeneratingPath: true,
});
});
test('derives big fish restore session from work id when needed', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
workId: 'big-fish-work-river',
}),
).toEqual({
kind: 'big-fish',
sessionId: null,
profileId: null,
draftId: null,
workId: 'big-fish-work-river',
isGeneratingPath: false,
bigFishSessionId: 'river',
});
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
sessionId: 'big-fish-session-carp',
workId: 'big-fish-work-river',
}),
).toMatchObject({
kind: 'big-fish',
bigFishSessionId: 'big-fish-session-carp',
});
});
test('keeps unsupported creation paths without a concrete restore target', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/rpg/result', {
sessionId: 'rpg-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/unknown/result', {
sessionId: 'unknown-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/big-fishery/result', {
sessionId: 'big-fish-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/works/detail', {
workId: 'work-1',
}),
).toBeNull();
});
test('matches restore targets against work and draft identities', () => {
const bigFishTarget = resolveCreationUrlRestoreTarget(
'/creation/big-fish/result',
{
workId: 'big-fish-work-river',
},
);
expect(bigFishTarget?.kind).toBe('big-fish');
if (bigFishTarget?.kind !== 'big-fish') {
throw new Error('big fish target expected');
}
expect(
matchesBigFishCreationUrlRestoreTarget(
{ sourceSessionId: 'river' },
bigFishTarget,
),
).toBe(true);
expect(
matchesBigFishCreationUrlRestoreTarget(
{ workId: 'big-fish-work-river' },
bigFishTarget,
),
).toBe(true);
const target = {
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
};
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ sourceSessionId: 'session-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ profileId: 'profile-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ workId: 'work-1' },
target,
),
).toBe(true);
expect(
matchesVisualNovelCreationUrlRestoreTarget(
{ profileId: 'profile-1' },
target,
),
).toBe(true);
expect(
matchesBarkBattleCreationUrlRestoreTarget(
{ draftId: 'draft-1' },
target,
),
).toBe(true);
expect(
matchesBabyObjectMatchCreationUrlRestoreTarget(
{ profileId: 'work-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ sourceSessionId: null, profileId: null, workId: null },
{ sessionId: null, profileId: null, workId: null },
),
).toBe(false);
expect(
matchesBarkBattleCreationUrlRestoreTarget(
{ workId: null, draftId: null },
{ workId: null, draftId: null },
),
).toBe(false);
});
test('resolves work backed restore stages', () => {
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: true,
hasRestoredDraft: false,
hasRestoredWork: true,
}),
).toBe('jump-hop-generating');
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
hasRestoredWork: true,
}),
).toBe('jump-hop-result');
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
hasRestoredWork: false,
}),
).toBe('jump-hop-workspace');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: true,
hasRestoredDraft: true,
}),
).toBe('wooden-fish-generating');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: true,
}),
).toBe('wooden-fish-result');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
}),
).toBe('wooden-fish-workspace');
});
test('builds creation restore state for core session based plays', () => {
expect(
buildBigFishCreationUrlState({
sessionId: ' big-fish-session-1 ',
} as BigFishSessionSnapshotResponse),
).toEqual({
sessionId: 'big-fish-session-1',
workId: 'big-fish-work-big-fish-session-1',
});
expect(
buildMatch3DCreationUrlState({
sessionId: 'match3d-session-1',
draft: { profileId: 'match3d-profile-draft' },
} as Match3DAgentSessionSnapshot),
).toEqual({
sessionId: 'match3d-session-1',
profileId: 'match3d-profile-draft',
workId: 'match3d-profile-draft',
});
expect(
buildSquareHoleCreationUrlState({
sessionId: 'square-session-1',
publishedProfileId: 'square-profile-published',
} as SquareHoleSessionSnapshot),
).toEqual({
sessionId: 'square-session-1',
profileId: 'square-profile-published',
workId: 'square-profile-published',
});
expect(
buildVisualNovelCreationUrlState({
sessionId: 'visual-session-1',
draft: { profileId: 'visual-profile-1' },
} as VisualNovelAgentSessionSnapshot),
).toEqual({
sessionId: 'visual-session-1',
profileId: 'visual-profile-1',
workId: 'visual-profile-1',
});
});
test('builds puzzle creation and runtime query state', () => {
expect(
buildPuzzleCreationUrlState({
sessionId: 'puzzle-session-ocean',
} as PuzzleAgentSessionSnapshot),
).toEqual({
sessionId: 'puzzle-session-ocean',
profileId: 'puzzle-profile-ocean',
workId: 'puzzle-work-ocean',
});
const draftRuntime = buildPuzzleDraftRuntimeUrlState(
buildPuzzleWork({
profileId: 'puzzle-profile-ocean',
sourceSessionId: null,
}),
'level-2',
);
expect(draftRuntime).toEqual({
mode: 'draft',
runtimeSessionId: 'puzzle-session-ocean',
runtimeProfileId: 'puzzle-profile-ocean',
runtimeLevelId: 'level-2',
});
expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true);
expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe(
'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|',
);
const publishedRuntime = buildPuzzlePublishedRuntimeUrlState(
buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }),
);
expect(publishedRuntime.mode).toBe('published');
expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean');
expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u);
});
test('builds creation state for work backed plays with work id priority', () => {
expect(
buildJumpHopCreationUrlState({
session: {
sessionId: 'jump-session-1',
draft: { profileId: 'jump-profile-draft' },
} as JumpHopSessionSnapshotResponse,
work: {
summary: {
profileId: 'jump-profile-work',
workId: 'jump-work-1',
},
} as JumpHopWorkProfileResponse,
}),
).toEqual({
sessionId: 'jump-session-1',
profileId: 'jump-profile-work',
workId: 'jump-work-1',
});
expect(
buildWoodenFishCreationUrlState({
session: {
sessionId: 'wood-session-1',
draft: { profileId: 'wood-profile-draft' },
} as WoodenFishSessionSnapshotResponse,
work: {
summary: {
profileId: 'wood-profile-work',
workId: 'wood-work-1',
},
} as WoodenFishWorkProfileResponse,
}),
).toEqual({
sessionId: 'wood-session-1',
profileId: 'wood-profile-work',
draftId: 'wood-profile-work',
workId: 'wood-work-1',
});
});
test('builds creation state for draft backed local plays', () => {
expect(
buildBarkBattleCreationUrlState({
draftId: 'bark-draft-1',
workId: 'bark-work-1',
} as BarkBattleDraftConfig),
).toEqual({
draftId: 'bark-draft-1',
workId: 'bark-work-1',
});
expect(
buildBabyObjectMatchCreationUrlState({
draftId: 'baby-draft-1',
profileId: 'baby-profile-1',
} as BabyObjectMatchDraft),
).toEqual({
profileId: 'baby-profile-1',
draftId: 'baby-draft-1',
workId: 'baby-profile-1',
});
});
});
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-base',
profileId: 'puzzle-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-base',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图',
workDescription: '潮雾港口拼图。',
levelName: '潮雾拼图',
summary: '潮雾港口拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}
@@ -0,0 +1,431 @@
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import {
type CreationUrlState,
isCreationRestorePath,
} from '../../services/creationUrlState';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../services/jump-hop/jumpHopClient';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
} from '../../services/wooden-fish/woodenFishClient';
import type { SelectionStage } from './platformEntryTypes';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
buildPuzzleSessionIdFromProfileId,
} from './platformPuzzleIdentityModel';
/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */
export function normalizeCreationUrlValue(value: string | null | undefined) {
return value?.trim() || null;
}
export function hasCreationUrlStateValue(state: CreationUrlState) {
return Boolean(
normalizeCreationUrlValue(state.sessionId) ||
normalizeCreationUrlValue(state.profileId) ||
normalizeCreationUrlValue(state.draftId) ||
normalizeCreationUrlValue(state.workId),
);
}
export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) {
return Boolean(
normalizeCreationUrlValue(state.runtimeSessionId) ||
normalizeCreationUrlValue(state.runtimeProfileId) ||
normalizeCreationUrlValue(state.runtimeLevelId) ||
normalizeCreationUrlValue(state.publicWorkCode) ||
normalizeCreationUrlValue(state.mode),
);
}
export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) {
return [
normalizeCreationUrlValue(state.mode),
normalizeCreationUrlValue(state.runtimeSessionId),
normalizeCreationUrlValue(state.runtimeProfileId),
normalizeCreationUrlValue(state.runtimeLevelId),
normalizeCreationUrlValue(state.publicWorkCode),
].join('|');
}
export type CreationUrlRestoreTargetKind =
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'puzzle'
| 'visual-novel'
| 'bark-battle'
| 'baby-object-match'
| 'jump-hop'
| 'wooden-fish';
type CreationUrlRestoreTargetBase = {
kind: CreationUrlRestoreTargetKind;
sessionId: string | null;
profileId: string | null;
draftId: string | null;
workId: string | null;
isGeneratingPath: boolean;
};
export type BigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
kind: 'big-fish';
bigFishSessionId: string | null;
};
type NonBigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'>;
};
export type CreationUrlRestoreTarget =
| BigFishCreationUrlRestoreTarget
| NonBigFishCreationUrlRestoreTarget;
export type BigFishRestoreWorkIdentity = {
sourceSessionId?: string | null;
workId?: string | null;
};
export type SessionProfileWorkRestoreIdentity = {
sourceSessionId?: string | null;
profileId?: string | null;
workId?: string | null;
};
export type ProfileRestoreWorkIdentity = {
profileId?: string | null;
};
export type BarkBattleRestoreWorkIdentity = {
workId?: string | null;
draftId?: string | null;
};
export type BabyObjectMatchRestoreDraftIdentity = {
profileId?: string | null;
draftId?: string | null;
};
const CREATION_URL_RESTORE_TARGET_ROUTES = [
['/creation/big-fish', 'big-fish'],
['/creation/match3d', 'match3d'],
['/creation/square-hole', 'square-hole'],
['/creation/puzzle', 'puzzle'],
['/creation/visual-novel', 'visual-novel'],
['/creation/bark-battle', 'bark-battle'],
['/creation/baby-object-match', 'baby-object-match'],
['/creation/jump-hop', 'jump-hop'],
['/creation/wooden-fish', 'wooden-fish'],
] as const satisfies readonly (readonly [
string,
CreationUrlRestoreTargetKind,
])[];
export function resolveCreationUrlRestoreTarget(
pathname: string | undefined,
state: CreationUrlState,
): CreationUrlRestoreTarget | null {
const path = pathname?.trim() ?? '';
const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) =>
path === prefix || path.startsWith(`${prefix}/`),
);
if (!route) {
return null;
}
const kind = route[1];
const sessionId = normalizeCreationUrlValue(state.sessionId);
const profileId = normalizeCreationUrlValue(state.profileId);
const draftId = normalizeCreationUrlValue(state.draftId);
const workId = normalizeCreationUrlValue(state.workId);
const base = {
kind,
sessionId,
profileId,
draftId,
workId,
isGeneratingPath: path.includes('/generating'),
};
if (kind === 'big-fish') {
return {
...base,
kind,
bigFishSessionId:
sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null,
};
}
return base as NonBigFishCreationUrlRestoreTarget;
}
function matchesRestoreValue(
itemValue: string | null | undefined,
targetValue: string | null,
) {
return Boolean(targetValue && itemValue === targetValue);
}
export function matchesBigFishCreationUrlRestoreTarget(
item: BigFishRestoreWorkIdentity,
target: BigFishCreationUrlRestoreTarget,
) {
return (
matchesRestoreValue(item.sourceSessionId, target.bigFishSessionId) ||
matchesRestoreValue(item.workId, target.workId)
);
}
export function matchesSessionProfileWorkCreationUrlRestoreTarget(
item: SessionProfileWorkRestoreIdentity,
target: Pick<CreationUrlRestoreTarget, 'sessionId' | 'profileId' | 'workId'>,
) {
return (
matchesRestoreValue(item.sourceSessionId, target.sessionId) ||
matchesRestoreValue(item.profileId, target.profileId) ||
matchesRestoreValue(item.workId, target.workId)
);
}
export function matchesVisualNovelCreationUrlRestoreTarget(
item: ProfileRestoreWorkIdentity,
target: Pick<CreationUrlRestoreTarget, 'profileId'>,
) {
return matchesRestoreValue(item.profileId, target.profileId);
}
export function matchesBarkBattleCreationUrlRestoreTarget(
item: BarkBattleRestoreWorkIdentity,
target: Pick<CreationUrlRestoreTarget, 'workId' | 'draftId'>,
) {
return (
matchesRestoreValue(item.workId, target.workId) ||
matchesRestoreValue(item.draftId, target.draftId)
);
}
export function matchesBabyObjectMatchCreationUrlRestoreTarget(
item: BabyObjectMatchRestoreDraftIdentity,
target: Pick<CreationUrlRestoreTarget, 'profileId' | 'draftId' | 'workId'>,
) {
return (
matchesRestoreValue(item.profileId, target.profileId) ||
matchesRestoreValue(item.draftId, target.draftId) ||
matchesRestoreValue(item.profileId, target.workId)
);
}
export function resolveJumpHopCreationUrlRestoreStage(params: {
isGeneratingPath: boolean;
hasRestoredDraft: boolean;
hasRestoredWork: boolean;
}): SelectionStage {
if (params.isGeneratingPath) {
return 'jump-hop-generating';
}
return params.hasRestoredDraft || params.hasRestoredWork
? 'jump-hop-result'
: 'jump-hop-workspace';
}
export function resolveWoodenFishCreationUrlRestoreStage(params: {
isGeneratingPath: boolean;
hasRestoredDraft: boolean;
}): SelectionStage {
if (params.isGeneratingPath) {
return 'wooden-fish-generating';
}
return params.hasRestoredDraft
? 'wooden-fish-result'
: 'wooden-fish-workspace';
}
export type InitialCreationUrlRestoreDecision =
| { type: 'skip' }
| { type: 'mark-handled' }
| { type: 'wait' }
| { type: 'restore' };
export function resolveInitialCreationUrlRestoreDecision(params: {
handled: boolean;
pathname: string | undefined;
state: CreationUrlState;
isLoadingPlatform: boolean;
canReadProtectedData: boolean;
}): InitialCreationUrlRestoreDecision {
if (params.handled) {
return { type: 'skip' };
}
if (
!isCreationRestorePath(params.pathname) ||
!hasCreationUrlStateValue(params.state)
) {
return { type: 'mark-handled' };
}
if (params.isLoadingPlatform || !params.canReadProtectedData) {
return { type: 'wait' };
}
return { type: 'restore' };
}
export function buildBigFishCreationUrlState(
session: BigFishSessionSnapshotResponse | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
return {
sessionId,
workId: sessionId ? `big-fish-work-${sessionId}` : null,
};
}
export function buildMatch3DCreationUrlState(
session: Match3DAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.draft?.profileId ?? session?.publishedProfileId,
);
return {
sessionId,
profileId,
workId: profileId,
};
}
export function buildSquareHoleCreationUrlState(
session: SquareHoleSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.draft?.profileId ?? session?.publishedProfileId,
);
return {
sessionId,
profileId,
workId: profileId,
};
}
export function buildPuzzleCreationUrlState(
session: PuzzleAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId),
);
return {
sessionId,
profileId,
workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null,
};
}
export function buildPuzzleDraftRuntimeUrlState(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRuntimeUrlState {
const runtimeSessionId =
normalizeCreationUrlValue(item.sourceSessionId) ??
buildPuzzleSessionIdFromProfileId(item.profileId);
return {
mode: 'draft',
runtimeSessionId,
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
runtimeLevelId: normalizeCreationUrlValue(levelId),
};
}
export function buildPuzzlePublishedRuntimeUrlState(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRuntimeUrlState {
return {
mode: 'published',
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
runtimeLevelId: normalizeCreationUrlValue(levelId),
publicWorkCode: buildPuzzlePublicWorkCode(item.profileId),
};
}
export function buildVisualNovelCreationUrlState(
session: VisualNovelAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(session?.draft?.profileId);
return {
sessionId,
profileId,
workId: profileId ?? sessionId,
};
}
export function buildJumpHopCreationUrlState(params: {
session?: JumpHopSessionSnapshotResponse | null;
work?: JumpHopWorkProfileResponse | null;
}): CreationUrlState {
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
const profileId = normalizeCreationUrlValue(
params.work?.summary.profileId ?? params.session?.draft?.profileId,
);
return {
sessionId,
profileId,
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
};
}
export function buildWoodenFishCreationUrlState(params: {
session?: WoodenFishSessionSnapshotResponse | null;
work?: WoodenFishWorkProfileResponse | null;
}): CreationUrlState {
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
const profileId = normalizeCreationUrlValue(
params.work?.summary.profileId ?? params.session?.draft?.profileId,
);
const draftId = profileId ?? sessionId;
return {
sessionId,
profileId,
draftId,
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
};
}
export function buildBarkBattleCreationUrlState(
draft: BarkBattleDraftConfig | null,
): CreationUrlState {
return {
draftId: normalizeCreationUrlValue(draft?.draftId),
workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId),
};
}
export function buildBabyObjectMatchCreationUrlState(
draft: BabyObjectMatchDraft | null,
): CreationUrlState {
const profileId = normalizeCreationUrlValue(draft?.profileId);
return {
profileId,
draftId: normalizeCreationUrlValue(draft?.draftId),
workId: profileId,
};
}
@@ -0,0 +1,334 @@
import { describe, expect, test } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
describe('platformCreationWorkDeleteFlow', () => {
test('resolves RPG library delete confirmation without draft notice keys', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg-library',
entry: {
profileId: 'rpg-profile',
worldName: '潮雾列岛',
},
}),
).toEqual({
id: 'rpg-profile',
title: '潮雾列岛',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: [],
});
});
test('resolves RPG work delete detail and notice keys by work status', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg',
work: buildRpgWork(),
}),
).toEqual({
id: 'rpg-work',
title: 'RPG 草稿',
detail: '删除后会从你的作品列表中移除。',
noticeKeys: ['rpg:rpg-work', 'rpg:rpg-session', 'rpg:rpg-profile'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg',
work: buildRpgWork({ status: 'published' }),
}).detail,
).toBe('删除后会从你的作品列表和公开广场中移除。');
});
test('resolves mini game delete models with shared public and private detail copy', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'big-fish',
work: buildBigFishWork({ status: 'published' }),
}),
).toMatchObject({
id: 'big-fish-work',
title: '大鱼作品',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: ['big-fish:big-fish-work', 'big-fish:big-fish-session'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'match3d',
work: buildMatch3DWork(),
}).detail,
).toBe('删除后会从你的作品列表中移除。');
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'square-hole',
work: buildSquareHoleWork({ publicationStatus: 'published' }),
}).noticeKeys,
).toEqual([
'square-hole:square-hole-work',
'square-hole:square-hole-profile',
'square-hole:square-hole-session',
]);
});
test('resolves puzzle title fallback and stable result notice keys', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'puzzle',
work: buildPuzzleWork({
workTitle: ' ',
levelName: ' 雾港第一关 ',
sourceSessionId: 'puzzle-session-ocean',
}),
}),
).toEqual({
id: 'puzzle-work',
title: '雾港第一关',
detail: '删除后会从你的作品列表中移除。',
noticeKeys: [
'puzzle:puzzle-work',
'puzzle:puzzle-profile',
'puzzle:puzzle-session-ocean',
'puzzle:puzzle-work-ocean',
'puzzle:puzzle-profile-ocean',
],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'puzzle',
work: buildPuzzleWork({ workTitle: '', levelName: ' ' }),
}).title,
).toBe('未命名拼图');
});
test('resolves visual novel and baby object match special delete copy', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'visual-novel',
work: buildVisualNovelWork({ title: '', publishStatus: 'published' }),
}),
).toEqual({
id: 'visual-novel-profile',
title: '未命名视觉小说',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: ['visual-novel:visual-novel-profile'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'baby-object-match',
work: buildBabyObjectMatchDraft({
workTitle: ' ',
publicationStatus: 'published',
}),
}),
).toEqual({
id: 'baby-profile',
title: '宝贝识物',
detail: '删除后会从你的作品列表和寓教于乐板块中移除。',
noticeKeys: [
'baby-object-match:baby-profile',
'baby-object-match:baby-draft',
],
});
});
});
function buildRpgWork(
overrides: Partial<CustomWorldWorkSummary> = {},
): CustomWorldWorkSummary {
return {
workId: 'rpg-work',
sourceType: 'agent_session',
status: 'draft',
title: 'RPG 草稿',
subtitle: '待完善',
summary: 'RPG 摘要。',
coverImageSrc: null,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
stage: 'draft',
stageLabel: '草稿',
playableNpcCount: 1,
landmarkCount: 1,
sessionId: 'rpg-session',
profileId: 'rpg-profile',
canResume: true,
canEnterWorld: false,
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work',
sourceSessionId: 'big-fish-session',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '大鱼作品',
subtitle: '大鱼吃小鱼',
summary: '大鱼摘要。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levelCount: 1,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: true,
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work',
profileId: 'puzzle-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session',
authorDisplayName: '玩家',
workTitle: '拼图作品',
workDescription: '拼图摘要。',
levelName: '拼图第一关',
summary: '拼图摘要。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work',
profileId: 'match3d-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session',
gameName: '抓大鹅作品',
themeText: '糖果厨房',
summary: '抓大鹅摘要。',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
...overrides,
};
}
function buildSquareHoleWork(
overrides: Partial<SquareHoleWorkSummary> = {},
): SquareHoleWorkSummary {
return {
workId: 'square-hole-work',
profileId: 'square-hole-profile',
ownerUserId: 'user-1',
sourceSessionId: 'square-hole-session',
gameName: '方洞作品',
themeText: '图形',
twistRule: '反直觉',
summary: '方洞摘要。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '背景',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 8,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function buildVisualNovelWork(
overrides: Partial<VisualNovelWorkSummary> = {},
): VisualNovelWorkSummary {
return {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile',
ownerUserId: 'user-1',
title: '视觉小说作品',
description: '视觉小说摘要。',
coverImageSrc: null,
tags: [],
publishStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
...overrides,
};
}
function buildBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
return {
draftId: 'baby-draft',
profileId: 'baby-profile',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物作品',
workDescription: '宝贝识物摘要。',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'apple',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'banana',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: [],
publicationStatus: 'draft',
createdAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
...overrides,
};
}
@@ -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,
]),
};
}
@@ -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);
});
});
@@ -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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
import { describe, expect, test } from 'vitest';
import type {
MiniGameDraftGenerationKind,
MiniGameDraftGenerationPhase,
MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
function buildGenerationState(
kind: MiniGameDraftGenerationKind,
phase: MiniGameDraftGenerationPhase = 'compile',
): MiniGameDraftGenerationState {
return {
kind,
phase,
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 1,
error: null,
};
}
describe('platformGenerationProgressTickModel', () => {
test('ticks while a mini-game generation stage has a running state', () => {
const cases: Array<
[stage: SelectionStage, kind: MiniGameDraftGenerationKind]
> = [
['puzzle-generating', 'puzzle'],
['match3d-generating', 'match3d'],
['big-fish-generating', 'big-fish'],
['square-hole-generating', 'square-hole'],
['jump-hop-generating', 'jump-hop'],
['wooden-fish-generating', 'wooden-fish'],
['baby-object-match-generating', 'baby-object-match'],
];
for (const [selectionStage, kind] of cases) {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage,
miniGameStates: {
[kind]: buildGenerationState(kind),
},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: kind,
shouldTick: true,
});
}
});
test('does not tick mini-game generation when state is missing or terminal', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'puzzle-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'puzzle',
shouldTick: false,
});
for (const phase of ['ready', 'failed'] as const) {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'puzzle-generating',
miniGameStates: {
puzzle: buildGenerationState('puzzle', phase),
},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'puzzle',
shouldTick: false,
});
}
});
test('does not tick when stage and mini-game state do not match', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'puzzle-generating',
miniGameStates: {
match3d: buildGenerationState('match3d'),
},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'puzzle',
shouldTick: false,
});
});
test('ticks visual novel generation only after it has started and before terminal phases', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'visual-novel-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: 1000,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'visual-novel',
shouldTick: true,
});
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'visual-novel-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'visual-novel',
shouldTick: false,
});
for (const phase of ['ready', 'failed'] as const) {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'visual-novel-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: 1000,
phase,
},
}),
).toEqual({
activeKind: 'visual-novel',
shouldTick: false,
});
}
});
test('does not tick non-generation stages even when states are present', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'platform',
miniGameStates: {
puzzle: buildGenerationState('puzzle'),
},
visualNovel: {
startedAtMs: 1000,
phase: 'generating',
},
}),
).toEqual({
activeKind: null,
shouldTick: false,
});
});
});
@@ -0,0 +1,79 @@
import type {
MiniGameDraftGenerationKind,
MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
export type PlatformVisualNovelGenerationPhase =
| 'generating'
| 'ready'
| 'failed';
export type PlatformGenerationProgressTickKind =
| MiniGameDraftGenerationKind
| 'visual-novel';
export type PlatformGenerationProgressTickInput = {
selectionStage: SelectionStage;
miniGameStates: Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
visualNovel: {
startedAtMs: number | null;
phase: PlatformVisualNovelGenerationPhase;
};
};
export type PlatformGenerationProgressTickDecision = {
activeKind: PlatformGenerationProgressTickKind | null;
shouldTick: boolean;
};
const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'match3d-generating': 'match3d',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'jump-hop-generating': 'jump-hop',
'wooden-fish-generating': 'wooden-fish',
'baby-object-match-generating': 'baby-object-match',
};
function shouldTickMiniGameGenerationState(
state: MiniGameDraftGenerationState | null | undefined,
) {
return state != null && state.phase !== 'ready' && state.phase !== 'failed';
}
/** 收口生成页进度 tick 判定,壳层只保留 interval 副作用。 */
export function resolvePlatformGenerationProgressTickDecision(
input: PlatformGenerationProgressTickInput,
): PlatformGenerationProgressTickDecision {
if (input.selectionStage === 'visual-novel-generating') {
return {
activeKind: 'visual-novel',
shouldTick:
input.visualNovel.startedAtMs != null &&
input.visualNovel.phase !== 'ready' &&
input.visualNovel.phase !== 'failed',
};
}
const activeKind =
MINI_GAME_GENERATION_STAGE_TO_KIND[input.selectionStage] ?? null;
if (!activeKind) {
return {
activeKind: null,
shouldTick: false,
};
}
return {
activeKind,
shouldTick: shouldTickMiniGameGenerationState(
input.miniGameStates[activeKind],
),
};
}
@@ -0,0 +1,294 @@
import { expect, test } from 'vitest';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import {
buildMatch3DProfileFromSession,
mapMatch3DWorkToPublicWorkDetail,
mapPublicWorkDetailToMatch3DWork,
resolveActiveMatch3DRuntimeProfile,
resolveMatch3DRuntimeBackgroundImageSrc,
resolveMatch3DRuntimeGeneratedBackgroundAsset,
resolveMatch3DRuntimeGeneratedItemAssets,
} from './platformMatch3DRuntimeProfile';
function buildBackgroundAsset(
overrides: Partial<Match3DGeneratedBackgroundAsset> = {},
): Match3DGeneratedBackgroundAsset {
return {
prompt: '森林棋盘',
imageSrc: '/generated/match3d/background.png',
imageObjectKey: null,
status: 'ready',
...overrides,
};
}
function buildItemAsset(
overrides: Partial<Match3DGeneratedItemAsset> = {},
): Match3DGeneratedItemAsset {
return {
itemId: 'item-1',
itemName: '蘑菇',
imageSrc: '/generated/match3d/item.png',
imageObjectKey: null,
status: 'image_ready',
...overrides,
};
}
function buildProfile(
overrides: Partial<Match3DWorkProfile> = {},
): Match3DWorkProfile {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '森林抓鹅',
themeText: '森林',
summary: '找出蘑菇。',
tags: ['森林', '蘑菇'],
coverImageSrc: '/cover.png',
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: 1,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
publishReady: true,
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [buildItemAsset()],
...overrides,
};
}
function buildRun(overrides: Partial<Match3DRunSnapshot> = {}): Match3DRunSnapshot {
return {
runId: 'match3d-run-1',
profileId: 'match3d-profile-1',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1000,
durationLimitMs: 60000,
remainingMs: 55000,
clearCount: 12,
totalItemCount: 12,
clearedItemCount: 0,
items: [],
traySlots: [],
...overrides,
};
}
function buildPublicWork(
overrides: Partial<PlatformMatch3DGalleryCard> = {},
): PlatformMatch3DGalleryCard {
return {
sourceType: 'match3d',
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
sourceSessionId: 'match3d-session-1',
publicWorkCode: 'M3D-00000001',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '森林抓鹅',
subtitle: '抓大鹅',
summaryText: '找出蘑菇。',
coverImageSrc: '/cover.png',
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [buildItemAsset()],
themeTags: ['森林', '蘑菇'],
visibility: 'published',
publishedAt: '2026-05-20T00:00:00.000Z',
updatedAt: '2026-05-20T00:00:00.000Z',
...overrides,
};
}
test('Match3D runtime profile maps public detail and promotes item background asset', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/background-from-item.png',
imageObjectKey: 'oss/background-from-item.png',
});
const work = mapPublicWorkDetailToMatch3DWork(
buildPublicWork({
generatedBackgroundAsset: null,
backgroundImageSrc: null,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
}),
);
expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset);
expect(work?.backgroundImageSrc).toBe(
'/generated/match3d/background-from-item.png',
);
expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png');
});
test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/detail-background.png',
});
const detail = mapMatch3DWorkToPublicWorkDetail(
buildProfile({
generatedBackgroundAsset: null,
backgroundImageSrc: null,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
}),
);
expect(detail).toMatchObject({
sourceType: 'match3d',
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
backgroundImageSrc: '/generated/match3d/detail-background.png',
generatedBackgroundAsset: backgroundAsset,
});
});
test('Match3D runtime profile builds draft profile from session snapshot', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/draft-background.png',
});
const session: Match3DAgentSessionSnapshot = {
sessionId: 'match3d-session-draft',
currentTurn: 2,
progressPercent: 100,
stage: 'draft_compiled',
anchorPack: {
theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' },
clearCount: {
key: 'clearCount',
label: '消除数',
value: '12',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '4',
status: 'confirmed',
},
},
messages: [],
lastAssistantReply: null,
updatedAt: '2026-05-21T00:00:00.000Z',
draft: {
profileId: 'match3d-draft-profile',
gameName: '草稿抓鹅',
themeText: '森林',
summaryText: '草稿摘要',
tags: ['森林'],
coverImageSrc: null,
referenceImageSrc: '/reference.png',
clearCount: 12,
difficulty: 4,
publishReady: true,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
},
};
const profile = buildMatch3DProfileFromSession(session);
expect(profile?.profileId).toBe('match3d-draft-profile');
expect(profile?.sourceSessionId).toBe('match3d-session-draft');
expect(profile?.publicationStatus).toBe('draft');
expect(profile?.coverImageSrc).toBe('/reference.png');
expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset);
expect(profile?.backgroundImageSrc).toBe(
'/generated/match3d/draft-background.png',
);
});
test('Match3D runtime profile selects active profile by run profile id', () => {
const runtimeProfile = buildProfile({
profileId: 'runtime-profile',
gameName: '运行态抓鹅',
});
const draftProfile = buildProfile({
profileId: 'draft-profile',
gameName: '旧草稿抓鹅',
});
expect(
resolveActiveMatch3DRuntimeProfile(
buildRun({ profileId: 'runtime-profile' }),
runtimeProfile,
draftProfile,
),
).toBe(runtimeProfile);
expect(
resolveActiveMatch3DRuntimeProfile(
buildRun({ profileId: 'draft-profile' }),
runtimeProfile,
draftProfile,
),
).toBe(draftProfile);
});
test('Match3D runtime profile resolves generated assets from matching public detail', () => {
const staleProfile = buildProfile({
profileId: 'stale-profile',
generatedBackgroundAsset: buildBackgroundAsset({
imageSrc: '/generated/match3d/stale-background.png',
}),
generatedItemAssets: [
buildItemAsset({
itemId: 'stale-item',
imageSrc: '/generated/match3d/stale-item.png',
}),
],
});
const publicBackground = buildBackgroundAsset({
imageSrc: '/generated/match3d/public-background.png',
});
const publicWork = buildPublicWork({
profileId: 'public-profile',
generatedBackgroundAsset: publicBackground,
generatedItemAssets: [
buildItemAsset({
itemId: 'public-item',
imageSrc: '/generated/match3d/public-item.png',
}),
],
});
const run = buildRun({ profileId: 'public-profile' });
expect(
resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some(
(asset) => asset.imageSrc === '/generated/match3d/public-item.png',
),
).toBe(true);
expect(
resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork),
).toEqual(publicBackground);
expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe(
'/generated/match3d/public-background.png',
);
});
@@ -0,0 +1,335 @@
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
hasMatch3DGeneratedImageAsset,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
isMatch3DGalleryEntry,
mapMatch3DWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
export function mapMatch3DWorkToPublicWorkDetail(
item: Match3DWorkSummary,
): PlatformPublicGalleryCard {
return mapMatch3DWorkToPlatformGalleryCard(
normalizeMatch3DWorkForRuntimeUi(item),
);
}
export function mapPublicWorkDetailToMatch3DWork(
entry: PlatformPublicGalleryCard,
): Match3DWorkSummary | null {
if (!isMatch3DGalleryEntry(entry)) {
return null;
}
return promoteMatch3DGeneratedBackgroundAsset({
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
sourceSessionId:
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
? entry.sourceSessionId
: null,
gameName: entry.worldName,
themeText: entry.themeTags[0] ?? '经典消除',
summary: entry.summaryText,
tags: entry.themeTags,
coverImageSrc: entry.coverImageSrc,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: entry.playCount ?? 0,
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
publishReady: true,
backgroundPrompt: entry.backgroundPrompt ?? null,
backgroundImageSrc: entry.backgroundImageSrc ?? null,
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
generatedBackgroundAsset:
entry.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ??
null,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
entry.generatedItemAssets ?? [],
),
});
}
export function findMatch3DGeneratedBackgroundAsset(
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
): Match3DGeneratedBackgroundAsset | null {
return (
generatedItemAssets
?.map((asset) => asset.backgroundAsset ?? null)
.find(Boolean) ?? null
);
}
export function promoteMatch3DGeneratedBackgroundAsset<
T extends Pick<
Match3DWorkSummary,
| 'backgroundPrompt'
| 'backgroundImageSrc'
| 'backgroundImageObjectKey'
| 'generatedBackgroundAsset'
| 'generatedItemAssets'
>,
>(profile: T): T {
const backgroundAsset =
profile.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
if (!backgroundAsset) {
return profile;
}
return {
...profile,
backgroundPrompt:
profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
backgroundImageSrc:
profile.backgroundImageSrc ??
backgroundAsset.imageSrc ??
backgroundAsset.imageObjectKey ??
null,
backgroundImageObjectKey:
profile.backgroundImageObjectKey ??
backgroundAsset.imageObjectKey ??
backgroundAsset.imageSrc ??
null,
generatedBackgroundAsset:
profile.generatedBackgroundAsset ?? backgroundAsset,
};
}
export function normalizeMatch3DWorkForRuntimeUi<T extends Match3DWorkSummary>(
profile: T,
): T {
return promoteMatch3DGeneratedBackgroundAsset({
...profile,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
profile.generatedItemAssets,
),
});
}
export function mapMatch3DWorksForRuntimeUi<T extends Match3DWorkSummary>(
profiles: readonly T[],
): T[] {
return profiles.map(normalizeMatch3DWorkForRuntimeUi);
}
export function buildMatch3DProfileFromSession(
session: Match3DAgentSessionSnapshot | null,
): Match3DWorkProfile | null {
const draft = session?.draft;
if (!session || !draft?.profileId) {
return null;
}
const now = session.updatedAt || new Date().toISOString();
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
draft.generatedItemAssets,
);
return promoteMatch3DGeneratedBackgroundAsset({
workId: draft.profileId,
profileId: draft.profileId,
ownerUserId: 'current-user',
sourceSessionId: session.sessionId,
gameName: draft.gameName,
themeText: draft.themeText,
summary: draft.summary ?? draft.summaryText ?? '',
tags: draft.tags,
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
referenceImageSrc: draft.referenceImageSrc ?? null,
clearCount: draft.clearCount,
difficulty: draft.difficulty,
publicationStatus: 'draft',
playCount: 0,
updatedAt: now,
publishedAt: null,
publishReady: Boolean(draft.publishReady),
backgroundPrompt: draft.backgroundPrompt ?? null,
backgroundImageSrc: draft.backgroundImageSrc ?? null,
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
generatedItemAssets,
});
}
export function hasMatch3DRuntimeAsset(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
return hasMatch3DGeneratedImageAsset(assets);
}
export function hasMatch3DRuntimeBackgroundAsset(
profile: Pick<
Match3DWorkSummary,
| 'backgroundImageSrc'
| 'backgroundImageObjectKey'
| 'generatedBackgroundAsset'
| 'generatedItemAssets'
>,
) {
return Boolean(
profile.backgroundImageSrc?.trim() ||
profile.backgroundImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
profile.generatedItemAssets?.some(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
),
);
}
export function resolveMatch3DRuntimeGeneratedItemAssets(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileAssets = profile?.generatedItemAssets ?? [];
const publicDetailAssets =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (publicWorkDetail.generatedItemAssets ?? [])
: [];
if (runProfileId && profile?.profileId === runProfileId) {
if (hasMatch3DRuntimeAsset(profileAssets)) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return hasMatch3DRuntimeAsset(publicDetailAssets)
? mergeMatch3DGeneratedItemAssetsForRuntime(
publicDetailAssets,
profileAssets,
)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
}
if (hasMatch3DRuntimeAsset(profileAssets)) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return publicDetailAssets.length > 0
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
export function resolveMatch3DRuntimeGeneratedBackgroundAsset(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileBackground = profile
? (promoteMatch3DGeneratedBackgroundAsset(profile)
.generatedBackgroundAsset ?? null)
: null;
const publicBackground =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
.generatedBackgroundAsset ?? null)
: null;
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground ?? publicBackground;
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicBackground ?? profileBackground;
}
return profileBackground ?? publicBackground;
}
export function resolveActiveMatch3DRuntimeProfile(
run: Match3DRunSnapshot | null,
runtimeProfile: Match3DWorkProfile | null,
profile: Match3DWorkProfile | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
return runtimeProfile;
}
if (runProfileId && profile?.profileId === runProfileId) {
return profile;
}
return runtimeProfile ?? profile;
}
export function resolveMatch3DRuntimeBackgroundImageSrc(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const resolvedProfile = profile
? promoteMatch3DGeneratedBackgroundAsset(profile)
: null;
const resolvedPublicWork =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
: null;
const profileBackground =
resolvedProfile?.backgroundImageSrc?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
const publicBackground =
resolvedPublicWork?.backgroundImageSrc?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground || publicBackground || null;
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicBackground || profileBackground || null;
}
return profileBackground || publicBackground || null;
}
@@ -0,0 +1,384 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import {
createFailedMiniGameDraftGenerationStateForRestoredDraft,
createMiniGameDraftGenerationStateForRestoredDraft,
createPuzzleDraftGenerationStateFromPayload,
isMiniGameDraftGenerating,
isMiniGameDraftReady,
mergeMatch3DGeneratedAssetsIntoGenerationState,
mergePuzzleSessionProgressIntoGenerationState,
rebaseMiniGameDraftBackgroundCompileTaskForDisplay,
rebaseMiniGameDraftGenerationStateForDisplay,
resolveFinishedMiniGameDraftGenerationState,
resolvePuzzlePhaseFromSessionProgress,
} from './platformMiniGameDraftGenerationStateModel';
const NOW = Date.parse('2026-06-04T03:00:00.000Z');
const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z';
const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT);
function buildAnchorPack(): PuzzleAnchorPack {
const item = {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed' as const,
};
return {
themePromise: item,
visualSubject: item,
visualMood: item,
compositionHooks: item,
tagsAndForbidden: item,
};
}
function buildPuzzleSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = buildAnchorPack();
return {
sessionId: 'puzzle-session-1',
seedText: '星桥',
currentTurn: 1,
progressPercent: 90,
stage: 'draft_ready',
anchorPack,
draft: {
workTitle: '星桥拼图',
workDescription: '修复星桥机关。',
levelName: '星桥机关',
summary: '把星桥碎片拼回原位。',
themeTags: ['星桥'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
levels: [],
},
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: SESSION_UPDATED_AT,
...overrides,
};
}
function buildState(
overrides: Partial<MiniGameDraftGenerationState> = {},
): MiniGameDraftGenerationState {
return {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 100,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: 200,
puzzleProgressPercent: 20,
},
...overrides,
};
}
function buildMatch3DAsset(
overrides: Partial<Match3DGeneratedItemAsset> = {},
): Match3DGeneratedItemAsset {
return {
itemId: 'item-1',
itemName: '红宝石',
status: 'pending',
...overrides,
};
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
describe('platformMiniGameDraftGenerationStateModel', () => {
test('creates restored generation state with metadata and explicit start time', () => {
expect(
createMiniGameDraftGenerationStateForRestoredDraft(
'match3d',
{ puzzleAiRedraw: false },
123,
),
).toMatchObject({
kind: 'match3d',
phase: 'match3d-work-title',
startedAtMs: 123,
metadata: {
puzzleAiRedraw: false,
},
});
});
test('creates failed restored state from backend updated time', () => {
expect(
createFailedMiniGameDraftGenerationStateForRestoredDraft(
'puzzle',
SESSION_UPDATED_AT,
'生成失败',
{ puzzleAiRedraw: true },
),
).toMatchObject({
kind: 'puzzle',
phase: 'failed',
startedAtMs: SESSION_UPDATED_AT_MS,
finishedAtMs: NOW,
error: '生成失败',
metadata: {
puzzleAiRedraw: true,
},
});
});
test('rebases finished state for display without changing other fields', () => {
const state = buildState({
phase: 'ready',
finishedAtMs: 300,
completedAssetCount: 2,
totalAssetCount: 3,
});
expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({
...state,
finishedAtMs: undefined,
});
expect(
rebaseMiniGameDraftBackgroundCompileTaskForDisplay({
sessionId: 'task-1',
generationState: state,
}),
).toEqual({
sessionId: 'task-1',
generationState: {
...state,
finishedAtMs: undefined,
},
});
});
test('creates puzzle generation state from payload and compiled session', () => {
const payload: CreatePuzzleAgentSessionRequest = {
seedText: '星桥',
aiRedraw: false,
};
expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({
kind: 'puzzle',
phase: 'compile',
startedAtMs: NOW,
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: undefined,
puzzleActiveStepStartedAtMs: undefined,
puzzleProgressPercent: undefined,
},
});
expect(
createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()),
).toMatchObject({
kind: 'puzzle',
phase: 'compile',
startedAtMs: SESSION_UPDATED_AT_MS,
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: NOW,
puzzleProgressPercent: 90,
},
});
});
test('resolves puzzle phase from backend progress thresholds', () => {
const state = buildState();
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 96 }),
),
).toBe('puzzle-select-image');
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 94 }),
),
).toBe('puzzle-ui-assets');
expect(
resolvePuzzlePhaseFromSessionProgress(
buildState({ metadata: { puzzleAiRedraw: false } }),
buildPuzzleSession({ progressPercent: 88 }),
),
).toBe('puzzle-level-scene');
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 88 }),
),
).toBe('puzzle-cover-image');
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 20 }),
),
).toBe('compile');
});
test('merges compiled puzzle session progress into generation state', () => {
expect(
mergePuzzleSessionProgressIntoGenerationState(
buildState({
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: 200,
puzzleProgressPercent: 20,
},
}),
buildPuzzleSession({ progressPercent: 90 }),
),
).toMatchObject({
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: 'puzzle-level-scene',
puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS,
puzzleProgressPercent: 90,
},
});
expect(
mergePuzzleSessionProgressIntoGenerationState(
buildState(),
buildPuzzleSession({
draft: {
...buildPuzzleSession().draft!,
formDraft: {
pictureDescription: '星桥',
},
},
}),
).metadata,
).toMatchObject({
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: 200,
puzzleProgressPercent: 20,
});
});
test('merges match3d generated assets into active generation state', () => {
const state = buildState({
kind: 'match3d',
phase: 'match3d-material-sheet',
completedAssetCount: 0,
totalAssetCount: 0,
error: '旧错误',
});
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(state, [
buildMatch3DAsset({
itemId: 'item-with-view',
imageViews: [
{
viewId: 'front',
viewIndex: 0,
imageObjectKey: 'objects/front.png',
},
],
}),
buildMatch3DAsset({
itemId: 'item-with-src',
imageSrc: '/generated/item.png',
}),
buildMatch3DAsset({
itemId: 'item-with-error',
error: '切图失败',
}),
]),
).toMatchObject({
phase: 'match3d-generate-views',
completedAssetCount: 2,
totalAssetCount: 5,
error: '切图失败',
});
});
test('keeps match3d generated asset merge away from finished states', () => {
const readyState = buildState({
kind: 'match3d',
phase: 'ready',
completedAssetCount: 5,
totalAssetCount: 5,
});
const failedState = buildState({
kind: 'match3d',
phase: 'failed',
error: '已失败',
});
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBe(readyState);
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBe(failedState);
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(null, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBeNull();
});
test('finishes generation state and resolves ready/generating flags', () => {
const failedState = resolveFinishedMiniGameDraftGenerationState(
buildState({ error: '旧错误' }),
'failed',
{
completedAssetCount: 1,
totalAssetCount: 2,
},
);
expect(failedState).toMatchObject({
phase: 'failed',
finishedAtMs: NOW,
error: '旧错误',
completedAssetCount: 1,
totalAssetCount: 2,
});
expect(isMiniGameDraftReady(failedState)).toBe(false);
expect(isMiniGameDraftGenerating(failedState)).toBe(false);
expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true);
expect(isMiniGameDraftGenerating(buildState())).toBe(true);
expect(isMiniGameDraftGenerating(null)).toBe(false);
});
});
@@ -0,0 +1,197 @@
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import {
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationKind,
type MiniGameDraftGenerationPhase,
type MiniGameDraftGenerationState,
resolveMiniGameDraftGenerationStartedAtMs,
} from '../../services/miniGameDraftGenerationProgress';
export function createMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,
metadata?: MiniGameDraftGenerationState['metadata'],
startedAtMs = Date.now(),
): MiniGameDraftGenerationState {
return {
...createMiniGameDraftGenerationState(kind, startedAtMs),
...(metadata ? { metadata } : {}),
};
}
export function createFailedMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,
updatedAt: string | null | undefined,
error: string,
metadata?: MiniGameDraftGenerationState['metadata'],
): MiniGameDraftGenerationState {
return resolveFinishedMiniGameDraftGenerationState(
createMiniGameDraftGenerationStateForRestoredDraft(
kind,
metadata,
resolveMiniGameDraftGenerationStartedAtMs(updatedAt),
),
'failed',
{ error },
);
}
/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */
export function rebaseMiniGameDraftGenerationStateForDisplay(
state: MiniGameDraftGenerationState,
): MiniGameDraftGenerationState {
return {
...state,
finishedAtMs: undefined,
};
}
export function rebaseMiniGameDraftBackgroundCompileTaskForDisplay<
T extends { generationState: MiniGameDraftGenerationState },
>(task: T): T {
return {
...task,
generationState: rebaseMiniGameDraftGenerationStateForDisplay(
task.generationState,
),
};
}
export function createPuzzleDraftGenerationStateFromPayload(
payload: CreatePuzzleAgentSessionRequest | null | undefined,
session: PuzzleAgentSessionSnapshot | null | undefined = null,
): MiniGameDraftGenerationState {
const puzzleProgressPercent =
session?.draft && !session.draft.formDraft
? session.progressPercent
: undefined;
return {
...createMiniGameDraftGenerationState(
'puzzle',
resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt),
),
metadata: {
puzzleAiRedraw: payload?.aiRedraw ?? true,
puzzleActivePhaseId:
typeof puzzleProgressPercent === 'number' ? 'compile' : undefined,
puzzleActiveStepStartedAtMs:
typeof puzzleProgressPercent === 'number' ? Date.now() : undefined,
puzzleProgressPercent,
},
};
}
export function resolvePuzzlePhaseFromSessionProgress(
state: MiniGameDraftGenerationState,
session: PuzzleAgentSessionSnapshot,
): MiniGameDraftGenerationPhase {
if (session.progressPercent >= 96) {
return 'puzzle-select-image';
}
if (session.progressPercent >= 94) {
return 'puzzle-ui-assets';
}
if (session.progressPercent >= 88) {
return state.metadata?.puzzleAiRedraw === false
? 'puzzle-level-scene'
: 'puzzle-cover-image';
}
return 'compile';
}
export function mergePuzzleSessionProgressIntoGenerationState(
state: MiniGameDraftGenerationState,
session: PuzzleAgentSessionSnapshot,
): MiniGameDraftGenerationState {
const isCompiledGenerationSession = Boolean(
session.draft && !session.draft.formDraft,
);
const nextPhaseId = isCompiledGenerationSession
? resolvePuzzlePhaseFromSessionProgress(state, session)
: state.metadata?.puzzleActivePhaseId;
const shouldResetActiveStepStart =
isCompiledGenerationSession &&
nextPhaseId != null &&
nextPhaseId !== state.metadata?.puzzleActivePhaseId;
return {
...state,
metadata: {
...state.metadata,
puzzleActivePhaseId: nextPhaseId,
puzzleActiveStepStartedAtMs: shouldResetActiveStepStart
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
: state.metadata?.puzzleActiveStepStartedAtMs,
puzzleProgressPercent: isCompiledGenerationSession
? session.progressPercent
: state.metadata?.puzzleProgressPercent,
},
};
}
export function mergeMatch3DGeneratedAssetsIntoGenerationState(
current: MiniGameDraftGenerationState | null,
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
): MiniGameDraftGenerationState | null {
if (!current || current.phase === 'ready' || current.phase === 'failed') {
return current;
}
const assetList = assets ?? [];
const imageReadyCount = assetList.filter(
(asset) =>
asset.imageViews?.some(
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
) ||
asset.imageObjectKey?.trim() ||
asset.imageSrc?.trim(),
).length;
const totalAssetCount = Math.max(5, assetList.length);
const failedAsset = assetList.find((asset) => asset.error?.trim());
return {
...current,
phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase,
completedAssetCount: imageReadyCount,
totalAssetCount,
error: failedAsset?.error?.trim() || current.error,
};
}
export function resolveFinishedMiniGameDraftGenerationState(
state: MiniGameDraftGenerationState,
phase: 'ready' | 'failed',
options: {
error?: string | null;
completedAssetCount?: number;
totalAssetCount?: number;
} = {},
): MiniGameDraftGenerationState {
return {
...state,
phase,
finishedAtMs: Date.now(),
error: options.error ?? state.error,
completedAssetCount:
options.completedAssetCount ?? state.completedAssetCount,
totalAssetCount: options.totalAssetCount ?? state.totalAssetCount,
};
}
export function isMiniGameDraftReady(
state: MiniGameDraftGenerationState | null,
) {
return state?.phase === 'ready';
}
export function isMiniGameDraftGenerating(
state: MiniGameDraftGenerationState | null,
) {
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
}

Some files were not shown because too many files have changed in this diff Show More