Merge codex/sse-stream-architecture into architecture adjustment
This commit is contained in:
@@ -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
Reference in New Issue
Block a user