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',
|
||||
});
|
||||
});
|
||||
});
|
||||
87
src/components/platform-entry/platformCreationLaunchModel.ts
Normal file
87
src/components/platform-entry/platformCreationLaunchModel.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
|
||||
export type PlatformCreationLaunchTarget =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish'
|
||||
| 'puzzle'
|
||||
| 'bark-battle'
|
||||
| 'visual-novel'
|
||||
| 'baby-object-match';
|
||||
|
||||
export type PlatformCreationLaunchIntent =
|
||||
| {
|
||||
type: 'noop';
|
||||
shouldPrepare: false;
|
||||
reason: 'placeholder';
|
||||
}
|
||||
| {
|
||||
type: 'noop';
|
||||
shouldPrepare: true;
|
||||
reason: 'unknown';
|
||||
}
|
||||
| {
|
||||
type: 'blocked';
|
||||
shouldPrepare: true;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'launch';
|
||||
shouldPrepare: true;
|
||||
target: PlatformCreationLaunchTarget;
|
||||
};
|
||||
|
||||
const PLATFORM_CREATION_LAUNCH_TARGETS = new Set<PlatformCreationTypeId>([
|
||||
'rpg',
|
||||
'big-fish',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'jump-hop',
|
||||
'wooden-fish',
|
||||
'puzzle',
|
||||
'bark-battle',
|
||||
'visual-novel',
|
||||
'baby-object-match',
|
||||
]);
|
||||
|
||||
export function resolvePlatformCreationLaunchIntent(params: {
|
||||
type: PlatformCreationTypeId;
|
||||
isBabyObjectMatchVisible: boolean;
|
||||
}): PlatformCreationLaunchIntent {
|
||||
if (params.type === 'airp') {
|
||||
return {
|
||||
type: 'noop',
|
||||
shouldPrepare: false,
|
||||
reason: 'placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
params.type === 'baby-object-match' &&
|
||||
!params.isBabyObjectMatchVisible
|
||||
) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
shouldPrepare: true,
|
||||
message: EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) {
|
||||
return {
|
||||
type: 'noop',
|
||||
shouldPrepare: true,
|
||||
reason: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'launch',
|
||||
shouldPrepare: true,
|
||||
target: params.type as PlatformCreationLaunchTarget,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../services/jump-hop/jumpHopClient';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
buildBabyObjectMatchCreationUrlState,
|
||||
buildBarkBattleCreationUrlState,
|
||||
buildBigFishCreationUrlState,
|
||||
buildJumpHopCreationUrlState,
|
||||
buildMatch3DCreationUrlState,
|
||||
buildPuzzleCreationUrlState,
|
||||
buildPuzzleDraftRuntimeUrlState,
|
||||
buildPuzzlePublishedRuntimeUrlState,
|
||||
buildPuzzleRuntimeUrlStateKey,
|
||||
buildSquareHoleCreationUrlState,
|
||||
buildVisualNovelCreationUrlState,
|
||||
buildWoodenFishCreationUrlState,
|
||||
hasCreationUrlStateValue,
|
||||
hasPuzzleRuntimeUrlStateValue,
|
||||
matchesBabyObjectMatchCreationUrlRestoreTarget,
|
||||
matchesBarkBattleCreationUrlRestoreTarget,
|
||||
matchesBigFishCreationUrlRestoreTarget,
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget,
|
||||
matchesVisualNovelCreationUrlRestoreTarget,
|
||||
normalizeCreationUrlValue,
|
||||
resolveCreationUrlRestoreTarget,
|
||||
resolveInitialCreationUrlRestoreDecision,
|
||||
resolveJumpHopCreationUrlRestoreStage,
|
||||
resolveWoodenFishCreationUrlRestoreStage,
|
||||
} from './platformCreationUrlStateModel';
|
||||
|
||||
describe('platformCreationUrlStateModel', () => {
|
||||
test('normalizes private creation url state values', () => {
|
||||
expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1');
|
||||
expect(normalizeCreationUrlValue(' ')).toBeNull();
|
||||
expect(
|
||||
hasCreationUrlStateValue({
|
||||
sessionId: ' ',
|
||||
profileId: null,
|
||||
draftId: undefined,
|
||||
workId: 'work-1',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(hasCreationUrlStateValue({})).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves initial creation url restore readiness', () => {
|
||||
const readyParams = {
|
||||
handled: false,
|
||||
pathname: '/creation/puzzle/result',
|
||||
state: { sessionId: 'puzzle-session-1' },
|
||||
isLoadingPlatform: false,
|
||||
canReadProtectedData: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
handled: true,
|
||||
}),
|
||||
).toEqual({ type: 'skip' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
pathname: '/works/detail',
|
||||
}),
|
||||
).toEqual({ type: 'mark-handled' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
state: {},
|
||||
}),
|
||||
).toEqual({ type: 'mark-handled' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
isLoadingPlatform: true,
|
||||
}),
|
||||
).toEqual({ type: 'wait' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
canReadProtectedData: false,
|
||||
}),
|
||||
).toEqual({ type: 'wait' });
|
||||
expect(resolveInitialCreationUrlRestoreDecision(readyParams)).toEqual({
|
||||
type: 'restore',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves supported creation url restore targets from paths', () => {
|
||||
const state = {
|
||||
sessionId: ' session-1 ',
|
||||
profileId: ' profile-1 ',
|
||||
draftId: ' draft-1 ',
|
||||
workId: ' work-1 ',
|
||||
};
|
||||
const cases = [
|
||||
['/creation/big-fish/result', 'big-fish'],
|
||||
['/creation/match3d/result', 'match3d'],
|
||||
['/creation/square-hole/result', 'square-hole'],
|
||||
['/creation/puzzle/result', 'puzzle'],
|
||||
['/creation/visual-novel/result', 'visual-novel'],
|
||||
['/creation/bark-battle/result', 'bark-battle'],
|
||||
['/creation/baby-object-match/result', 'baby-object-match'],
|
||||
['/creation/jump-hop/result', 'jump-hop'],
|
||||
['/creation/wooden-fish/result', 'wooden-fish'],
|
||||
] as const;
|
||||
|
||||
cases.forEach(([pathname, kind]) => {
|
||||
expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({
|
||||
kind,
|
||||
sessionId: 'session-1',
|
||||
profileId: 'profile-1',
|
||||
draftId: 'draft-1',
|
||||
workId: 'work-1',
|
||||
isGeneratingPath: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes creation url restore target values and generating paths', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', {
|
||||
sessionId: ' ',
|
||||
profileId: ' jump-profile-1 ',
|
||||
draftId: undefined,
|
||||
workId: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'jump-hop',
|
||||
sessionId: null,
|
||||
profileId: 'jump-profile-1',
|
||||
draftId: null,
|
||||
workId: null,
|
||||
isGeneratingPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('derives big fish restore session from work id when needed', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
|
||||
workId: 'big-fish-work-river',
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'big-fish',
|
||||
sessionId: null,
|
||||
profileId: null,
|
||||
draftId: null,
|
||||
workId: 'big-fish-work-river',
|
||||
isGeneratingPath: false,
|
||||
bigFishSessionId: 'river',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
|
||||
sessionId: 'big-fish-session-carp',
|
||||
workId: 'big-fish-work-river',
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'big-fish',
|
||||
bigFishSessionId: 'big-fish-session-carp',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unsupported creation paths without a concrete restore target', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/rpg/result', {
|
||||
sessionId: 'rpg-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/unknown/result', {
|
||||
sessionId: 'unknown-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fishery/result', {
|
||||
sessionId: 'big-fish-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/works/detail', {
|
||||
workId: 'work-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('matches restore targets against work and draft identities', () => {
|
||||
const bigFishTarget = resolveCreationUrlRestoreTarget(
|
||||
'/creation/big-fish/result',
|
||||
{
|
||||
workId: 'big-fish-work-river',
|
||||
},
|
||||
);
|
||||
expect(bigFishTarget?.kind).toBe('big-fish');
|
||||
if (bigFishTarget?.kind !== 'big-fish') {
|
||||
throw new Error('big fish target expected');
|
||||
}
|
||||
expect(
|
||||
matchesBigFishCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: 'river' },
|
||||
bigFishTarget,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBigFishCreationUrlRestoreTarget(
|
||||
{ workId: 'big-fish-work-river' },
|
||||
bigFishTarget,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const target = {
|
||||
sessionId: 'session-1',
|
||||
profileId: 'profile-1',
|
||||
draftId: 'draft-1',
|
||||
workId: 'work-1',
|
||||
};
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: 'session-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ profileId: 'profile-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ workId: 'work-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesVisualNovelCreationUrlRestoreTarget(
|
||||
{ profileId: 'profile-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBarkBattleCreationUrlRestoreTarget(
|
||||
{ draftId: 'draft-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBabyObjectMatchCreationUrlRestoreTarget(
|
||||
{ profileId: 'work-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: null, profileId: null, workId: null },
|
||||
{ sessionId: null, profileId: null, workId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesBarkBattleCreationUrlRestoreTarget(
|
||||
{ workId: null, draftId: null },
|
||||
{ workId: null, draftId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves work backed restore stages', () => {
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: true,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: true,
|
||||
}),
|
||||
).toBe('jump-hop-generating');
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: true,
|
||||
}),
|
||||
).toBe('jump-hop-result');
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: false,
|
||||
}),
|
||||
).toBe('jump-hop-workspace');
|
||||
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: true,
|
||||
hasRestoredDraft: true,
|
||||
}),
|
||||
).toBe('wooden-fish-generating');
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: true,
|
||||
}),
|
||||
).toBe('wooden-fish-result');
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
}),
|
||||
).toBe('wooden-fish-workspace');
|
||||
});
|
||||
|
||||
test('builds creation restore state for core session based plays', () => {
|
||||
expect(
|
||||
buildBigFishCreationUrlState({
|
||||
sessionId: ' big-fish-session-1 ',
|
||||
} as BigFishSessionSnapshotResponse),
|
||||
).toEqual({
|
||||
sessionId: 'big-fish-session-1',
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildMatch3DCreationUrlState({
|
||||
sessionId: 'match3d-session-1',
|
||||
draft: { profileId: 'match3d-profile-draft' },
|
||||
} as Match3DAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'match3d-session-1',
|
||||
profileId: 'match3d-profile-draft',
|
||||
workId: 'match3d-profile-draft',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildSquareHoleCreationUrlState({
|
||||
sessionId: 'square-session-1',
|
||||
publishedProfileId: 'square-profile-published',
|
||||
} as SquareHoleSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'square-session-1',
|
||||
profileId: 'square-profile-published',
|
||||
workId: 'square-profile-published',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildVisualNovelCreationUrlState({
|
||||
sessionId: 'visual-session-1',
|
||||
draft: { profileId: 'visual-profile-1' },
|
||||
} as VisualNovelAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'visual-session-1',
|
||||
profileId: 'visual-profile-1',
|
||||
workId: 'visual-profile-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle creation and runtime query state', () => {
|
||||
expect(
|
||||
buildPuzzleCreationUrlState({
|
||||
sessionId: 'puzzle-session-ocean',
|
||||
} as PuzzleAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'puzzle-session-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
workId: 'puzzle-work-ocean',
|
||||
});
|
||||
|
||||
const draftRuntime = buildPuzzleDraftRuntimeUrlState(
|
||||
buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
'level-2',
|
||||
);
|
||||
expect(draftRuntime).toEqual({
|
||||
mode: 'draft',
|
||||
runtimeSessionId: 'puzzle-session-ocean',
|
||||
runtimeProfileId: 'puzzle-profile-ocean',
|
||||
runtimeLevelId: 'level-2',
|
||||
});
|
||||
expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true);
|
||||
expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe(
|
||||
'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|',
|
||||
);
|
||||
|
||||
const publishedRuntime = buildPuzzlePublishedRuntimeUrlState(
|
||||
buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }),
|
||||
);
|
||||
expect(publishedRuntime.mode).toBe('published');
|
||||
expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean');
|
||||
expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u);
|
||||
});
|
||||
|
||||
test('builds creation state for work backed plays with work id priority', () => {
|
||||
expect(
|
||||
buildJumpHopCreationUrlState({
|
||||
session: {
|
||||
sessionId: 'jump-session-1',
|
||||
draft: { profileId: 'jump-profile-draft' },
|
||||
} as JumpHopSessionSnapshotResponse,
|
||||
work: {
|
||||
summary: {
|
||||
profileId: 'jump-profile-work',
|
||||
workId: 'jump-work-1',
|
||||
},
|
||||
} as JumpHopWorkProfileResponse,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'jump-session-1',
|
||||
profileId: 'jump-profile-work',
|
||||
workId: 'jump-work-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishCreationUrlState({
|
||||
session: {
|
||||
sessionId: 'wood-session-1',
|
||||
draft: { profileId: 'wood-profile-draft' },
|
||||
} as WoodenFishSessionSnapshotResponse,
|
||||
work: {
|
||||
summary: {
|
||||
profileId: 'wood-profile-work',
|
||||
workId: 'wood-work-1',
|
||||
},
|
||||
} as WoodenFishWorkProfileResponse,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'wood-session-1',
|
||||
profileId: 'wood-profile-work',
|
||||
draftId: 'wood-profile-work',
|
||||
workId: 'wood-work-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds creation state for draft backed local plays', () => {
|
||||
expect(
|
||||
buildBarkBattleCreationUrlState({
|
||||
draftId: 'bark-draft-1',
|
||||
workId: 'bark-work-1',
|
||||
} as BarkBattleDraftConfig),
|
||||
).toEqual({
|
||||
draftId: 'bark-draft-1',
|
||||
workId: 'bark-work-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildBabyObjectMatchCreationUrlState({
|
||||
draftId: 'baby-draft-1',
|
||||
profileId: 'baby-profile-1',
|
||||
} as BabyObjectMatchDraft),
|
||||
).toEqual({
|
||||
profileId: 'baby-profile-1',
|
||||
draftId: 'baby-draft-1',
|
||||
workId: 'baby-profile-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-base',
|
||||
profileId: 'puzzle-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-base',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾港口拼图。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾港口拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
431
src/components/platform-entry/platformCreationUrlStateModel.ts
Normal file
431
src/components/platform-entry/platformCreationUrlStateModel.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
type CreationUrlState,
|
||||
isCreationRestorePath,
|
||||
} from '../../services/creationUrlState';
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../services/jump-hop/jumpHopClient';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
buildPuzzleSessionIdFromProfileId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */
|
||||
export function normalizeCreationUrlValue(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
export function hasCreationUrlStateValue(state: CreationUrlState) {
|
||||
return Boolean(
|
||||
normalizeCreationUrlValue(state.sessionId) ||
|
||||
normalizeCreationUrlValue(state.profileId) ||
|
||||
normalizeCreationUrlValue(state.draftId) ||
|
||||
normalizeCreationUrlValue(state.workId),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) {
|
||||
return Boolean(
|
||||
normalizeCreationUrlValue(state.runtimeSessionId) ||
|
||||
normalizeCreationUrlValue(state.runtimeProfileId) ||
|
||||
normalizeCreationUrlValue(state.runtimeLevelId) ||
|
||||
normalizeCreationUrlValue(state.publicWorkCode) ||
|
||||
normalizeCreationUrlValue(state.mode),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) {
|
||||
return [
|
||||
normalizeCreationUrlValue(state.mode),
|
||||
normalizeCreationUrlValue(state.runtimeSessionId),
|
||||
normalizeCreationUrlValue(state.runtimeProfileId),
|
||||
normalizeCreationUrlValue(state.runtimeLevelId),
|
||||
normalizeCreationUrlValue(state.publicWorkCode),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export type CreationUrlRestoreTargetKind =
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'puzzle'
|
||||
| 'visual-novel'
|
||||
| 'bark-battle'
|
||||
| 'baby-object-match'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish';
|
||||
|
||||
type CreationUrlRestoreTargetBase = {
|
||||
kind: CreationUrlRestoreTargetKind;
|
||||
sessionId: string | null;
|
||||
profileId: string | null;
|
||||
draftId: string | null;
|
||||
workId: string | null;
|
||||
isGeneratingPath: boolean;
|
||||
};
|
||||
|
||||
export type BigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
|
||||
kind: 'big-fish';
|
||||
bigFishSessionId: string | null;
|
||||
};
|
||||
|
||||
type NonBigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
|
||||
kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'>;
|
||||
};
|
||||
|
||||
export type CreationUrlRestoreTarget =
|
||||
| BigFishCreationUrlRestoreTarget
|
||||
| NonBigFishCreationUrlRestoreTarget;
|
||||
|
||||
export type BigFishRestoreWorkIdentity = {
|
||||
sourceSessionId?: string | null;
|
||||
workId?: string | null;
|
||||
};
|
||||
|
||||
export type SessionProfileWorkRestoreIdentity = {
|
||||
sourceSessionId?: string | null;
|
||||
profileId?: string | null;
|
||||
workId?: string | null;
|
||||
};
|
||||
|
||||
export type ProfileRestoreWorkIdentity = {
|
||||
profileId?: string | null;
|
||||
};
|
||||
|
||||
export type BarkBattleRestoreWorkIdentity = {
|
||||
workId?: string | null;
|
||||
draftId?: string | null;
|
||||
};
|
||||
|
||||
export type BabyObjectMatchRestoreDraftIdentity = {
|
||||
profileId?: string | null;
|
||||
draftId?: string | null;
|
||||
};
|
||||
|
||||
const CREATION_URL_RESTORE_TARGET_ROUTES = [
|
||||
['/creation/big-fish', 'big-fish'],
|
||||
['/creation/match3d', 'match3d'],
|
||||
['/creation/square-hole', 'square-hole'],
|
||||
['/creation/puzzle', 'puzzle'],
|
||||
['/creation/visual-novel', 'visual-novel'],
|
||||
['/creation/bark-battle', 'bark-battle'],
|
||||
['/creation/baby-object-match', 'baby-object-match'],
|
||||
['/creation/jump-hop', 'jump-hop'],
|
||||
['/creation/wooden-fish', 'wooden-fish'],
|
||||
] as const satisfies readonly (readonly [
|
||||
string,
|
||||
CreationUrlRestoreTargetKind,
|
||||
])[];
|
||||
|
||||
export function resolveCreationUrlRestoreTarget(
|
||||
pathname: string | undefined,
|
||||
state: CreationUrlState,
|
||||
): CreationUrlRestoreTarget | null {
|
||||
const path = pathname?.trim() ?? '';
|
||||
const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) =>
|
||||
path === prefix || path.startsWith(`${prefix}/`),
|
||||
);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = route[1];
|
||||
const sessionId = normalizeCreationUrlValue(state.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(state.profileId);
|
||||
const draftId = normalizeCreationUrlValue(state.draftId);
|
||||
const workId = normalizeCreationUrlValue(state.workId);
|
||||
const base = {
|
||||
kind,
|
||||
sessionId,
|
||||
profileId,
|
||||
draftId,
|
||||
workId,
|
||||
isGeneratingPath: path.includes('/generating'),
|
||||
};
|
||||
|
||||
if (kind === 'big-fish') {
|
||||
return {
|
||||
...base,
|
||||
kind,
|
||||
bigFishSessionId:
|
||||
sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return base as NonBigFishCreationUrlRestoreTarget;
|
||||
}
|
||||
|
||||
function matchesRestoreValue(
|
||||
itemValue: string | null | undefined,
|
||||
targetValue: string | null,
|
||||
) {
|
||||
return Boolean(targetValue && itemValue === targetValue);
|
||||
}
|
||||
|
||||
export function matchesBigFishCreationUrlRestoreTarget(
|
||||
item: BigFishRestoreWorkIdentity,
|
||||
target: BigFishCreationUrlRestoreTarget,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.sourceSessionId, target.bigFishSessionId) ||
|
||||
matchesRestoreValue(item.workId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
item: SessionProfileWorkRestoreIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'sessionId' | 'profileId' | 'workId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.sourceSessionId, target.sessionId) ||
|
||||
matchesRestoreValue(item.profileId, target.profileId) ||
|
||||
matchesRestoreValue(item.workId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesVisualNovelCreationUrlRestoreTarget(
|
||||
item: ProfileRestoreWorkIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'profileId'>,
|
||||
) {
|
||||
return matchesRestoreValue(item.profileId, target.profileId);
|
||||
}
|
||||
|
||||
export function matchesBarkBattleCreationUrlRestoreTarget(
|
||||
item: BarkBattleRestoreWorkIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'workId' | 'draftId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.workId, target.workId) ||
|
||||
matchesRestoreValue(item.draftId, target.draftId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesBabyObjectMatchCreationUrlRestoreTarget(
|
||||
item: BabyObjectMatchRestoreDraftIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'profileId' | 'draftId' | 'workId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.profileId, target.profileId) ||
|
||||
matchesRestoreValue(item.draftId, target.draftId) ||
|
||||
matchesRestoreValue(item.profileId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveJumpHopCreationUrlRestoreStage(params: {
|
||||
isGeneratingPath: boolean;
|
||||
hasRestoredDraft: boolean;
|
||||
hasRestoredWork: boolean;
|
||||
}): SelectionStage {
|
||||
if (params.isGeneratingPath) {
|
||||
return 'jump-hop-generating';
|
||||
}
|
||||
|
||||
return params.hasRestoredDraft || params.hasRestoredWork
|
||||
? 'jump-hop-result'
|
||||
: 'jump-hop-workspace';
|
||||
}
|
||||
|
||||
export function resolveWoodenFishCreationUrlRestoreStage(params: {
|
||||
isGeneratingPath: boolean;
|
||||
hasRestoredDraft: boolean;
|
||||
}): SelectionStage {
|
||||
if (params.isGeneratingPath) {
|
||||
return 'wooden-fish-generating';
|
||||
}
|
||||
|
||||
return params.hasRestoredDraft
|
||||
? 'wooden-fish-result'
|
||||
: 'wooden-fish-workspace';
|
||||
}
|
||||
|
||||
export type InitialCreationUrlRestoreDecision =
|
||||
| { type: 'skip' }
|
||||
| { type: 'mark-handled' }
|
||||
| { type: 'wait' }
|
||||
| { type: 'restore' };
|
||||
|
||||
export function resolveInitialCreationUrlRestoreDecision(params: {
|
||||
handled: boolean;
|
||||
pathname: string | undefined;
|
||||
state: CreationUrlState;
|
||||
isLoadingPlatform: boolean;
|
||||
canReadProtectedData: boolean;
|
||||
}): InitialCreationUrlRestoreDecision {
|
||||
if (params.handled) {
|
||||
return { type: 'skip' };
|
||||
}
|
||||
|
||||
if (
|
||||
!isCreationRestorePath(params.pathname) ||
|
||||
!hasCreationUrlStateValue(params.state)
|
||||
) {
|
||||
return { type: 'mark-handled' };
|
||||
}
|
||||
|
||||
if (params.isLoadingPlatform || !params.canReadProtectedData) {
|
||||
return { type: 'wait' };
|
||||
}
|
||||
|
||||
return { type: 'restore' };
|
||||
}
|
||||
|
||||
export function buildBigFishCreationUrlState(
|
||||
session: BigFishSessionSnapshotResponse | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
workId: sessionId ? `big-fish-work-${sessionId}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DCreationUrlState(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.draft?.profileId ?? session?.publishedProfileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSquareHoleCreationUrlState(
|
||||
session: SquareHoleSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.draft?.profileId ?? session?.publishedProfileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleCreationUrlState(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId),
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleDraftRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRuntimeUrlState {
|
||||
const runtimeSessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ??
|
||||
buildPuzzleSessionIdFromProfileId(item.profileId);
|
||||
|
||||
return {
|
||||
mode: 'draft',
|
||||
runtimeSessionId,
|
||||
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
|
||||
runtimeLevelId: normalizeCreationUrlValue(levelId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzlePublishedRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRuntimeUrlState {
|
||||
return {
|
||||
mode: 'published',
|
||||
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
|
||||
runtimeLevelId: normalizeCreationUrlValue(levelId),
|
||||
publicWorkCode: buildPuzzlePublicWorkCode(item.profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVisualNovelCreationUrlState(
|
||||
session: VisualNovelAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(session?.draft?.profileId);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId ?? sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopCreationUrlState(params: {
|
||||
session?: JumpHopSessionSnapshotResponse | null;
|
||||
work?: JumpHopWorkProfileResponse | null;
|
||||
}): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
params.work?.summary.profileId ?? params.session?.draft?.profileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishCreationUrlState(params: {
|
||||
session?: WoodenFishSessionSnapshotResponse | null;
|
||||
work?: WoodenFishWorkProfileResponse | null;
|
||||
}): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
params.work?.summary.profileId ?? params.session?.draft?.profileId,
|
||||
);
|
||||
const draftId = profileId ?? sessionId;
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
draftId,
|
||||
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattleCreationUrlState(
|
||||
draft: BarkBattleDraftConfig | null,
|
||||
): CreationUrlState {
|
||||
return {
|
||||
draftId: normalizeCreationUrlValue(draft?.draftId),
|
||||
workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchCreationUrlState(
|
||||
draft: BabyObjectMatchDraft | null,
|
||||
): CreationUrlState {
|
||||
const profileId = normalizeCreationUrlValue(draft?.profileId);
|
||||
return {
|
||||
profileId,
|
||||
draftId: normalizeCreationUrlValue(draft?.draftId),
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
|
||||
|
||||
describe('platformCreationWorkDeleteFlow', () => {
|
||||
test('resolves RPG library delete confirmation without draft notice keys', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg-library',
|
||||
entry: {
|
||||
profileId: 'rpg-profile',
|
||||
worldName: '潮雾列岛',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'rpg-profile',
|
||||
title: '潮雾列岛',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves RPG work delete detail and notice keys by work status', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg',
|
||||
work: buildRpgWork(),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'rpg-work',
|
||||
title: 'RPG 草稿',
|
||||
detail: '删除后会从你的作品列表中移除。',
|
||||
noticeKeys: ['rpg:rpg-work', 'rpg:rpg-session', 'rpg:rpg-profile'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg',
|
||||
work: buildRpgWork({ status: 'published' }),
|
||||
}).detail,
|
||||
).toBe('删除后会从你的作品列表和公开广场中移除。');
|
||||
});
|
||||
|
||||
test('resolves mini game delete models with shared public and private detail copy', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'big-fish',
|
||||
work: buildBigFishWork({ status: 'published' }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 'big-fish-work',
|
||||
title: '大鱼作品',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: ['big-fish:big-fish-work', 'big-fish:big-fish-session'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'match3d',
|
||||
work: buildMatch3DWork(),
|
||||
}).detail,
|
||||
).toBe('删除后会从你的作品列表中移除。');
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'square-hole',
|
||||
work: buildSquareHoleWork({ publicationStatus: 'published' }),
|
||||
}).noticeKeys,
|
||||
).toEqual([
|
||||
'square-hole:square-hole-work',
|
||||
'square-hole:square-hole-profile',
|
||||
'square-hole:square-hole-session',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolves puzzle title fallback and stable result notice keys', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'puzzle',
|
||||
work: buildPuzzleWork({
|
||||
workTitle: ' ',
|
||||
levelName: ' 雾港第一关 ',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'puzzle-work',
|
||||
title: '雾港第一关',
|
||||
detail: '删除后会从你的作品列表中移除。',
|
||||
noticeKeys: [
|
||||
'puzzle:puzzle-work',
|
||||
'puzzle:puzzle-profile',
|
||||
'puzzle:puzzle-session-ocean',
|
||||
'puzzle:puzzle-work-ocean',
|
||||
'puzzle:puzzle-profile-ocean',
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'puzzle',
|
||||
work: buildPuzzleWork({ workTitle: '', levelName: ' ' }),
|
||||
}).title,
|
||||
).toBe('未命名拼图');
|
||||
});
|
||||
|
||||
test('resolves visual novel and baby object match special delete copy', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'visual-novel',
|
||||
work: buildVisualNovelWork({ title: '', publishStatus: 'published' }),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'visual-novel-profile',
|
||||
title: '未命名视觉小说',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: ['visual-novel:visual-novel-profile'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'baby-object-match',
|
||||
work: buildBabyObjectMatchDraft({
|
||||
workTitle: ' ',
|
||||
publicationStatus: 'published',
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'baby-profile',
|
||||
title: '宝贝识物',
|
||||
detail: '删除后会从你的作品列表和寓教于乐板块中移除。',
|
||||
noticeKeys: [
|
||||
'baby-object-match:baby-profile',
|
||||
'baby-object-match:baby-draft',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildRpgWork(
|
||||
overrides: Partial<CustomWorldWorkSummary> = {},
|
||||
): CustomWorldWorkSummary {
|
||||
return {
|
||||
workId: 'rpg-work',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: 'RPG 草稿',
|
||||
subtitle: '待完善',
|
||||
summary: 'RPG 摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
stage: 'draft',
|
||||
stageLabel: '草稿',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
sessionId: 'rpg-session',
|
||||
profileId: 'rpg-profile',
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work',
|
||||
sourceSessionId: 'big-fish-session',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '大鱼作品',
|
||||
subtitle: '大鱼吃小鱼',
|
||||
summary: '大鱼摘要。',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work',
|
||||
profileId: 'puzzle-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: '拼图作品',
|
||||
workDescription: '拼图摘要。',
|
||||
levelName: '拼图第一关',
|
||||
summary: '拼图摘要。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work',
|
||||
profileId: 'match3d-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session',
|
||||
gameName: '抓大鹅作品',
|
||||
themeText: '糖果厨房',
|
||||
summary: '抓大鹅摘要。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work',
|
||||
profileId: 'square-hole-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session',
|
||||
gameName: '方洞作品',
|
||||
themeText: '图形',
|
||||
twistRule: '反直觉',
|
||||
summary: '方洞摘要。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '背景',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 8,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile',
|
||||
ownerUserId: 'user-1',
|
||||
title: '视觉小说作品',
|
||||
description: '视觉小说摘要。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'draft',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-draft',
|
||||
profileId: 'baby-profile',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '宝贝识物作品',
|
||||
workDescription: '宝贝识物摘要。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'apple',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'banana',
|
||||
itemName: '香蕉',
|
||||
imageSrc: '/banana.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: [],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
288
src/components/platform-entry/platformCreationWorkDeleteFlow.ts
Normal file
288
src/components/platform-entry/platformCreationWorkDeleteFlow.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
collectDraftNoticeKeys,
|
||||
} from './platformDraftGenerationShelfModel';
|
||||
|
||||
const PRIVATE_WORK_DELETE_DETAIL = '删除后会从你的作品列表中移除。';
|
||||
const PUBLIC_GALLERY_DELETE_DETAIL = '删除后会从你的作品列表和公开广场中移除。';
|
||||
const EDUTAINMENT_PUBLIC_DELETE_DETAIL =
|
||||
'删除后会从你的作品列表和寓教于乐板块中移除。';
|
||||
|
||||
export type PlatformCreationWorkDeleteConfirmationModel = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
noticeKeys: string[];
|
||||
};
|
||||
|
||||
export type PlatformCreationWorkDeleteInput =
|
||||
| {
|
||||
kind: 'rpg-library';
|
||||
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>;
|
||||
}
|
||||
| {
|
||||
kind: 'rpg';
|
||||
work: Pick<
|
||||
CustomWorldWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'big-fish';
|
||||
work: Pick<
|
||||
BigFishWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sourceSessionId'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
work: Pick<
|
||||
PuzzleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'workTitle'
|
||||
| 'levelName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'match3d';
|
||||
work: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'square-hole';
|
||||
work: Pick<
|
||||
SquareHoleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'visual-novel';
|
||||
work: Pick<
|
||||
VisualNovelWorkSummary,
|
||||
'profileId' | 'title' | 'publishStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'baby-object-match';
|
||||
work: Pick<
|
||||
BabyObjectMatchDraft,
|
||||
| 'profileId'
|
||||
| 'draftId'
|
||||
| 'workTitle'
|
||||
| 'templateName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
};
|
||||
|
||||
export function resolvePlatformCreationWorkDeleteConfirmationModel(
|
||||
input: PlatformCreationWorkDeleteInput,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
switch (input.kind) {
|
||||
case 'rpg-library':
|
||||
return resolveRpgLibraryDeleteConfirmationModel(input.entry);
|
||||
case 'rpg':
|
||||
return resolveRpgWorkDeleteConfirmationModel(input.work);
|
||||
case 'big-fish':
|
||||
return resolveBigFishWorkDeleteConfirmationModel(input.work);
|
||||
case 'puzzle':
|
||||
return resolvePuzzleWorkDeleteConfirmationModel(input.work);
|
||||
case 'match3d':
|
||||
return resolveMatch3DWorkDeleteConfirmationModel(input.work);
|
||||
case 'square-hole':
|
||||
return resolveSquareHoleWorkDeleteConfirmationModel(input.work);
|
||||
case 'visual-novel':
|
||||
return resolveVisualNovelWorkDeleteConfirmationModel(input.work);
|
||||
case 'baby-object-match':
|
||||
return resolveBabyObjectMatchDeleteConfirmationModel(input.work);
|
||||
default: {
|
||||
const exhaustive: never = input;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusDeleteDetail(
|
||||
status: string,
|
||||
publishedDetail = PUBLIC_GALLERY_DELETE_DETAIL,
|
||||
) {
|
||||
return status === 'published' ? publishedDetail : PRIVATE_WORK_DELETE_DETAIL;
|
||||
}
|
||||
|
||||
function resolveTrimmedTitle(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const trimmedValue = value?.trim();
|
||||
return trimmedValue || fallback;
|
||||
}
|
||||
|
||||
function resolveRpgLibraryDeleteConfirmationModel(
|
||||
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: entry.profileId,
|
||||
title: entry.worldName,
|
||||
detail: PUBLIC_GALLERY_DELETE_DETAIL,
|
||||
noticeKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRpgWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
CustomWorldWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.title,
|
||||
detail: resolveStatusDeleteDetail(work.status),
|
||||
noticeKeys: collectDraftNoticeKeys('rpg', [
|
||||
work.workId,
|
||||
work.sessionId,
|
||||
work.profileId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBigFishWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
BigFishWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sourceSessionId'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.title,
|
||||
detail: resolveStatusDeleteDetail(work.status),
|
||||
noticeKeys: collectDraftNoticeKeys('big-fish', [
|
||||
work.workId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePuzzleWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
PuzzleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'workTitle'
|
||||
| 'levelName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: resolveTrimmedTitle(
|
||||
work.workTitle,
|
||||
resolveTrimmedTitle(work.levelName, '未命名拼图'),
|
||||
),
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('puzzle', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
buildPuzzleResultWorkId(work.sourceSessionId),
|
||||
buildPuzzleResultProfileId(work.sourceSessionId),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatch3DWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.gameName,
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('match3d', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSquareHoleWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
SquareHoleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.gameName,
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('square-hole', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVisualNovelWorkDeleteConfirmationModel(
|
||||
work: Pick<VisualNovelWorkSummary, 'profileId' | 'title' | 'publishStatus'>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.profileId,
|
||||
title: work.title || '未命名视觉小说',
|
||||
detail: resolveStatusDeleteDetail(work.publishStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('visual-novel', [work.profileId]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBabyObjectMatchDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
BabyObjectMatchDraft,
|
||||
'profileId' | 'draftId' | 'workTitle' | 'templateName' | 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.profileId,
|
||||
title: resolveTrimmedTitle(work.workTitle, work.templateName),
|
||||
detail: resolveStatusDeleteDetail(
|
||||
work.publicationStatus,
|
||||
EDUTAINMENT_PUBLIC_DELETE_DETAIL,
|
||||
),
|
||||
noticeKeys: collectDraftNoticeKeys('baby-object-match', [
|
||||
work.profileId,
|
||||
work.draftId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
113
src/components/platform-entry/platformDialogStateModel.test.ts
Normal file
113
src/components/platform-entry/platformDialogStateModel.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
buildPlatformTaskCompletionDialogDismissKey,
|
||||
formatPlatformDialogSource,
|
||||
isBackgroundGenerationStillRunningMessage,
|
||||
normalizePlatformDialogMessage,
|
||||
PLATFORM_TASK_COMPLETION_MESSAGE,
|
||||
resolveActivePlatformDialog,
|
||||
resolvePlatformErrorDialog,
|
||||
} from './platformDialogStateModel';
|
||||
|
||||
describe('platformDialogStateModel', () => {
|
||||
test('normalizes platform dialog messages', () => {
|
||||
expect(normalizePlatformDialogMessage(' 图片失败 ')).toBe('图片失败');
|
||||
expect(normalizePlatformDialogMessage(' ')).toBeNull();
|
||||
expect(normalizePlatformDialogMessage(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('formats dialog source with optional identity', () => {
|
||||
expect(formatPlatformDialogSource('拼图草稿', ' puzzle-session-1 ')).toBe(
|
||||
'拼图草稿 puzzle-session-1',
|
||||
);
|
||||
expect(formatPlatformDialogSource('拼图草稿', ' ')).toBe('拼图草稿');
|
||||
});
|
||||
|
||||
test('detects background generation still running messages', () => {
|
||||
expect(
|
||||
isBackgroundGenerationStillRunningMessage('后台仍在处理,请稍后查看。'),
|
||||
).toBe(true);
|
||||
expect(isBackgroundGenerationStillRunningMessage('素材生成失败。')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves the first non-empty error candidate', () => {
|
||||
expect(
|
||||
resolvePlatformErrorDialog([
|
||||
{
|
||||
key: 'empty',
|
||||
source: '空来源',
|
||||
message: ' ',
|
||||
},
|
||||
{
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: ' 素材生成失败。 ',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformErrorDialog([
|
||||
{
|
||||
key: 'empty',
|
||||
source: '空来源',
|
||||
message: null,
|
||||
},
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds stable dismiss keys for error and completion dialogs', () => {
|
||||
expect(
|
||||
buildPlatformErrorDialogDismissKey({
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
}),
|
||||
).toBe('puzzle:拼图草稿 puzzle-session-1:素材生成失败。');
|
||||
expect(buildPlatformErrorDialogDismissKey(null)).toBeNull();
|
||||
|
||||
expect(
|
||||
buildPlatformTaskCompletionDialogDismissKey({
|
||||
key: 'match3d',
|
||||
source: '抓大鹅草稿 match3d-session-1',
|
||||
message: PLATFORM_TASK_COMPLETION_MESSAGE,
|
||||
completedAtMs: null,
|
||||
}),
|
||||
).toBe(
|
||||
`match3d:抓大鹅草稿 match3d-session-1:${PLATFORM_TASK_COMPLETION_MESSAGE}:0`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hides active dialog when the dismiss key has already been recorded', () => {
|
||||
const dialog = {
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
};
|
||||
const dismissKey = buildPlatformErrorDialogDismissKey(dialog);
|
||||
|
||||
expect(
|
||||
resolveActivePlatformDialog(
|
||||
dialog,
|
||||
dismissKey,
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveActivePlatformDialog(
|
||||
dialog,
|
||||
'other-dismiss-key',
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
),
|
||||
).toBe(dialog);
|
||||
});
|
||||
});
|
||||
85
src/components/platform-entry/platformDialogStateModel.ts
Normal file
85
src/components/platform-entry/platformDialogStateModel.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PlatformErrorDialogPayload } from './PlatformErrorDialog';
|
||||
import type { PlatformTaskCompletionDialogPayload } from './PlatformTaskCompletionDialog';
|
||||
|
||||
export type PlatformErrorDialogState = PlatformErrorDialogPayload & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type PlatformTaskFailureDialogState = PlatformErrorDialogState & {
|
||||
failedAtMs: number;
|
||||
};
|
||||
|
||||
export type PlatformTaskCompletionDialogState =
|
||||
PlatformTaskCompletionDialogPayload & {
|
||||
key: string;
|
||||
completedAtMs: number | null;
|
||||
};
|
||||
|
||||
export type PlatformDialogCandidate = {
|
||||
key: string;
|
||||
source: string;
|
||||
message: string | null | undefined;
|
||||
};
|
||||
|
||||
export const PLATFORM_TASK_COMPLETION_MESSAGE =
|
||||
'生成任务已完成,可以继续查看草稿。';
|
||||
|
||||
/** 收口平台弹窗候选的纯状态规则,壳层只负责副作用清理。 */
|
||||
export function normalizePlatformDialogMessage(
|
||||
message: string | null | undefined,
|
||||
) {
|
||||
const normalized = message?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export function formatPlatformDialogSource(label: string, id?: string | null) {
|
||||
const normalizedId = id?.trim();
|
||||
return normalizedId ? `${label} ${normalizedId}` : label;
|
||||
}
|
||||
|
||||
export function isBackgroundGenerationStillRunningMessage(message: string) {
|
||||
return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message);
|
||||
}
|
||||
|
||||
export function resolvePlatformErrorDialog(
|
||||
candidates: readonly PlatformDialogCandidate[],
|
||||
): PlatformErrorDialogState | null {
|
||||
for (const candidate of candidates) {
|
||||
const message = normalizePlatformDialogMessage(candidate.message);
|
||||
if (message) {
|
||||
return {
|
||||
key: candidate.key,
|
||||
source: candidate.source,
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPlatformErrorDialogDismissKey(
|
||||
error: PlatformErrorDialogState | null,
|
||||
) {
|
||||
return error ? `${error.key}:${error.source}:${error.message}` : null;
|
||||
}
|
||||
|
||||
export function buildPlatformTaskCompletionDialogDismissKey(
|
||||
completion: PlatformTaskCompletionDialogState | null,
|
||||
) {
|
||||
return completion
|
||||
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveActivePlatformDialog<TDialog>(
|
||||
currentDialog: TDialog | null,
|
||||
dismissedDialogKey: string | null,
|
||||
buildDismissKey: (dialog: TDialog | null) => string | null,
|
||||
): TDialog | null {
|
||||
const currentDialogDismissKey = buildDismissKey(currentDialog);
|
||||
return currentDialogDismissKey &&
|
||||
currentDialogDismissKey === dismissedDialogKey
|
||||
? null
|
||||
: currentDialog;
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
buildCreationWorkShelfRuntimeState,
|
||||
buildPendingPuzzleWorks,
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
collectVisibleDraftNoticeKeys,
|
||||
createPendingDraftShelfState,
|
||||
type DraftGenerationNoticeMap,
|
||||
getGenerationNoticeShelfKeys,
|
||||
hasUnreadDraftGenerationUpdates,
|
||||
mergeBigFishWorkSummary,
|
||||
mergePuzzleWorkSummary,
|
||||
resolveBigFishDraftOpenIntent,
|
||||
resolveJumpHopDraftOpenIntent,
|
||||
resolveMatch3DDraftOpenIntent,
|
||||
resolvePuzzleDraftOpenIntent,
|
||||
resolveSquareHoleDraftOpenIntent,
|
||||
resolveVisualNovelDraftOpenIntent,
|
||||
resolveWoodenFishDraftOpenIntent,
|
||||
} from './platformDraftGenerationShelfModel';
|
||||
|
||||
describe('platformDraftGenerationShelfModel', () => {
|
||||
test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
sourceSessionId: null,
|
||||
publicationStatus: 'published',
|
||||
}),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork(),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
message: '首图生成失败。',
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'failed-generation',
|
||||
source: 'restored',
|
||||
errorMessage: '首图生成失败。',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork(),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'puzzle-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
coverImageSrc: '/media/puzzle-cover.png',
|
||||
}),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'generating',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
|
||||
const item = buildMatch3DWork({
|
||||
publicationStatus: 'published',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item,
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item,
|
||||
notices: {},
|
||||
forceDraft: true,
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => {
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item: buildMatch3DWork(),
|
||||
notices: {
|
||||
'match3d:match3d-session-base': {
|
||||
status: 'ready',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
hasBackgroundGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'ready-unread',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => {
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item: buildMatch3DWork({
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveBigFishDraftOpenIntent reopens active generating session before restoring draft', () => {
|
||||
expect(
|
||||
resolveBigFishDraftOpenIntent({
|
||||
item: buildBigFishWork(),
|
||||
activeSessionId: 'big-fish-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveBigFishDraftOpenIntent({
|
||||
item: buildBigFishWork(),
|
||||
activeSessionId: 'other-session',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSquareHoleDraftOpenIntent handles published, missing, active and restore states', () => {
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork({ publicationStatus: 'published' }),
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork({ sourceSessionId: null }),
|
||||
forceDraft: true,
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'missing-session',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork(),
|
||||
activeSessionId: 'square-hole-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
sourceSessionId: 'square-hole-session-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork(),
|
||||
activeSessionId: 'other-session',
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
shouldClearGenerationState: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveVisualNovelDraftOpenIntent handles published, active, current result and load detail states', () => {
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork({ publishStatus: 'published' }),
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'visual-novel-profile-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'visual-novel-profile-base',
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'current-result',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'other-profile',
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => {
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ publicationStatus: 'published' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork(),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'jump-hop-session-base',
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-failed-generation',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ sourceSessionId: null }),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-work-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => {
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork({
|
||||
sourceSessionId: null,
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
notices: {
|
||||
'wooden-fish:wooden-fish-profile-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'wooden-fish-profile-base',
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-failed-generation',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork({ generationStatus: 'generating' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork(),
|
||||
notices: {
|
||||
'wooden-fish:wooden-fish-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
failureFallbackStage: 'wooden-fish-workspace',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork(),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
failureFallbackStage: 'wooden-fish-generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => {
|
||||
const pending = buildPendingPuzzleWorks(
|
||||
{
|
||||
'puzzle-session-ocean': createPendingDraftShelfState(
|
||||
'failed',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
),
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]).toMatchObject({
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
workTitle: '拼图草稿',
|
||||
summary: '拼图草稿生成失败,可重新打开处理。',
|
||||
generationStatus: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => {
|
||||
const pending = buildPendingPuzzleWorks(
|
||||
{
|
||||
'puzzle-session-ocean': createPendingDraftShelfState(
|
||||
'generating',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
),
|
||||
},
|
||||
[buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })],
|
||||
);
|
||||
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
|
||||
test('mergePuzzleWorkSummary only replaces the matching profile', () => {
|
||||
const current = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-1',
|
||||
workTitle: '旧拼图',
|
||||
});
|
||||
const updated = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-1',
|
||||
workTitle: '新拼图',
|
||||
});
|
||||
const other = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-2',
|
||||
workTitle: '别的拼图',
|
||||
});
|
||||
|
||||
expect(mergePuzzleWorkSummary(current, updated)).toBe(updated);
|
||||
expect(mergePuzzleWorkSummary(current, other)).toBe(current);
|
||||
});
|
||||
|
||||
test('mergeBigFishWorkSummary only replaces the matching source session', () => {
|
||||
const current = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
title: '旧大鱼',
|
||||
});
|
||||
const updated = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
title: '新大鱼',
|
||||
});
|
||||
const other = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-2',
|
||||
title: '别的大鱼',
|
||||
});
|
||||
|
||||
expect(mergeBigFishWorkSummary(current, updated)).toBe(updated);
|
||||
expect(mergeBigFishWorkSummary(current, other)).toBe(current);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => {
|
||||
const [item] = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
buildPuzzleWork({
|
||||
workId: 'puzzle-work-empty',
|
||||
profileId: 'puzzle-profile-empty',
|
||||
sourceSessionId: 'puzzle-session-empty',
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
levelName: '',
|
||||
summary: '正在生成拼图草稿。',
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(item).toBeTruthy();
|
||||
|
||||
const noticeKeys = getGenerationNoticeShelfKeys(item!);
|
||||
const notices = Object.fromEntries(
|
||||
noticeKeys.map((key) => [
|
||||
key,
|
||||
{ status: 'failed', seen: false },
|
||||
]),
|
||||
) as DraftGenerationNoticeMap;
|
||||
|
||||
const state = buildCreationWorkShelfRuntimeState({
|
||||
item: item!,
|
||||
notices,
|
||||
pendingShelfItems: {
|
||||
puzzle: {
|
||||
'puzzle-session-empty': createPendingDraftShelfState(
|
||||
'failed',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
{ summary: '图片生成超时,可重新打开处理。' },
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
isGenerating: false,
|
||||
hasGenerationFailure: true,
|
||||
generationFailureSummary: '拼图草稿生成失败,可重新打开处理。',
|
||||
hasUnreadUpdate: false,
|
||||
suppressPersistedGenerating: true,
|
||||
titleOverride: '拼图草稿',
|
||||
summaryOverride: '图片生成超时,可重新打开处理。',
|
||||
});
|
||||
});
|
||||
|
||||
test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => {
|
||||
const puzzle = buildPuzzleWork({
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
});
|
||||
const visibleKeys = collectVisibleDraftNoticeKeys({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
jumpHopItems: [],
|
||||
woodenFishItems: [],
|
||||
match3dItems: [],
|
||||
squareHoleItems: [],
|
||||
puzzleItems: [puzzle],
|
||||
visualNovelItems: [],
|
||||
barkBattleItems: [],
|
||||
babyObjectMatchItems: [],
|
||||
});
|
||||
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-work-ocean');
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean');
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-session-ocean');
|
||||
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-work-ocean',
|
||||
);
|
||||
expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-profile-ocean',
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnreadDraftGenerationUpdates(
|
||||
{
|
||||
'puzzle:puzzle-profile-ocean': {
|
||||
status: 'ready',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
visibleKeys,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasUnreadDraftGenerationUpdates(
|
||||
{
|
||||
'puzzle:puzzle-profile-ocean': {
|
||||
status: 'ready',
|
||||
seen: true,
|
||||
},
|
||||
},
|
||||
visibleKeys,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function emptyGenerationFacts(
|
||||
overrides: Partial<Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation']> = {},
|
||||
): Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation'] {
|
||||
return {
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationFailure: false,
|
||||
hasActiveGenerationRunning: false,
|
||||
hasBackgroundGenerationFailure: false,
|
||||
hasBackgroundGenerationRunning: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-base',
|
||||
profileId: 'puzzle-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-base',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾港口拼图。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾港口拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-base',
|
||||
profileId: 'match3d-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-base',
|
||||
gameName: '潮雾抓大鹅',
|
||||
themeText: '潮雾港口',
|
||||
summary: '潮雾港口抓大鹅。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work-base',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾大鱼',
|
||||
subtitle: '潮雾港口',
|
||||
summary: '潮雾港口大鱼吃小鱼。',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work-base',
|
||||
profileId: 'square-hole-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session-base',
|
||||
gameName: '潮雾方洞',
|
||||
themeText: '潮雾港口',
|
||||
twistRule: '避开雾门',
|
||||
summary: '潮雾港口方洞挑战。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '潮雾港口',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 1,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
title: '潮雾视觉小说',
|
||||
description: '潮雾港口视觉小说。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'draft',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopWork(
|
||||
overrides: Partial<JumpHopWorkSummaryResponse> = {},
|
||||
): JumpHopWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-base',
|
||||
profileId: 'jump-hop-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-base',
|
||||
workTitle: '潮雾跳一跳',
|
||||
workDescription: '潮雾港口跳一跳。',
|
||||
themeTags: [],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishWork(
|
||||
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-base',
|
||||
profileId: 'wooden-fish-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-base',
|
||||
workTitle: '潮雾敲木鱼',
|
||||
workDescription: '潮雾港口敲木鱼。',
|
||||
themeTags: ['敲木鱼'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
1490
src/components/platform-entry/platformDraftGenerationShelfModel.ts
Normal file
1490
src/components/platform-entry/platformDraftGenerationShelfModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
MiniGameDraftGenerationKind,
|
||||
MiniGameDraftGenerationPhase,
|
||||
MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
|
||||
|
||||
function buildGenerationState(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
phase: MiniGameDraftGenerationPhase = 'compile',
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind,
|
||||
phase,
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 1,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformGenerationProgressTickModel', () => {
|
||||
test('ticks while a mini-game generation stage has a running state', () => {
|
||||
const cases: Array<
|
||||
[stage: SelectionStage, kind: MiniGameDraftGenerationKind]
|
||||
> = [
|
||||
['puzzle-generating', 'puzzle'],
|
||||
['match3d-generating', 'match3d'],
|
||||
['big-fish-generating', 'big-fish'],
|
||||
['square-hole-generating', 'square-hole'],
|
||||
['jump-hop-generating', 'jump-hop'],
|
||||
['wooden-fish-generating', 'wooden-fish'],
|
||||
['baby-object-match-generating', 'baby-object-match'],
|
||||
];
|
||||
|
||||
for (const [selectionStage, kind] of cases) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
[kind]: buildGenerationState(kind),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: kind,
|
||||
shouldTick: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick mini-game generation when state is missing or terminal', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
|
||||
for (const phase of ['ready', 'failed'] as const) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {
|
||||
puzzle: buildGenerationState('puzzle', phase),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick when stage and mini-game state do not match', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {
|
||||
match3d: buildGenerationState('match3d'),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('ticks visual novel generation only after it has started and before terminal phases', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: false,
|
||||
});
|
||||
|
||||
for (const phase of ['ready', 'failed'] as const) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick non-generation stages even when states are present', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'platform',
|
||||
miniGameStates: {
|
||||
puzzle: buildGenerationState('puzzle'),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: null,
|
||||
shouldTick: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
MiniGameDraftGenerationKind,
|
||||
MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
export type PlatformVisualNovelGenerationPhase =
|
||||
| 'generating'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
export type PlatformGenerationProgressTickKind =
|
||||
| MiniGameDraftGenerationKind
|
||||
| 'visual-novel';
|
||||
|
||||
export type PlatformGenerationProgressTickInput = {
|
||||
selectionStage: SelectionStage;
|
||||
miniGameStates: Partial<
|
||||
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
|
||||
>;
|
||||
visualNovel: {
|
||||
startedAtMs: number | null;
|
||||
phase: PlatformVisualNovelGenerationPhase;
|
||||
};
|
||||
};
|
||||
|
||||
export type PlatformGenerationProgressTickDecision = {
|
||||
activeKind: PlatformGenerationProgressTickKind | null;
|
||||
shouldTick: boolean;
|
||||
};
|
||||
|
||||
const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial<
|
||||
Record<SelectionStage, MiniGameDraftGenerationKind>
|
||||
> = {
|
||||
'puzzle-generating': 'puzzle',
|
||||
'match3d-generating': 'match3d',
|
||||
'big-fish-generating': 'big-fish',
|
||||
'square-hole-generating': 'square-hole',
|
||||
'jump-hop-generating': 'jump-hop',
|
||||
'wooden-fish-generating': 'wooden-fish',
|
||||
'baby-object-match-generating': 'baby-object-match',
|
||||
};
|
||||
|
||||
function shouldTickMiniGameGenerationState(
|
||||
state: MiniGameDraftGenerationState | null | undefined,
|
||||
) {
|
||||
return state != null && state.phase !== 'ready' && state.phase !== 'failed';
|
||||
}
|
||||
|
||||
/** 收口生成页进度 tick 判定,壳层只保留 interval 副作用。 */
|
||||
export function resolvePlatformGenerationProgressTickDecision(
|
||||
input: PlatformGenerationProgressTickInput,
|
||||
): PlatformGenerationProgressTickDecision {
|
||||
if (input.selectionStage === 'visual-novel-generating') {
|
||||
return {
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick:
|
||||
input.visualNovel.startedAtMs != null &&
|
||||
input.visualNovel.phase !== 'ready' &&
|
||||
input.visualNovel.phase !== 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
const activeKind =
|
||||
MINI_GAME_GENERATION_STAGE_TO_KIND[input.selectionStage] ?? null;
|
||||
if (!activeKind) {
|
||||
return {
|
||||
activeKind: null,
|
||||
shouldTick: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeKind,
|
||||
shouldTick: shouldTickMiniGameGenerationState(
|
||||
input.miniGameStates[activeKind],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildMatch3DProfileFromSession,
|
||||
mapMatch3DWorkToPublicWorkDetail,
|
||||
mapPublicWorkDetailToMatch3DWork,
|
||||
resolveActiveMatch3DRuntimeProfile,
|
||||
resolveMatch3DRuntimeBackgroundImageSrc,
|
||||
resolveMatch3DRuntimeGeneratedBackgroundAsset,
|
||||
resolveMatch3DRuntimeGeneratedItemAssets,
|
||||
} from './platformMatch3DRuntimeProfile';
|
||||
|
||||
function buildBackgroundAsset(
|
||||
overrides: Partial<Match3DGeneratedBackgroundAsset> = {},
|
||||
): Match3DGeneratedBackgroundAsset {
|
||||
return {
|
||||
prompt: '森林棋盘',
|
||||
imageSrc: '/generated/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
status: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildItemAsset(
|
||||
overrides: Partial<Match3DGeneratedItemAsset> = {},
|
||||
): Match3DGeneratedItemAsset {
|
||||
return {
|
||||
itemId: 'item-1',
|
||||
itemName: '蘑菇',
|
||||
imageSrc: '/generated/match3d/item.png',
|
||||
imageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfile(
|
||||
overrides: Partial<Match3DWorkProfile> = {},
|
||||
): Match3DWorkProfile {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '森林抓鹅',
|
||||
themeText: '森林',
|
||||
summary: '找出蘑菇。',
|
||||
tags: ['森林', '蘑菇'],
|
||||
coverImageSrc: '/cover.png',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: 1,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [buildItemAsset()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRun(overrides: Partial<Match3DRunSnapshot> = {}): Match3DRunSnapshot {
|
||||
return {
|
||||
runId: 'match3d-run-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
status: 'running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1000,
|
||||
durationLimitMs: 60000,
|
||||
remainingMs: 55000,
|
||||
clearCount: 12,
|
||||
totalItemCount: 12,
|
||||
clearedItemCount: 0,
|
||||
items: [],
|
||||
traySlots: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublicWork(
|
||||
overrides: Partial<PlatformMatch3DGalleryCard> = {},
|
||||
): PlatformMatch3DGalleryCard {
|
||||
return {
|
||||
sourceType: 'match3d',
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
publicWorkCode: 'M3D-00000001',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '森林抓鹅',
|
||||
subtitle: '抓大鹅',
|
||||
summaryText: '找出蘑菇。',
|
||||
coverImageSrc: '/cover.png',
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [buildItemAsset()],
|
||||
themeTags: ['森林', '蘑菇'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('Match3D runtime profile maps public detail and promotes item background asset', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/background-from-item.png',
|
||||
imageObjectKey: 'oss/background-from-item.png',
|
||||
});
|
||||
const work = mapPublicWorkDetailToMatch3DWork(
|
||||
buildPublicWork({
|
||||
generatedBackgroundAsset: null,
|
||||
backgroundImageSrc: null,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset);
|
||||
expect(work?.backgroundImageSrc).toBe(
|
||||
'/generated/match3d/background-from-item.png',
|
||||
);
|
||||
expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png');
|
||||
});
|
||||
|
||||
test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/detail-background.png',
|
||||
});
|
||||
const detail = mapMatch3DWorkToPublicWorkDetail(
|
||||
buildProfile({
|
||||
generatedBackgroundAsset: null,
|
||||
backgroundImageSrc: null,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
sourceType: 'match3d',
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
backgroundImageSrc: '/generated/match3d/detail-background.png',
|
||||
generatedBackgroundAsset: backgroundAsset,
|
||||
});
|
||||
});
|
||||
|
||||
test('Match3D runtime profile builds draft profile from session snapshot', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/draft-background.png',
|
||||
});
|
||||
const session: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-draft',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_compiled',
|
||||
anchorPack: {
|
||||
theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' },
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除数',
|
||||
value: '12',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '4',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
draft: {
|
||||
profileId: 'match3d-draft-profile',
|
||||
gameName: '草稿抓鹅',
|
||||
themeText: '森林',
|
||||
summaryText: '草稿摘要',
|
||||
tags: ['森林'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: '/reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publishReady: true,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const profile = buildMatch3DProfileFromSession(session);
|
||||
|
||||
expect(profile?.profileId).toBe('match3d-draft-profile');
|
||||
expect(profile?.sourceSessionId).toBe('match3d-session-draft');
|
||||
expect(profile?.publicationStatus).toBe('draft');
|
||||
expect(profile?.coverImageSrc).toBe('/reference.png');
|
||||
expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset);
|
||||
expect(profile?.backgroundImageSrc).toBe(
|
||||
'/generated/match3d/draft-background.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('Match3D runtime profile selects active profile by run profile id', () => {
|
||||
const runtimeProfile = buildProfile({
|
||||
profileId: 'runtime-profile',
|
||||
gameName: '运行态抓鹅',
|
||||
});
|
||||
const draftProfile = buildProfile({
|
||||
profileId: 'draft-profile',
|
||||
gameName: '旧草稿抓鹅',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
buildRun({ profileId: 'runtime-profile' }),
|
||||
runtimeProfile,
|
||||
draftProfile,
|
||||
),
|
||||
).toBe(runtimeProfile);
|
||||
expect(
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
buildRun({ profileId: 'draft-profile' }),
|
||||
runtimeProfile,
|
||||
draftProfile,
|
||||
),
|
||||
).toBe(draftProfile);
|
||||
});
|
||||
|
||||
test('Match3D runtime profile resolves generated assets from matching public detail', () => {
|
||||
const staleProfile = buildProfile({
|
||||
profileId: 'stale-profile',
|
||||
generatedBackgroundAsset: buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/stale-background.png',
|
||||
}),
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
itemId: 'stale-item',
|
||||
imageSrc: '/generated/match3d/stale-item.png',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const publicBackground = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/public-background.png',
|
||||
});
|
||||
const publicWork = buildPublicWork({
|
||||
profileId: 'public-profile',
|
||||
generatedBackgroundAsset: publicBackground,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
itemId: 'public-item',
|
||||
imageSrc: '/generated/match3d/public-item.png',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const run = buildRun({ profileId: 'public-profile' });
|
||||
|
||||
expect(
|
||||
resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some(
|
||||
(asset) => asset.imageSrc === '/generated/match3d/public-item.png',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork),
|
||||
).toEqual(publicBackground);
|
||||
expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe(
|
||||
'/generated/match3d/public-background.png',
|
||||
);
|
||||
});
|
||||
335
src/components/platform-entry/platformMatch3DRuntimeProfile.ts
Normal file
335
src/components/platform-entry/platformMatch3DRuntimeProfile.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
Match3DWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import {
|
||||
hasMatch3DGeneratedImageAsset,
|
||||
mergeMatch3DGeneratedItemAssetsForRuntime,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
isMatch3DGalleryEntry,
|
||||
mapMatch3DWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export function mapMatch3DWorkToPublicWorkDetail(
|
||||
item: Match3DWorkSummary,
|
||||
): PlatformPublicGalleryCard {
|
||||
return mapMatch3DWorkToPlatformGalleryCard(
|
||||
normalizeMatch3DWorkForRuntimeUi(item),
|
||||
);
|
||||
}
|
||||
|
||||
export function mapPublicWorkDetailToMatch3DWork(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): Match3DWorkSummary | null {
|
||||
if (!isMatch3DGalleryEntry(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: entry.workId,
|
||||
profileId: entry.profileId,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
sourceSessionId:
|
||||
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
|
||||
? entry.sourceSessionId
|
||||
: null,
|
||||
gameName: entry.worldName,
|
||||
themeText: entry.themeTags[0] ?? '经典消除',
|
||||
summary: entry.summaryText,
|
||||
tags: entry.themeTags,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: entry.playCount ?? 0,
|
||||
updatedAt: entry.updatedAt,
|
||||
publishedAt: entry.publishedAt,
|
||||
publishReady: true,
|
||||
backgroundPrompt: entry.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: entry.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset:
|
||||
entry.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ??
|
||||
null,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
entry.generatedItemAssets ?? [],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function findMatch3DGeneratedBackgroundAsset(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
): Match3DGeneratedBackgroundAsset | null {
|
||||
return (
|
||||
generatedItemAssets
|
||||
?.map((asset) => asset.backgroundAsset ?? null)
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function promoteMatch3DGeneratedBackgroundAsset<
|
||||
T extends Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundPrompt'
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
>(profile: T): T {
|
||||
const backgroundAsset =
|
||||
profile.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
|
||||
if (!backgroundAsset) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
backgroundPrompt:
|
||||
profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
|
||||
backgroundImageSrc:
|
||||
profile.backgroundImageSrc ??
|
||||
backgroundAsset.imageSrc ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
null,
|
||||
backgroundImageObjectKey:
|
||||
profile.backgroundImageObjectKey ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
backgroundAsset.imageSrc ??
|
||||
null,
|
||||
generatedBackgroundAsset:
|
||||
profile.generatedBackgroundAsset ?? backgroundAsset,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMatch3DWorkForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profile: T,
|
||||
): T {
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
...profile,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
profile.generatedItemAssets,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function mapMatch3DWorksForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profiles: readonly T[],
|
||||
): T[] {
|
||||
return profiles.map(normalizeMatch3DWorkForRuntimeUi);
|
||||
}
|
||||
|
||||
export function buildMatch3DProfileFromSession(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): Match3DWorkProfile | null {
|
||||
const draft = session?.draft;
|
||||
if (!session || !draft?.profileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
draft.generatedItemAssets,
|
||||
);
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
gameName: draft.gameName,
|
||||
themeText: draft.themeText,
|
||||
summary: draft.summary ?? draft.summaryText ?? '',
|
||||
tags: draft.tags,
|
||||
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
|
||||
referenceImageSrc: draft.referenceImageSrc ?? null,
|
||||
clearCount: draft.clearCount,
|
||||
difficulty: draft.difficulty,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
backgroundPrompt: draft.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasMatch3DRuntimeAsset(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
return hasMatch3DGeneratedImageAsset(assets);
|
||||
}
|
||||
|
||||
export function hasMatch3DRuntimeBackgroundAsset(
|
||||
profile: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
) {
|
||||
return Boolean(
|
||||
profile.backgroundImageSrc?.trim() ||
|
||||
profile.backgroundImageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
profile.generatedItemAssets?.some(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.imageSrc?.trim() ||
|
||||
asset.backgroundAsset?.imageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileAssets = profile?.generatedItemAssets ?? [];
|
||||
const publicDetailAssets =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (publicWorkDetail.generatedItemAssets ?? [])
|
||||
: [];
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
if (hasMatch3DRuntimeAsset(profileAssets)) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
if (
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return hasMatch3DRuntimeAsset(publicDetailAssets)
|
||||
? mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
publicDetailAssets,
|
||||
profileAssets,
|
||||
)
|
||||
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
|
||||
}
|
||||
|
||||
if (hasMatch3DRuntimeAsset(profileAssets)) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
return publicDetailAssets.length > 0
|
||||
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
|
||||
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeGeneratedBackgroundAsset(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileBackground = profile
|
||||
? (promoteMatch3DGeneratedBackgroundAsset(profile)
|
||||
.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground ?? profileBackground;
|
||||
}
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
|
||||
export function resolveActiveMatch3DRuntimeProfile(
|
||||
run: Match3DRunSnapshot | null,
|
||||
runtimeProfile: Match3DWorkProfile | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
|
||||
return runtimeProfile;
|
||||
}
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profile;
|
||||
}
|
||||
return runtimeProfile ?? profile;
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const resolvedProfile = profile
|
||||
? promoteMatch3DGeneratedBackgroundAsset(profile)
|
||||
: null;
|
||||
const resolvedPublicWork =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
: null;
|
||||
const profileBackground =
|
||||
resolvedProfile?.backgroundImageSrc?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
const publicBackground =
|
||||
resolvedPublicWork?.backgroundImageSrc?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground || profileBackground || null;
|
||||
}
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
import {
|
||||
createFailedMiniGameDraftGenerationStateForRestoredDraft,
|
||||
createMiniGameDraftGenerationStateForRestoredDraft,
|
||||
createPuzzleDraftGenerationStateFromPayload,
|
||||
isMiniGameDraftGenerating,
|
||||
isMiniGameDraftReady,
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState,
|
||||
mergePuzzleSessionProgressIntoGenerationState,
|
||||
rebaseMiniGameDraftBackgroundCompileTaskForDisplay,
|
||||
rebaseMiniGameDraftGenerationStateForDisplay,
|
||||
resolveFinishedMiniGameDraftGenerationState,
|
||||
resolvePuzzlePhaseFromSessionProgress,
|
||||
} from './platformMiniGameDraftGenerationStateModel';
|
||||
|
||||
const NOW = Date.parse('2026-06-04T03:00:00.000Z');
|
||||
const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z';
|
||||
const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT);
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 90,
|
||||
stage: 'draft_ready',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把星桥碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [],
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: SESSION_UPDATED_AT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildState(
|
||||
overrides: Partial<MiniGameDraftGenerationState> = {},
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: 100,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
metadata: {
|
||||
puzzleAiRedraw: true,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DAsset(
|
||||
overrides: Partial<Match3DGeneratedItemAsset> = {},
|
||||
): Match3DGeneratedItemAsset {
|
||||
return {
|
||||
itemId: 'item-1',
|
||||
itemName: '红宝石',
|
||||
status: 'pending',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('platformMiniGameDraftGenerationStateModel', () => {
|
||||
test('creates restored generation state with metadata and explicit start time', () => {
|
||||
expect(
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'match3d',
|
||||
{ puzzleAiRedraw: false },
|
||||
123,
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: 'match3d',
|
||||
phase: 'match3d-work-title',
|
||||
startedAtMs: 123,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('creates failed restored state from backend updated time', () => {
|
||||
expect(
|
||||
createFailedMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'puzzle',
|
||||
SESSION_UPDATED_AT,
|
||||
'生成失败',
|
||||
{ puzzleAiRedraw: true },
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'failed',
|
||||
startedAtMs: SESSION_UPDATED_AT_MS,
|
||||
finishedAtMs: NOW,
|
||||
error: '生成失败',
|
||||
metadata: {
|
||||
puzzleAiRedraw: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('rebases finished state for display without changing other fields', () => {
|
||||
const state = buildState({
|
||||
phase: 'ready',
|
||||
finishedAtMs: 300,
|
||||
completedAssetCount: 2,
|
||||
totalAssetCount: 3,
|
||||
});
|
||||
|
||||
expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
});
|
||||
expect(
|
||||
rebaseMiniGameDraftBackgroundCompileTaskForDisplay({
|
||||
sessionId: 'task-1',
|
||||
generationState: state,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'task-1',
|
||||
generationState: {
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('creates puzzle generation state from payload and compiled session', () => {
|
||||
const payload: CreatePuzzleAgentSessionRequest = {
|
||||
seedText: '星桥',
|
||||
aiRedraw: false,
|
||||
};
|
||||
|
||||
expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: NOW,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: undefined,
|
||||
puzzleActiveStepStartedAtMs: undefined,
|
||||
puzzleProgressPercent: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()),
|
||||
).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: SESSION_UPDATED_AT_MS,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: NOW,
|
||||
puzzleProgressPercent: 90,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves puzzle phase from backend progress thresholds', () => {
|
||||
const state = buildState();
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 96 }),
|
||||
),
|
||||
).toBe('puzzle-select-image');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 94 }),
|
||||
),
|
||||
).toBe('puzzle-ui-assets');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
buildState({ metadata: { puzzleAiRedraw: false } }),
|
||||
buildPuzzleSession({ progressPercent: 88 }),
|
||||
),
|
||||
).toBe('puzzle-level-scene');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 88 }),
|
||||
),
|
||||
).toBe('puzzle-cover-image');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 20 }),
|
||||
),
|
||||
).toBe('compile');
|
||||
});
|
||||
|
||||
test('merges compiled puzzle session progress into generation state', () => {
|
||||
expect(
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
buildState({
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
},
|
||||
}),
|
||||
buildPuzzleSession({ progressPercent: 90 }),
|
||||
),
|
||||
).toMatchObject({
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'puzzle-level-scene',
|
||||
puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS,
|
||||
puzzleProgressPercent: 90,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
buildState(),
|
||||
buildPuzzleSession({
|
||||
draft: {
|
||||
...buildPuzzleSession().draft!,
|
||||
formDraft: {
|
||||
pictureDescription: '星桥',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).metadata,
|
||||
).toMatchObject({
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test('merges match3d generated assets into active generation state', () => {
|
||||
const state = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'match3d-material-sheet',
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: '旧错误',
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(state, [
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-view',
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'front',
|
||||
viewIndex: 0,
|
||||
imageObjectKey: 'objects/front.png',
|
||||
},
|
||||
],
|
||||
}),
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-src',
|
||||
imageSrc: '/generated/item.png',
|
||||
}),
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-error',
|
||||
error: '切图失败',
|
||||
}),
|
||||
]),
|
||||
).toMatchObject({
|
||||
phase: 'match3d-generate-views',
|
||||
completedAssetCount: 2,
|
||||
totalAssetCount: 5,
|
||||
error: '切图失败',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps match3d generated asset merge away from finished states', () => {
|
||||
const readyState = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'ready',
|
||||
completedAssetCount: 5,
|
||||
totalAssetCount: 5,
|
||||
});
|
||||
const failedState = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'failed',
|
||||
error: '已失败',
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBe(readyState);
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBe(failedState);
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(null, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('finishes generation state and resolves ready/generating flags', () => {
|
||||
const failedState = resolveFinishedMiniGameDraftGenerationState(
|
||||
buildState({ error: '旧错误' }),
|
||||
'failed',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 2,
|
||||
},
|
||||
);
|
||||
|
||||
expect(failedState).toMatchObject({
|
||||
phase: 'failed',
|
||||
finishedAtMs: NOW,
|
||||
error: '旧错误',
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 2,
|
||||
});
|
||||
expect(isMiniGameDraftReady(failedState)).toBe(false);
|
||||
expect(isMiniGameDraftGenerating(failedState)).toBe(false);
|
||||
expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true);
|
||||
expect(isMiniGameDraftGenerating(buildState())).toBe(true);
|
||||
expect(isMiniGameDraftGenerating(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
createMiniGameDraftGenerationState,
|
||||
type MiniGameDraftGenerationKind,
|
||||
type MiniGameDraftGenerationPhase,
|
||||
type MiniGameDraftGenerationState,
|
||||
resolveMiniGameDraftGenerationStartedAtMs,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
|
||||
export function createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
startedAtMs = Date.now(),
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(kind, startedAtMs),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFailedMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
updatedAt: string | null | undefined,
|
||||
error: string,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
): MiniGameDraftGenerationState {
|
||||
return resolveFinishedMiniGameDraftGenerationState(
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind,
|
||||
metadata,
|
||||
resolveMiniGameDraftGenerationStartedAtMs(updatedAt),
|
||||
),
|
||||
'failed',
|
||||
{ error },
|
||||
);
|
||||
}
|
||||
|
||||
/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */
|
||||
export function rebaseMiniGameDraftGenerationStateForDisplay(
|
||||
state: MiniGameDraftGenerationState,
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function rebaseMiniGameDraftBackgroundCompileTaskForDisplay<
|
||||
T extends { generationState: MiniGameDraftGenerationState },
|
||||
>(task: T): T {
|
||||
return {
|
||||
...task,
|
||||
generationState: rebaseMiniGameDraftGenerationStateForDisplay(
|
||||
task.generationState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPuzzleDraftGenerationStateFromPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null | undefined,
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined = null,
|
||||
): MiniGameDraftGenerationState {
|
||||
const puzzleProgressPercent =
|
||||
session?.draft && !session.draft.formDraft
|
||||
? session.progressPercent
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(
|
||||
'puzzle',
|
||||
resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt),
|
||||
),
|
||||
metadata: {
|
||||
puzzleAiRedraw: payload?.aiRedraw ?? true,
|
||||
puzzleActivePhaseId:
|
||||
typeof puzzleProgressPercent === 'number' ? 'compile' : undefined,
|
||||
puzzleActiveStepStartedAtMs:
|
||||
typeof puzzleProgressPercent === 'number' ? Date.now() : undefined,
|
||||
puzzleProgressPercent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePuzzlePhaseFromSessionProgress(
|
||||
state: MiniGameDraftGenerationState,
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (session.progressPercent >= 96) {
|
||||
return 'puzzle-select-image';
|
||||
}
|
||||
if (session.progressPercent >= 94) {
|
||||
return 'puzzle-ui-assets';
|
||||
}
|
||||
if (session.progressPercent >= 88) {
|
||||
return state.metadata?.puzzleAiRedraw === false
|
||||
? 'puzzle-level-scene'
|
||||
: 'puzzle-cover-image';
|
||||
}
|
||||
|
||||
return 'compile';
|
||||
}
|
||||
|
||||
export function mergePuzzleSessionProgressIntoGenerationState(
|
||||
state: MiniGameDraftGenerationState,
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): MiniGameDraftGenerationState {
|
||||
const isCompiledGenerationSession = Boolean(
|
||||
session.draft && !session.draft.formDraft,
|
||||
);
|
||||
|
||||
const nextPhaseId = isCompiledGenerationSession
|
||||
? resolvePuzzlePhaseFromSessionProgress(state, session)
|
||||
: state.metadata?.puzzleActivePhaseId;
|
||||
const shouldResetActiveStepStart =
|
||||
isCompiledGenerationSession &&
|
||||
nextPhaseId != null &&
|
||||
nextPhaseId !== state.metadata?.puzzleActivePhaseId;
|
||||
|
||||
return {
|
||||
...state,
|
||||
metadata: {
|
||||
...state.metadata,
|
||||
puzzleActivePhaseId: nextPhaseId,
|
||||
puzzleActiveStepStartedAtMs: shouldResetActiveStepStart
|
||||
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
|
||||
: state.metadata?.puzzleActiveStepStartedAtMs,
|
||||
puzzleProgressPercent: isCompiledGenerationSession
|
||||
? session.progressPercent
|
||||
: state.metadata?.puzzleProgressPercent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeMatch3DGeneratedAssetsIntoGenerationState(
|
||||
current: MiniGameDraftGenerationState | null,
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
): MiniGameDraftGenerationState | null {
|
||||
if (!current || current.phase === 'ready' || current.phase === 'failed') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const assetList = assets ?? [];
|
||||
const imageReadyCount = assetList.filter(
|
||||
(asset) =>
|
||||
asset.imageViews?.some(
|
||||
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
|
||||
) ||
|
||||
asset.imageObjectKey?.trim() ||
|
||||
asset.imageSrc?.trim(),
|
||||
).length;
|
||||
const totalAssetCount = Math.max(5, assetList.length);
|
||||
const failedAsset = assetList.find((asset) => asset.error?.trim());
|
||||
|
||||
return {
|
||||
...current,
|
||||
phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase,
|
||||
completedAssetCount: imageReadyCount,
|
||||
totalAssetCount,
|
||||
error: failedAsset?.error?.trim() || current.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveFinishedMiniGameDraftGenerationState(
|
||||
state: MiniGameDraftGenerationState,
|
||||
phase: 'ready' | 'failed',
|
||||
options: {
|
||||
error?: string | null;
|
||||
completedAssetCount?: number;
|
||||
totalAssetCount?: number;
|
||||
} = {},
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...state,
|
||||
phase,
|
||||
finishedAtMs: Date.now(),
|
||||
error: options.error ?? state.error,
|
||||
completedAssetCount:
|
||||
options.completedAssetCount ?? state.completedAssetCount,
|
||||
totalAssetCount: options.totalAssetCount ?? state.totalAssetCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function isMiniGameDraftReady(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
) {
|
||||
return state?.phase === 'ready';
|
||||
}
|
||||
|
||||
export function isMiniGameDraftGenerating(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
) {
|
||||
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorPackResponse,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleDraftLevel,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildJumpHopDraftActionPayload,
|
||||
buildMatch3DFormPayloadFromSession,
|
||||
buildMatch3DFormPayloadFromWork,
|
||||
buildPendingMatch3DDraftMetadata,
|
||||
buildPendingPuzzleDraftMetadata,
|
||||
buildPuzzleCompileActionFromFormPayload,
|
||||
buildPuzzleFormPayloadFromAction,
|
||||
buildPuzzleFormPayloadFromSession,
|
||||
buildPuzzleFormPayloadFromWork,
|
||||
buildPuzzleWorkUpdatePayloadFromDraft,
|
||||
buildWoodenFishDraftActionPayload,
|
||||
isEmptyPuzzleFormOnlyDraft,
|
||||
isPuzzleFormOnlyDraft,
|
||||
} from './platformMiniGameDraftPayloadModel';
|
||||
|
||||
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleLevel(
|
||||
overrides: Partial<PuzzleDraftLevel> = {},
|
||||
): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '关卡画面描述',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: ' 星桥拼图 ',
|
||||
workDescription: ' 修复星桥机关。 ',
|
||||
levelName: '星桥机关',
|
||||
summary: '把碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: '/cover.png',
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
levels: [buildPuzzleLevel()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildPuzzleAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '种子描述',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '会话标题',
|
||||
workDescription: '会话描述',
|
||||
levelName: '星桥机关',
|
||||
summary: '会话摘要',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle',
|
||||
levels: [buildPuzzleLevel()],
|
||||
formDraft: {
|
||||
workTitle: '表单标题',
|
||||
workDescription: '表单描述',
|
||||
pictureDescription: '表单画面',
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DAnchorPack(
|
||||
overrides: Partial<Match3DAnchorPackResponse> = {},
|
||||
): Match3DAnchorPackResponse {
|
||||
return {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '海岛玩具',
|
||||
status: 'confirmed',
|
||||
},
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除次数',
|
||||
value: '12',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DSession(
|
||||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||||
): Match3DAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'match3d-session-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting',
|
||||
anchorPack: buildMatch3DAnchorPack(),
|
||||
config: null,
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '海岛抓大鹅',
|
||||
themeText: ' 海岛玩具 ',
|
||||
summary: '收集海岛玩具。',
|
||||
tags: ['海岛'],
|
||||
coverImageSrc: '/match3d-cover.png',
|
||||
referenceImageSrc: '/match3d-reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 3,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopDraft(
|
||||
overrides: Partial<NonNullable<JumpHopSessionSnapshotResponse['draft']>> = {},
|
||||
): NonNullable<JumpHopSessionSnapshotResponse['draft']> {
|
||||
return {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
workTitle: '草稿跳一跳',
|
||||
workDescription: '从草稿恢复。',
|
||||
themeTags: ['草稿'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '草稿角色',
|
||||
tilePrompt: '草稿平台',
|
||||
endMoodPrompt: '草稿终点',
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
generationStatus: 'draft',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopPayload(
|
||||
overrides: Partial<JumpHopWorkspaceCreateRequest> = {},
|
||||
): JumpHopWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '表单跳一跳',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
difficulty: 'advanced',
|
||||
stylePreset: 'neon-glass',
|
||||
characterPrompt: '表单角色',
|
||||
tilePrompt: '表单平台',
|
||||
endMoodPrompt: '表单终点',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishDraft(
|
||||
overrides: Partial<
|
||||
NonNullable<WoodenFishSessionSnapshotResponse['draft']>
|
||||
> = {},
|
||||
): NonNullable<WoodenFishSessionSnapshotResponse['draft']> {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '草稿木鱼',
|
||||
workDescription: '从草稿恢复。',
|
||||
themeTags: ['草稿'],
|
||||
hitObjectPrompt: '草稿敲击物',
|
||||
hitObjectReferenceImageSrc: '/draft-hit-ref.png',
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['草稿 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: null,
|
||||
generationStatus: 'draft',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishPayload(
|
||||
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
|
||||
): WoodenFishWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '表单木鱼',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
hitObjectPrompt: '表单敲击物',
|
||||
hitObjectReferenceImageSrc: '/form-hit-ref.png',
|
||||
hitSoundPrompt: null,
|
||||
hitSoundAsset: null,
|
||||
floatingWords: ['表单 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformMiniGameDraftPayloadModel', () => {
|
||||
test('builds puzzle form payload from work with fallback description priority', () => {
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromWork(
|
||||
buildPuzzleWork({
|
||||
workDescription: ' ',
|
||||
summary: ' 摘要描述 ',
|
||||
levelName: ' 关卡标题 ',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '摘要描述',
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '摘要描述',
|
||||
pictureDescription: '摘要描述',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle work update payload from result draft', () => {
|
||||
const draft = buildPuzzleSession().draft!;
|
||||
|
||||
expect(buildPuzzleWorkUpdatePayloadFromDraft(draft)).toEqual({
|
||||
workTitle: '会话标题',
|
||||
workDescription: '会话描述',
|
||||
levelName: '星桥机关',
|
||||
summary: '会话摘要',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
levels: [buildPuzzleLevel()],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPuzzleWorkUpdatePayloadFromDraft({
|
||||
...draft,
|
||||
levels: undefined,
|
||||
}).levels,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('builds jump hop draft action payload from payload or draft', () => {
|
||||
expect(
|
||||
buildJumpHopDraftActionPayload('compile-draft', {
|
||||
payload: buildJumpHopPayload(),
|
||||
draft: buildJumpHopDraft(),
|
||||
}),
|
||||
).toEqual({
|
||||
actionType: 'compile-draft',
|
||||
workTitle: '表单跳一跳',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
difficulty: 'advanced',
|
||||
stylePreset: 'neon-glass',
|
||||
characterPrompt: '表单角色',
|
||||
tilePrompt: '表单平台',
|
||||
endMoodPrompt: '表单终点',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildJumpHopDraftActionPayload('regenerate-tiles', {
|
||||
draft: buildJumpHopDraft(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
actionType: 'regenerate-tiles',
|
||||
workTitle: '草稿跳一跳',
|
||||
tilePrompt: '草稿平台',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish draft action payload from payload or draft', () => {
|
||||
expect(
|
||||
buildWoodenFishDraftActionPayload('compile-draft', {
|
||||
payload: buildWoodenFishPayload(),
|
||||
draft: buildWoodenFishDraft(),
|
||||
}),
|
||||
).toEqual({
|
||||
actionType: 'compile-draft',
|
||||
workTitle: '表单木鱼',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
hitObjectPrompt: '表单敲击物',
|
||||
hitObjectReferenceImageSrc: '/form-hit-ref.png',
|
||||
hitSoundAsset: null,
|
||||
floatingWords: ['表单 +1'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishDraftActionPayload('regenerate-hit-object', {
|
||||
draft: buildWoodenFishDraft(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
actionType: 'regenerate-hit-object',
|
||||
workTitle: '草稿木鱼',
|
||||
hitObjectPrompt: '草稿敲击物',
|
||||
floatingWords: ['草稿 +1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle form payload from session form draft and fallbacks', () => {
|
||||
expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({
|
||||
seedText: '表单画面',
|
||||
workTitle: '表单标题',
|
||||
workDescription: '表单描述',
|
||||
pictureDescription: '表单画面',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: {
|
||||
...buildPuzzleSession().draft!,
|
||||
formDraft: null,
|
||||
levels: [buildPuzzleLevel({ pictureDescription: '关卡优先' })],
|
||||
},
|
||||
}),
|
||||
).pictureDescription,
|
||||
).toBe('关卡优先');
|
||||
});
|
||||
|
||||
test('resolves puzzle form-only draft state for empty and filled forms', () => {
|
||||
const baseDraft = buildPuzzleSession().draft!;
|
||||
const emptySession = buildPuzzleSession({
|
||||
seedText: ' ',
|
||||
draft: {
|
||||
...baseDraft,
|
||||
formDraft: {
|
||||
workTitle: ' ',
|
||||
workDescription: ' ',
|
||||
pictureDescription: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(isPuzzleFormOnlyDraft(emptySession)).toBe(true);
|
||||
expect(isEmptyPuzzleFormOnlyDraft(emptySession)).toBe(true);
|
||||
expect(isPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(true);
|
||||
expect(isEmptyPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(false);
|
||||
expect(
|
||||
isPuzzleFormOnlyDraft(buildPuzzleSession({ stage: 'ready_to_publish' })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('builds puzzle compile action and restores form payload from action', () => {
|
||||
const payload: CreatePuzzleAgentSessionRequest = {
|
||||
seedText: '种子',
|
||||
workTitle: ' 标题 ',
|
||||
workDescription: '',
|
||||
pictureDescription: ' 画面 ',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
};
|
||||
const action = buildPuzzleCompileActionFromFormPayload(payload);
|
||||
|
||||
expect(action).toEqual({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: '画面',
|
||||
workTitle: '标题',
|
||||
workDescription: '画面',
|
||||
pictureDescription: '画面',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
candidateCount: 1,
|
||||
});
|
||||
expect(buildPuzzleFormPayloadFromAction(action)).toEqual({
|
||||
seedText: '画面',
|
||||
workTitle: '标题',
|
||||
workDescription: '画面',
|
||||
pictureDescription: '画面',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
});
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromAction({
|
||||
action: 'publish_puzzle_work',
|
||||
} as PuzzleAgentActionRequest),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds pending puzzle metadata from non-empty payload fields', () => {
|
||||
expect(
|
||||
buildPendingPuzzleDraftMetadata({
|
||||
workTitle: ' 标题 ',
|
||||
workDescription: ' ',
|
||||
pictureDescription: ' 画面 ',
|
||||
seedText: '种子',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '标题',
|
||||
summary: '画面',
|
||||
});
|
||||
expect(buildPendingPuzzleDraftMetadata(null)).toEqual({});
|
||||
});
|
||||
|
||||
test('builds match3d form payload from session config, draft and anchors', () => {
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromSession(
|
||||
buildMatch3DSession({
|
||||
config: {
|
||||
themeText: ' 配置主题 ',
|
||||
referenceImageSrc: '/config-ref.png',
|
||||
clearCount: 9,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'style-1',
|
||||
assetStyleLabel: '手办',
|
||||
assetStylePrompt: '软陶手办',
|
||||
generateClickSound: true,
|
||||
},
|
||||
draft: {
|
||||
profileId: 'profile-1',
|
||||
gameName: '草稿标题',
|
||||
themeText: '草稿主题',
|
||||
tags: [],
|
||||
referenceImageSrc: '/draft-ref.png',
|
||||
clearCount: 6,
|
||||
difficulty: 2,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '配置主题',
|
||||
themeText: '配置主题',
|
||||
referenceImageSrc: '/config-ref.png',
|
||||
clearCount: 9,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'style-1',
|
||||
assetStyleLabel: '手办',
|
||||
assetStylePrompt: '软陶手办',
|
||||
generateClickSound: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromSession(
|
||||
buildMatch3DSession({
|
||||
anchorPack: buildMatch3DAnchorPack({
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除次数',
|
||||
value: 'not-number',
|
||||
status: 'confirmed',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
seedText: '海岛玩具',
|
||||
clearCount: undefined,
|
||||
difficulty: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('builds match3d form payload from work and pending metadata', () => {
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromWork(
|
||||
buildMatch3DWork({
|
||||
themeText: ' ',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '海岛抓大鹅',
|
||||
themeText: '海岛抓大鹅',
|
||||
referenceImageSrc: '/match3d-reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPendingMatch3DDraftMetadata({
|
||||
themeText: ' ',
|
||||
seedText: ' 海岛抓大鹅 ',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '海岛抓大鹅',
|
||||
summary: '海岛抓大鹅',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
JumpHopActionRequest,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleDraftLevel,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
WoodenFishActionRequest,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
|
||||
export type PuzzleWorkUpdatePayload = {
|
||||
workTitle?: string;
|
||||
workDescription?: string;
|
||||
levelName: string;
|
||||
summary: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc?: string | null;
|
||||
coverAssetId?: string | null;
|
||||
levels: PuzzleDraftLevel[];
|
||||
};
|
||||
|
||||
export function buildPuzzleFormPayloadFromWork(
|
||||
item: PuzzleWorkSummary,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const pictureDescription =
|
||||
item.workDescription?.trim() ||
|
||||
item.summary?.trim() ||
|
||||
item.levels?.[0]?.pictureDescription?.trim() ||
|
||||
item.levelName?.trim() ||
|
||||
item.workTitle?.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined,
|
||||
workDescription: item.workDescription?.trim() || item.summary?.trim(),
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleWorkUpdatePayloadFromDraft(
|
||||
draft: PuzzleResultDraft,
|
||||
): PuzzleWorkUpdatePayload {
|
||||
return {
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
levels: draft.levels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopDraftActionPayload(
|
||||
actionType: 'compile-draft' | 'regenerate-character' | 'regenerate-tiles',
|
||||
input: {
|
||||
payload?: JumpHopWorkspaceCreateRequest | null;
|
||||
draft?: JumpHopSessionSnapshotResponse['draft'] | null;
|
||||
},
|
||||
): JumpHopActionRequest {
|
||||
const { payload, draft } = input;
|
||||
return {
|
||||
actionType,
|
||||
workTitle: payload?.workTitle ?? draft?.workTitle,
|
||||
workDescription: payload?.workDescription ?? draft?.workDescription,
|
||||
themeTags: payload?.themeTags ?? draft?.themeTags,
|
||||
difficulty: payload?.difficulty ?? draft?.difficulty,
|
||||
stylePreset: payload?.stylePreset ?? draft?.stylePreset,
|
||||
characterPrompt: payload?.characterPrompt ?? draft?.characterPrompt,
|
||||
tilePrompt: payload?.tilePrompt ?? draft?.tilePrompt,
|
||||
endMoodPrompt: payload?.endMoodPrompt ?? draft?.endMoodPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishDraftActionPayload(
|
||||
actionType: 'compile-draft' | 'regenerate-hit-object',
|
||||
input: {
|
||||
payload?: WoodenFishWorkspaceCreateRequest | null;
|
||||
draft?: WoodenFishSessionSnapshotResponse['draft'] | null;
|
||||
},
|
||||
): WoodenFishActionRequest {
|
||||
const { payload, draft } = input;
|
||||
return {
|
||||
actionType,
|
||||
workTitle: payload?.workTitle ?? draft?.workTitle,
|
||||
workDescription: payload?.workDescription ?? draft?.workDescription,
|
||||
themeTags: payload?.themeTags ?? draft?.themeTags,
|
||||
hitObjectPrompt: payload?.hitObjectPrompt ?? draft?.hitObjectPrompt,
|
||||
hitObjectReferenceImageSrc:
|
||||
payload?.hitObjectReferenceImageSrc ??
|
||||
draft?.hitObjectReferenceImageSrc,
|
||||
hitSoundAsset: payload?.hitSoundAsset ?? draft?.hitSoundAsset,
|
||||
floatingWords: payload?.floatingWords ?? draft?.floatingWords,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalFiniteNumber(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
const normalizedValue = value?.trim();
|
||||
if (!normalizedValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsedValue = Number(normalizedValue);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : undefined;
|
||||
}
|
||||
|
||||
export function buildMatch3DFormPayloadFromSession(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
): CreateMatch3DSessionRequest {
|
||||
const themeText =
|
||||
session.config?.themeText?.trim() ||
|
||||
session.draft?.themeText?.trim() ||
|
||||
session.anchorPack.theme.value.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: themeText,
|
||||
themeText,
|
||||
referenceImageSrc:
|
||||
session.config?.referenceImageSrc ??
|
||||
session.draft?.referenceImageSrc ??
|
||||
null,
|
||||
clearCount:
|
||||
session.config?.clearCount ??
|
||||
session.draft?.clearCount ??
|
||||
parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ??
|
||||
undefined,
|
||||
difficulty:
|
||||
session.config?.difficulty ??
|
||||
session.draft?.difficulty ??
|
||||
parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ??
|
||||
undefined,
|
||||
assetStyleId: session.config?.assetStyleId ?? null,
|
||||
assetStyleLabel: session.config?.assetStyleLabel ?? null,
|
||||
assetStylePrompt: session.config?.assetStylePrompt ?? null,
|
||||
generateClickSound: session.config?.generateClickSound,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DFormPayloadFromWork(
|
||||
item: Match3DWorkSummary,
|
||||
): CreateMatch3DSessionRequest {
|
||||
const themeText = item.themeText?.trim() || item.gameName?.trim() || '';
|
||||
return {
|
||||
seedText: themeText,
|
||||
themeText,
|
||||
referenceImageSrc: item.referenceImageSrc ?? null,
|
||||
clearCount: item.clearCount,
|
||||
difficulty: item.difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleCompileActionFromFormPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null,
|
||||
): PuzzleAgentActionRequest {
|
||||
const pictureDescription =
|
||||
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
|
||||
const workTitle = payload?.workTitle?.trim();
|
||||
const workDescription = payload?.workDescription?.trim() || pictureDescription;
|
||||
|
||||
return {
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
aiRedraw: payload?.aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleFormPayloadFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const formDraft = session.draft?.formDraft;
|
||||
const pictureDescription =
|
||||
formDraft?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
||||
session.anchorPack.visualSubject.value.trim() ||
|
||||
session.seedText?.trim() ||
|
||||
'';
|
||||
const workTitle =
|
||||
formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim();
|
||||
const workDescription =
|
||||
formDraft?.workDescription?.trim() ||
|
||||
session.draft?.workDescription?.trim() ||
|
||||
session.draft?.summary?.trim() ||
|
||||
pictureDescription;
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPuzzleFormOnlyDraft(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
return Boolean(
|
||||
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmptyPuzzleFormOnlyDraft(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
if (!isPuzzleFormOnlyDraft(session)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const formDraft = session?.draft?.formDraft;
|
||||
return !(
|
||||
session?.seedText?.trim() ||
|
||||
formDraft?.workTitle?.trim() ||
|
||||
formDraft?.workDescription?.trim() ||
|
||||
formDraft?.pictureDescription?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPendingPuzzleDraftMetadata(
|
||||
payload: CreatePuzzleAgentSessionRequest | null | undefined,
|
||||
) {
|
||||
const title = payload?.workTitle?.trim();
|
||||
const summary =
|
||||
payload?.workDescription?.trim() ||
|
||||
payload?.pictureDescription?.trim() ||
|
||||
payload?.seedText?.trim();
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPendingMatch3DDraftMetadata(
|
||||
payload: CreateMatch3DSessionRequest | null | undefined,
|
||||
) {
|
||||
const themeText = payload?.themeText?.trim() || payload?.seedText?.trim();
|
||||
return {
|
||||
...(themeText ? { title: themeText, summary: themeText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleFormPayloadFromAction(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
): CreatePuzzleAgentSessionRequest | null {
|
||||
if (
|
||||
payload.action !== 'compile_puzzle_draft' &&
|
||||
payload.action !== 'save_puzzle_form_draft'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workTitle = payload.workTitle?.trim() ?? '';
|
||||
const workDescription = payload.workDescription?.trim() ?? '';
|
||||
const pictureDescription =
|
||||
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.referenceImageSrc ?? null)
|
||||
: (payload.referenceImageSrc ?? null),
|
||||
referenceImageSrcs: payload.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
|
||||
imageModel:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.imageModel ?? null)
|
||||
: (payload.imageModel ?? null),
|
||||
aiRedraw:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.aiRedraw ?? true)
|
||||
: (payload.aiRedraw ?? true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
SquareHoleResultDraft,
|
||||
SquareHoleSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type {
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelWorkDetail,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
WoodenFishAudioAsset,
|
||||
WoodenFishImageAsset,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildJumpHopPendingSession,
|
||||
buildPuzzleRuntimeWorkFromSession,
|
||||
buildSquareHoleProfileFromSession,
|
||||
buildVisualNovelSessionFromWorkDetail,
|
||||
buildWoodenFishGeneratingWorkSummary,
|
||||
buildWoodenFishPendingSession,
|
||||
buildWoodenFishSessionFromWorkDetail,
|
||||
} from './platformMiniGameSessionMappingModel';
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleDraft(
|
||||
overrides: Partial<PuzzleResultDraft> = {},
|
||||
): PuzzleResultDraft {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把星桥碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
coverAssetId: 'asset-cover',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/puzzle-level-cover.png',
|
||||
coverAssetId: 'asset-level-cover',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = buildPuzzleDraft();
|
||||
return {
|
||||
sessionId: 'puzzle-session-12345678',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: draft.anchorPack,
|
||||
draft,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: {
|
||||
draft,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
publishReady: true,
|
||||
},
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopSummary(
|
||||
overrides: Partial<JumpHopWorkSummaryResponse> = {},
|
||||
): JumpHopWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: ' jump-hop-session-1 ',
|
||||
workTitle: '云阶跳跃',
|
||||
workDescription: '越过云阶。',
|
||||
themeTags: ['云阶'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: '/jump-hop-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleDraft(
|
||||
overrides: Partial<SquareHoleResultDraft> = {},
|
||||
): SquareHoleResultDraft {
|
||||
return {
|
||||
profileId: 'square-hole-profile-1',
|
||||
gameName: '星桥方洞',
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
summary: '把星桥机关里的形状送入正确孔洞。',
|
||||
tags: ['星桥', '机关'],
|
||||
coverImageSrc: '/square-hole-cover.png',
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
backgroundImageSrc: '/square-hole-background.png',
|
||||
shapeOptions: [
|
||||
{
|
||||
optionId: 'shape-1',
|
||||
shapeKind: 'star',
|
||||
label: '星形',
|
||||
targetHoleId: 'hole-1',
|
||||
imagePrompt: '星形积木',
|
||||
imageSrc: '/shape-star.png',
|
||||
},
|
||||
],
|
||||
holeOptions: [
|
||||
{
|
||||
holeId: 'hole-1',
|
||||
holeKind: 'star-hole',
|
||||
label: '星形洞',
|
||||
imagePrompt: '星形洞口',
|
||||
imageSrc: '/hole-star.png',
|
||||
},
|
||||
],
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleSession(
|
||||
overrides: Partial<SquareHoleSessionSnapshot> = {},
|
||||
): SquareHoleSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'square-hole-session-1',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed',
|
||||
},
|
||||
twistRule: {
|
||||
key: 'twistRule',
|
||||
label: '扭转规则',
|
||||
value: '只允许相同颜色形状入洞',
|
||||
status: 'confirmed',
|
||||
},
|
||||
shapeCount: {
|
||||
key: 'shapeCount',
|
||||
label: '形状数量',
|
||||
value: '6',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
coverImageSrc: null,
|
||||
backgroundImageSrc: null,
|
||||
},
|
||||
draft: buildSquareHoleDraft(),
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-06-01T12:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelDraft(
|
||||
overrides: Partial<VisualNovelResultDraft> = {},
|
||||
): VisualNovelResultDraft {
|
||||
return {
|
||||
profileId: 'visual-novel-profile-1',
|
||||
workTitle: '雪线电台',
|
||||
workDescription: '旧电台牵出雪夜列车谜案。',
|
||||
workTags: ['雪夜', '电台'],
|
||||
coverImageSrc: '/visual-novel-cover.png',
|
||||
sourceMode: 'idea',
|
||||
sourceAssetIds: ['asset-source-1'],
|
||||
world: {
|
||||
title: '北境终点线',
|
||||
summary: '边境小城与旧电台。',
|
||||
background: '十二年前的雪崩留下夜间广播。',
|
||||
premise: '玩家需要在日出前找出列车停摆的原因。',
|
||||
literaryStyle: '克制冷光感。',
|
||||
playerRole: '临时广播员',
|
||||
defaultTone: '安静紧张',
|
||||
},
|
||||
characters: [
|
||||
{
|
||||
characterId: 'vn-char-1',
|
||||
name: '林遥',
|
||||
gender: '女',
|
||||
role: 'main',
|
||||
appearance: '灰色长外套。',
|
||||
personality: '谨慎敏锐。',
|
||||
tone: '短句多。',
|
||||
background: '旧电台夜班实习生。',
|
||||
relationshipToPlayer: '临时搭档',
|
||||
imageAssets: [],
|
||||
defaultExpression: 'calm',
|
||||
isPlayerVisible: false,
|
||||
},
|
||||
],
|
||||
scenes: [
|
||||
{
|
||||
sceneId: 'vn-scene-1',
|
||||
name: '风雪站台',
|
||||
description: '站灯忽明忽暗。',
|
||||
backgroundImageSrc: null,
|
||||
musicSrc: null,
|
||||
ambientSoundSrc: null,
|
||||
availability: 'opening',
|
||||
phaseIds: ['vn-phase-1'],
|
||||
},
|
||||
],
|
||||
storyPhases: [
|
||||
{
|
||||
phaseId: 'vn-phase-1',
|
||||
title: '重启站台',
|
||||
goal: '确认列车为何停在废弃站台。',
|
||||
summary: '玩家抵达风雪站台。',
|
||||
entryCondition: '开场进入',
|
||||
exitCondition: '找到车长日志',
|
||||
sceneIds: ['vn-scene-1'],
|
||||
characterIds: ['vn-char-1'],
|
||||
suggestedChoices: ['检查广播柜'],
|
||||
},
|
||||
],
|
||||
opening: {
|
||||
sceneId: 'vn-scene-1',
|
||||
narration: '雪落得很慢。',
|
||||
speakerCharacterId: 'vn-char-1',
|
||||
firstDialogue: '你听见了吗?',
|
||||
initialChoices: [
|
||||
{
|
||||
choiceId: 'vn-choice-1',
|
||||
text: '靠近广播柜。',
|
||||
actionHint: 'inspect_radio',
|
||||
},
|
||||
],
|
||||
},
|
||||
runtimeConfig: {
|
||||
textModeEnabled: true,
|
||||
defaultTextMode: false,
|
||||
maxHistoryEntries: 80,
|
||||
maxAssistantStepCountPerTurn: 8,
|
||||
allowFreeTextAction: true,
|
||||
allowHistoryRegeneration: true,
|
||||
attributePanelMode: 'template_config',
|
||||
saveArchiveEnabled: true,
|
||||
},
|
||||
publishReady: true,
|
||||
validationIssues: [],
|
||||
updatedAt: '2026-06-01T13:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWorkDetail(
|
||||
overrides: Partial<VisualNovelWorkDetail> = {},
|
||||
): VisualNovelWorkDetail {
|
||||
const draft = buildVisualNovelDraft();
|
||||
return {
|
||||
workId: 'visual-novel-work-1',
|
||||
summary: {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-1',
|
||||
ownerUserId: 'user-visual-novel-1',
|
||||
title: draft.workTitle,
|
||||
description: draft.workDescription,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
tags: draft.workTags,
|
||||
publishStatus: 'draft',
|
||||
publishReady: draft.publishReady,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T13:30:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
sourceSessionId: ' visual-novel-session-1 ',
|
||||
authorDisplayName: '视觉小说作者',
|
||||
sourceAssetIds: draft.sourceAssetIds,
|
||||
draft,
|
||||
createdAt: '2026-06-01T12:50:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const woodenFishImageAsset: WoodenFishImageAsset = {
|
||||
assetId: 'asset-hit',
|
||||
imageSrc: '/hit.png',
|
||||
imageObjectKey: 'hit.png',
|
||||
assetObjectId: 'asset-object-hit',
|
||||
generationProvider: 'test',
|
||||
prompt: '木鱼',
|
||||
width: 512,
|
||||
height: 512,
|
||||
};
|
||||
|
||||
const woodenFishAudioAsset: WoodenFishAudioAsset = {
|
||||
assetId: 'asset-sound',
|
||||
audioSrc: '/hit.mp3',
|
||||
audioObjectKey: 'hit.mp3',
|
||||
assetObjectId: 'asset-object-sound',
|
||||
source: 'test',
|
||||
};
|
||||
|
||||
function buildWoodenFishSummary(
|
||||
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: ' wooden-fish-session-1 ',
|
||||
workTitle: '星灯木鱼',
|
||||
workDescription: '敲亮星灯。',
|
||||
themeTags: ['星灯'],
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishWorkProfile(
|
||||
overrides: Partial<WoodenFishWorkProfileResponse> = {},
|
||||
): WoodenFishWorkProfileResponse {
|
||||
const summary = buildWoodenFishSummary();
|
||||
const draft = {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: summary.profileId,
|
||||
workTitle: summary.workTitle,
|
||||
workDescription: summary.workDescription,
|
||||
themeTags: summary.themeTags,
|
||||
hitObjectPrompt: '星灯',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: woodenFishImageAsset,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: woodenFishAudioAsset,
|
||||
coverImageSrc: summary.coverImageSrc,
|
||||
generationStatus: summary.generationStatus,
|
||||
};
|
||||
return {
|
||||
summary,
|
||||
draft,
|
||||
hitObjectAsset: woodenFishImageAsset,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: woodenFishAudioAsset,
|
||||
floatingWords: ['功德 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishSession(
|
||||
overrides: Partial<WoodenFishSessionSnapshotResponse> = {},
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const summary = buildWoodenFishSummary();
|
||||
return {
|
||||
sessionId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: buildWoodenFishWorkProfile({ summary }).draft,
|
||||
createdAt: '2026-06-01T11:59:00.000Z',
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishCreatePayload(
|
||||
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
|
||||
): WoodenFishWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '表单星灯木鱼',
|
||||
workDescription: '表单里敲亮星灯。',
|
||||
themeTags: ['表单星灯'],
|
||||
hitObjectPrompt: '星灯',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformMiniGameSessionMappingModel', () => {
|
||||
test('builds a draft puzzle runtime work from a session', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), {
|
||||
userId: 'user-1',
|
||||
displayName: '玩家一号',
|
||||
}),
|
||||
).toMatchObject({
|
||||
workId: 'puzzle-work-12345678',
|
||||
profileId: 'puzzle-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-12345678',
|
||||
authorDisplayName: '玩家一号',
|
||||
workTitle: '星桥拼图',
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('prefers published puzzle profile id when present', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
publishedProfileId: 'published-puzzle-profile',
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
profileId: 'published-puzzle-profile',
|
||||
workId: 'puzzle-work-12345678',
|
||||
ownerUserId: 'current-user',
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for puzzle runtime work without draft or cover', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: null,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: buildPuzzleDraft({ coverImageSrc: ' ' }),
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds jump hop pending session from work summary', () => {
|
||||
expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({
|
||||
sessionId: 'jump-hop-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
workTitle: '云阶跳跃',
|
||||
workDescription: '越过云阶。',
|
||||
themeTags: ['云阶'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: '/jump-hop-cover.png',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
createdAt: '2026-06-01T11:00:00.000Z',
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds square hole draft profile from session', () => {
|
||||
expect(buildSquareHoleProfileFromSession(buildSquareHoleSession())).toEqual({
|
||||
workId: 'square-hole-profile-1',
|
||||
profileId: 'square-hole-profile-1',
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: 'square-hole-session-1',
|
||||
gameName: '星桥方洞',
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
summary: '把星桥机关里的形状送入正确孔洞。',
|
||||
tags: ['星桥', '机关'],
|
||||
coverImageSrc: '/square-hole-cover.png',
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
backgroundImageSrc: '/square-hole-background.png',
|
||||
shapeOptions: buildSquareHoleDraft().shapeOptions,
|
||||
holeOptions: buildSquareHoleDraft().holeOptions,
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:30:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for square hole profile without session draft or profile id', () => {
|
||||
expect(buildSquareHoleProfileFromSession(null)).toBeNull();
|
||||
expect(
|
||||
buildSquareHoleProfileFromSession(
|
||||
buildSquareHoleSession({
|
||||
draft: null,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildSquareHoleProfileFromSession(
|
||||
buildSquareHoleSession({
|
||||
draft: buildSquareHoleDraft({ profileId: '' }),
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds visual novel recovered session from work detail', () => {
|
||||
const work = buildVisualNovelWorkDetail();
|
||||
|
||||
expect(buildVisualNovelSessionFromWorkDetail(work)).toEqual({
|
||||
sessionId: 'visual-novel-session-1',
|
||||
ownerUserId: 'user-visual-novel-1',
|
||||
sourceMode: 'idea',
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
draft: work.draft,
|
||||
pendingAction: null,
|
||||
createdAt: '2026-06-01T12:50:00.000Z',
|
||||
updatedAt: '2026-06-01T13:30:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back visual novel recovered session id to work id', () => {
|
||||
expect(
|
||||
buildVisualNovelSessionFromWorkDetail(
|
||||
buildVisualNovelWorkDetail({
|
||||
sourceSessionId: ' ',
|
||||
workId: 'visual-novel-work-fallback',
|
||||
}),
|
||||
).sessionId,
|
||||
).toBe('visual-novel-work-fallback');
|
||||
});
|
||||
|
||||
test('builds wooden fish pending session from work summary', () => {
|
||||
expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({
|
||||
sessionId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '星灯木鱼',
|
||||
workDescription: '敲亮星灯。',
|
||||
themeTags: ['星灯'],
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
createdAt: '2026-06-01T12:00:00.000Z',
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish generating work summary from session and payload', () => {
|
||||
expect(
|
||||
buildWoodenFishGeneratingWorkSummary(
|
||||
buildWoodenFishSession(),
|
||||
buildWoodenFishCreatePayload(),
|
||||
),
|
||||
).toEqual({
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-session-1',
|
||||
profileId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-1',
|
||||
workTitle: '表单星灯木鱼',
|
||||
workDescription: '表单里敲亮星灯。',
|
||||
themeTags: ['表单星灯'],
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishGeneratingWorkSummary(
|
||||
buildWoodenFishSession({
|
||||
draft: null,
|
||||
createdAt: '2026-06-01T11:59:00.000Z',
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toMatchObject({
|
||||
workTitle: '敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: ['敲木鱼'],
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish recovered session with summary, fallback and profile id priority', () => {
|
||||
expect(
|
||||
buildWoodenFishSessionFromWorkDetail(
|
||||
buildWoodenFishWorkProfile({
|
||||
summary: buildWoodenFishSummary({
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
}),
|
||||
buildWoodenFishSummary({
|
||||
sourceSessionId: ' fallback-session ',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
sessionId: 'fallback-session',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishSessionFromWorkDetail(
|
||||
buildWoodenFishWorkProfile({
|
||||
summary: buildWoodenFishSummary({
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
).sessionId,
|
||||
).toBe('wooden-fish-profile-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { JumpHopSessionSnapshotResponse, JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type {
|
||||
VisualNovelAgentSessionSnapshot,
|
||||
VisualNovelWorkDetail,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { normalizeCreationUrlValue } from './platformCreationUrlStateModel';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
export type PlatformMiniGameSessionOwner = {
|
||||
userId?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
export function buildPuzzleRuntimeWorkFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
owner: PlatformMiniGameSessionOwner,
|
||||
): PuzzleWorkSummary | null {
|
||||
const draft = session.draft;
|
||||
const profileId =
|
||||
session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId);
|
||||
if (!draft || !profileId || !draft.coverImageSrc?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId,
|
||||
profileId,
|
||||
ownerUserId: owner.userId ?? 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
authorDisplayName: owner.displayName ?? '玩家',
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: session.updatedAt,
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: Boolean(session.resultPreview?.publishReady),
|
||||
levels: draft.levels,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSquareHoleProfileFromSession(
|
||||
session: SquareHoleSessionSnapshot | null,
|
||||
): SquareHoleWorkProfile | null {
|
||||
const draft = session?.draft;
|
||||
if (!session || !draft?.profileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
return {
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
gameName: draft.gameName,
|
||||
themeText: draft.themeText,
|
||||
twistRule: draft.twistRule,
|
||||
summary: draft.summary,
|
||||
tags: draft.tags,
|
||||
coverImageSrc: draft.coverImageSrc ?? null,
|
||||
backgroundPrompt: draft.backgroundPrompt,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
shapeOptions: draft.shapeOptions,
|
||||
holeOptions: draft.holeOptions,
|
||||
shapeCount: draft.shapeCount,
|
||||
difficulty: draft.difficulty,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVisualNovelSessionFromWorkDetail(
|
||||
work: VisualNovelWorkDetail,
|
||||
): VisualNovelAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: normalizeCreationUrlValue(work.sourceSessionId) ?? work.workId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
sourceMode: work.draft.sourceMode,
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
draft: work.draft,
|
||||
pendingAction: null,
|
||||
createdAt: work.createdAt,
|
||||
updatedAt: work.summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopPendingSession(
|
||||
item: JumpHopWorkSummaryResponse,
|
||||
): JumpHopSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: item.ownerUserId,
|
||||
status: item.generationStatus,
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: item.profileId,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themeTags: item.themeTags,
|
||||
difficulty: item.difficulty,
|
||||
stylePreset: item.stylePreset,
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: item.coverImageSrc,
|
||||
generationStatus: item.generationStatus,
|
||||
},
|
||||
createdAt: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishSessionFromWorkDetail(
|
||||
work: WoodenFishWorkProfileResponse,
|
||||
fallbackItem?: WoodenFishWorkSummaryResponse | null,
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
|
||||
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
|
||||
work.summary.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
status: work.summary.generationStatus,
|
||||
draft: work.draft,
|
||||
createdAt: work.summary.updatedAt,
|
||||
updatedAt: work.summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishGeneratingWorkSummary(
|
||||
session: WoodenFishSessionSnapshotResponse,
|
||||
payload?: WoodenFishWorkspaceCreateRequest | null,
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
const updatedAt = session.updatedAt ?? session.createdAt;
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: session.sessionId,
|
||||
profileId: session.sessionId,
|
||||
ownerUserId: session.ownerUserId,
|
||||
sourceSessionId: session.sessionId,
|
||||
workTitle: payload?.workTitle ?? session.draft?.workTitle ?? '敲木鱼',
|
||||
workDescription:
|
||||
payload?.workDescription ?? session.draft?.workDescription ?? '',
|
||||
themeTags: payload?.themeTags ?? session.draft?.themeTags ?? ['敲木鱼'],
|
||||
coverImageSrc: session.draft?.coverImageSrc ?? null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt,
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishPendingSession(
|
||||
item: WoodenFishWorkSummaryResponse,
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: item.ownerUserId,
|
||||
status: item.generationStatus,
|
||||
draft: {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: item.profileId,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themeTags: item.themeTags,
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
generationStatus: item.generationStatus,
|
||||
},
|
||||
createdAt: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||
|
||||
function buildPlayedWork(
|
||||
overrides: Partial<ProfilePlayedWorkSummary> = {},
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldTitle: '潮雾列岛',
|
||||
worldSubtitle: '旧灯塔与失控航路',
|
||||
firstPlayedAt: '2026-04-18T12:00:00.000Z',
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
lastObservedPlayTimeMs: 12_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformPlayedWorkOpenModel', () => {
|
||||
test('opens puzzle played works with profile tab context', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'PUZZLE',
|
||||
profileId: 'puzzle-profile-1',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-puzzle',
|
||||
profileId: 'puzzle-profile-1',
|
||||
tab: 'profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to worldKey prefixes when profile id is absent', () => {
|
||||
const cases = [
|
||||
['puzzle:profile-1', 'open-puzzle', 'profile-1'],
|
||||
['match3d:profile-2', 'open-match3d', 'profile-2'],
|
||||
['square-hole:profile-3', 'open-square-hole', 'profile-3'],
|
||||
['jump-hop:profile-4', 'open-jump-hop', 'profile-4'],
|
||||
['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'],
|
||||
] as const;
|
||||
|
||||
for (const [worldKey, type, profileId] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey,
|
||||
profileId: null,
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ type, profileId });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps explicit profile id ahead of worldKey fallback', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'jump-hop:key-profile',
|
||||
profileId: 'explicit-profile',
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: 'open-jump-hop',
|
||||
profileId: 'explicit-profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('supports played work type aliases for mini-games', () => {
|
||||
const cases = [
|
||||
['match_3d', 'open-match3d'],
|
||||
['square_hole', 'open-square-hole'],
|
||||
['jump_hop', 'open-jump-hop'],
|
||||
['wooden_fish', 'open-wooden-fish'],
|
||||
] as const;
|
||||
|
||||
for (const [worldType, type] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType,
|
||||
profileId: `${worldType}-profile`,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type,
|
||||
profileId: `${worldType}-profile`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('returns noop when a mini-game target is empty', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'puzzle:key-profile',
|
||||
profileId: '',
|
||||
worldType: 'puzzle',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds big fish intent and fallback work for gallery misses', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'big-fish:big-fish-session-1',
|
||||
ownerUserId: null,
|
||||
profileId: null,
|
||||
worldType: 'big_fish',
|
||||
worldTitle: '机械深海',
|
||||
worldSubtitle: '',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-big-fish',
|
||||
sessionId: 'big-fish-session-1',
|
||||
fallbackWork: {
|
||||
workId: 'big-fish:big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: '',
|
||||
authorDisplayName: '玩家',
|
||||
title: '机械深海',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('opens unknown played work types as RPG detail when identity is complete', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'CUSTOM',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-rpg',
|
||||
detail: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'custom:world-1',
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-18T12:00:00.000Z',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
authorDisplayName: '旧灯塔与失控航路',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('returns noop for RPG fallback when owner or profile is missing', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
ownerUserId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: '',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
});
|
||||
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
ProfilePlayedWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type PlatformPlayedWorkOpenIntent =
|
||||
| {
|
||||
type: 'noop';
|
||||
reason: 'missing-target';
|
||||
}
|
||||
| {
|
||||
type: 'open-puzzle';
|
||||
profileId: string;
|
||||
tab: 'profile';
|
||||
}
|
||||
| {
|
||||
type: 'open-match3d';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-square-hole';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-jump-hop';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-wooden-fish';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-big-fish';
|
||||
sessionId: string;
|
||||
fallbackWork: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
type: 'open-rpg';
|
||||
detail: CustomWorldGalleryCard;
|
||||
};
|
||||
|
||||
function normalizePlayedWorkWorldType(worldType: string | null) {
|
||||
return (worldType ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function resolvePlayedWorkTargetId(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
worldKeyPrefix: string,
|
||||
) {
|
||||
const prefixedWorldKey = `${worldKeyPrefix}:`;
|
||||
return (
|
||||
work.profileId ??
|
||||
(work.worldKey.startsWith(prefixedWorldKey)
|
||||
? work.worldKey.slice(prefixedWorldKey.length)
|
||||
: work.worldKey)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlayedWorkProfileIntent<TIntent extends PlatformPlayedWorkOpenIntent>(
|
||||
profileId: string,
|
||||
intent: (profileId: string) => TIntent,
|
||||
) {
|
||||
return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent {
|
||||
return {
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedBigFishFallbackWork(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
sessionId: string,
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
authorDisplayName: work.worldSubtitle || '玩家',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: work.lastPlayedAt,
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedRpgDetail(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
profileId: string,
|
||||
ownerUserId: string,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: work.firstPlayedAt,
|
||||
updatedAt: work.lastPlayedAt,
|
||||
authorDisplayName: work.worldSubtitle,
|
||||
worldName: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */
|
||||
export function resolvePlatformPlayedWorkOpenIntent(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
): PlatformPlayedWorkOpenIntent {
|
||||
const worldType = normalizePlayedWorkWorldType(work.worldType);
|
||||
|
||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'puzzle');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-puzzle',
|
||||
profileId: resolvedProfileId,
|
||||
tab: 'profile',
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'match3d' ||
|
||||
worldType === 'match_3d' ||
|
||||
work.worldKey.startsWith('match3d:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'match3d');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-match3d',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'square-hole' ||
|
||||
worldType === 'square_hole' ||
|
||||
work.worldKey.startsWith('square-hole:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'square-hole');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-square-hole',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'jump-hop' ||
|
||||
worldType === 'jump_hop' ||
|
||||
work.worldKey.startsWith('jump-hop:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'jump-hop');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-jump-hop',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'wooden-fish' ||
|
||||
worldType === 'wooden_fish' ||
|
||||
work.worldKey.startsWith('wooden-fish:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-wooden-fish',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
work.worldKey.startsWith('big-fish:')
|
||||
) {
|
||||
const sessionId = resolvePlayedWorkTargetId(work, 'big-fish');
|
||||
return sessionId
|
||||
? {
|
||||
type: 'open-big-fish',
|
||||
sessionId,
|
||||
fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId),
|
||||
}
|
||||
: buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
const profileId = work.profileId ?? work.worldKey;
|
||||
const ownerUserId = work.ownerUserId;
|
||||
if (!ownerUserId || !profileId) {
|
||||
return buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'open-rpg',
|
||||
detail: buildPlayedRpgDetail(work, profileId, ownerUserId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
adjustProfileDashboardWalletBalance,
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard,
|
||||
resolveProfileWalletBalance,
|
||||
} from './platformProfileWalletDeltaModel';
|
||||
|
||||
const NOW = Date.parse('2026-06-04T04:30:00.000Z');
|
||||
|
||||
function buildDashboard(
|
||||
overrides: Partial<ProfileDashboardSummary> = {},
|
||||
): ProfileDashboardSummary {
|
||||
return {
|
||||
walletBalance: 100,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('platformProfileWalletDeltaModel', () => {
|
||||
test('normalizes wallet balance to a non-negative integer', () => {
|
||||
expect(resolveProfileWalletBalance(buildDashboard({ walletBalance: 12.8 }))).toBe(
|
||||
12,
|
||||
);
|
||||
expect(
|
||||
resolveProfileWalletBalance(buildDashboard({ walletBalance: -4 })),
|
||||
).toBe(0);
|
||||
expect(resolveProfileWalletBalance({ walletBalance: Number.NaN })).toBe(0);
|
||||
expect(resolveProfileWalletBalance(null)).toBe(0);
|
||||
});
|
||||
|
||||
test('applies local delta and refreshes dashboard timestamp', () => {
|
||||
expect(
|
||||
adjustProfileDashboardWalletBalance(buildDashboard(), -3.8),
|
||||
).toMatchObject({
|
||||
walletBalance: 97,
|
||||
updatedAt: '2026-06-04T04:30:00.000Z',
|
||||
});
|
||||
expect(
|
||||
adjustProfileDashboardWalletBalance(buildDashboard({ walletBalance: 2 }), -10),
|
||||
).toMatchObject({
|
||||
walletBalance: 0,
|
||||
});
|
||||
expect(adjustProfileDashboardWalletBalance(null, 5)).toBeNull();
|
||||
const dashboard = buildDashboard();
|
||||
expect(adjustProfileDashboardWalletBalance(dashboard, Number.POSITIVE_INFINITY)).toBe(
|
||||
dashboard,
|
||||
);
|
||||
});
|
||||
|
||||
test('reconciles debit delta already reflected by latest server dashboard', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 98 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(-3);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 92 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('reconciles credit delta already reflected by latest server dashboard', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 103 }),
|
||||
8,
|
||||
),
|
||||
).toBe(5);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 120 }),
|
||||
8,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('does not reconcile when server balance moves against local delta', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 104 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(-5);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 96 }),
|
||||
8,
|
||||
),
|
||||
).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type ProfileWalletBalanceSource =
|
||||
| Pick<ProfileDashboardSummary, 'walletBalance'>
|
||||
| { walletBalance?: number | null }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export function resolveProfileWalletBalance(
|
||||
dashboard: ProfileWalletBalanceSource,
|
||||
) {
|
||||
const walletBalance = dashboard?.walletBalance;
|
||||
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
|
||||
? Math.max(0, Math.floor(walletBalance))
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function adjustProfileDashboardWalletBalance(
|
||||
dashboard: ProfileDashboardSummary | null,
|
||||
delta: number,
|
||||
): ProfileDashboardSummary | null {
|
||||
if (!dashboard || !Number.isFinite(delta) || delta === 0) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
return {
|
||||
...dashboard,
|
||||
walletBalance: Math.max(
|
||||
0,
|
||||
resolveProfileWalletBalance(dashboard) + Math.trunc(delta),
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previousDashboard: ProfileDashboardSummary | null,
|
||||
latestDashboard: ProfileDashboardSummary | null,
|
||||
localDelta: number,
|
||||
) {
|
||||
if (
|
||||
!previousDashboard ||
|
||||
!latestDashboard ||
|
||||
!Number.isFinite(localDelta) ||
|
||||
localDelta === 0
|
||||
) {
|
||||
return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0;
|
||||
}
|
||||
|
||||
const previousBalance = resolveProfileWalletBalance(previousDashboard);
|
||||
const latestBalance = resolveProfileWalletBalance(latestDashboard);
|
||||
const normalizedDelta = Math.trunc(localDelta);
|
||||
|
||||
if (normalizedDelta < 0) {
|
||||
const reflectedDebit = Math.max(0, previousBalance - latestBalance);
|
||||
return Math.min(0, normalizedDelta + reflectedDebit);
|
||||
}
|
||||
|
||||
const reflectedCredit = Math.max(0, latestBalance - previousBalance);
|
||||
return Math.max(0, normalizedDelta - reflectedCredit);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
mapRpgPublicCodeSearchDetailToGalleryCard,
|
||||
type PlatformPublicCodeSearchStep,
|
||||
resolveBabyObjectMatchPublicCodeSearchMatch,
|
||||
resolveBarkBattlePublicCodeSearchMatch,
|
||||
resolveBigFishPublicCodeSearchMatch,
|
||||
resolveJumpHopPublicCodeSearchMatch,
|
||||
resolveMatch3DPublicCodeSearchMatch,
|
||||
resolvePlatformPublicCodeSearchPlan,
|
||||
resolvePuzzlePublicCodeSearchMatch,
|
||||
resolveSquareHolePublicCodeSearchMatch,
|
||||
resolveVisualNovelPublicCodeSearchMatch,
|
||||
resolveWoodenFishPublicCodeSearchMatch,
|
||||
} from './platformPublicCodeSearchModel';
|
||||
|
||||
function expectSearchSteps(
|
||||
keyword: string,
|
||||
steps: readonly PlatformPublicCodeSearchStep[],
|
||||
) {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(keyword)?.steps).toEqual(steps);
|
||||
}
|
||||
|
||||
describe('platformPublicCodeSearchModel', () => {
|
||||
test('ignores empty public code search input', () => {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('normalizes public code search keyword before planning', () => {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(' PZ-00000001 ')).toEqual({
|
||||
normalizedKeyword: 'PZ-00000001',
|
||||
steps: ['puzzle-work'],
|
||||
});
|
||||
});
|
||||
|
||||
test('searches internal user ids directly without work fallback', () => {
|
||||
expectSearchSteps('user_00000001', ['user-id']);
|
||||
expectSearchSteps('USER-profile-1', ['user-id']);
|
||||
});
|
||||
|
||||
test('routes known public work prefixes to their play-specific lookup', () => {
|
||||
const cases: Array<
|
||||
[keyword: string, step: PlatformPublicCodeSearchStep]
|
||||
> = [
|
||||
['PZ-EPUBLIC1', 'puzzle-work'],
|
||||
['BF-NPUBLIC1', 'big-fish-work'],
|
||||
['JH-EPUBLIC1', 'jump-hop-work'],
|
||||
['WF-EPUBLIC1', 'wooden-fish-work'],
|
||||
['BO-EPUBLIC1', 'baby-object-match-work'],
|
||||
['M3-EPUBLIC1', 'match3d-work'],
|
||||
['M3D-LEGACY1', 'match3d-work'],
|
||||
['SH-EPUBLIC1', 'square-hole-work'],
|
||||
['VN-EPUBLIC1', 'visual-novel-work'],
|
||||
['BB-EPUBLIC1', 'bark-battle-work'],
|
||||
];
|
||||
|
||||
for (const [keyword, step] of cases) {
|
||||
expectSearchSteps(keyword, [step]);
|
||||
}
|
||||
});
|
||||
|
||||
test('searches RPG public works before public user codes for CW and numeric codes', () => {
|
||||
expectSearchSteps('CW-00000001', ['rpg-work', 'public-user-code']);
|
||||
expectSearchSteps('12345678', ['rpg-work', 'public-user-code']);
|
||||
});
|
||||
|
||||
test('keeps legacy user-code-first fallback for SY and ordinary keywords', () => {
|
||||
const legacyFallbackSteps = [
|
||||
'public-user-code',
|
||||
'rpg-work',
|
||||
'bark-battle-work',
|
||||
'public-user-code',
|
||||
] as const;
|
||||
|
||||
expectSearchSteps('SY-00000001', legacyFallbackSteps);
|
||||
expectSearchSteps('月井守望', legacyFallbackSteps);
|
||||
});
|
||||
|
||||
test('maps RPG detail responses to gallery cards with count defaults', () => {
|
||||
expect(
|
||||
mapRpgPublicCodeSearchDetailToGalleryCard(
|
||||
buildRpgDetailEntry({
|
||||
playCount: undefined,
|
||||
remixCount: undefined,
|
||||
likeCount: undefined,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
profileId: 'rpg-profile-1',
|
||||
visibility: 'published',
|
||||
worldName: '潮雾世界',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves public code matches for every play-specific gallery type', () => {
|
||||
const puzzle = buildPuzzleWork({ profileId: 'puzzle-profile-12345678' });
|
||||
const bigFish = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-12345678',
|
||||
});
|
||||
const jumpHop = buildJumpHopCard({ profileId: 'jump-hop-profile-12345678' });
|
||||
const woodenFish = buildWoodenFishCard({
|
||||
profileId: 'wooden-fish-profile-12345678',
|
||||
});
|
||||
const babyObjectMatch = buildBabyObjectMatchDraft({
|
||||
profileId: 'baby-object-profile-12345678',
|
||||
});
|
||||
const match3d = buildMatch3DWork({ profileId: 'match3d-profile-12345678' });
|
||||
const squareHole = buildSquareHoleWork({
|
||||
profileId: 'square-hole-profile-12345678',
|
||||
});
|
||||
const visualNovel = buildVisualNovelWork({
|
||||
profileId: 'visual-novel-profile-12345678',
|
||||
});
|
||||
const barkBattle = buildBarkBattleWork({
|
||||
workId: 'bark-battle-work-12345678',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePuzzlePublicCodeSearchMatch(
|
||||
[puzzle],
|
||||
buildPuzzlePublicWorkCode(puzzle.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'puzzle' });
|
||||
expect(
|
||||
resolveBigFishPublicCodeSearchMatch(
|
||||
[bigFish],
|
||||
buildBigFishPublicWorkCode(bigFish.sourceSessionId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'big-fish' });
|
||||
expect(
|
||||
resolveJumpHopPublicCodeSearchMatch(
|
||||
[jumpHop],
|
||||
buildJumpHopPublicWorkCode(jumpHop.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'jump-hop' });
|
||||
expect(
|
||||
resolveWoodenFishPublicCodeSearchMatch(
|
||||
[woodenFish],
|
||||
buildWoodenFishPublicWorkCode(woodenFish.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'wooden-fish' });
|
||||
expect(
|
||||
resolveBabyObjectMatchPublicCodeSearchMatch(
|
||||
[babyObjectMatch],
|
||||
`BO-${babyObjectMatch.profileId.slice(-8)}`,
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'edutainment' });
|
||||
expect(
|
||||
resolveMatch3DPublicCodeSearchMatch(
|
||||
[match3d],
|
||||
buildMatch3DPublicWorkCode(match3d.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'match3d' });
|
||||
expect(
|
||||
resolveSquareHolePublicCodeSearchMatch(
|
||||
[squareHole],
|
||||
buildSquareHolePublicWorkCode(squareHole.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'square-hole' });
|
||||
expect(
|
||||
resolveVisualNovelPublicCodeSearchMatch(
|
||||
[visualNovel],
|
||||
buildVisualNovelPublicWorkCode(visualNovel.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'visual-novel' });
|
||||
expect(
|
||||
resolveBarkBattlePublicCodeSearchMatch(
|
||||
[barkBattle],
|
||||
buildBarkBattlePublicWorkCode(barkBattle.workId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'bark-battle' });
|
||||
});
|
||||
|
||||
test('public code search matchers skip entries hidden by visibility policy', () => {
|
||||
const hiddenPuzzle = buildPuzzleWork({
|
||||
profileId: 'hidden-profile-12345678',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePuzzlePublicCodeSearchMatch(
|
||||
[hiddenPuzzle],
|
||||
buildPuzzlePublicWorkCode(hiddenPuzzle.profileId),
|
||||
() => false,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function buildRpgDetailEntry(
|
||||
overrides: Partial<CustomWorldLibraryEntry<CustomWorldProfile>> = {},
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return {
|
||||
ownerUserId: 'rpg-owner-1',
|
||||
profileId: 'rpg-profile-1',
|
||||
publicWorkCode: 'CW-00000001',
|
||||
authorPublicUserCode: 'SY-00000001',
|
||||
profile: {} as CustomWorldProfile,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
authorDisplayName: '测试作者',
|
||||
worldName: '潮雾世界',
|
||||
subtitle: '潮雾港',
|
||||
summaryText: '潮雾世界说明。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
playCount: 1,
|
||||
remixCount: 1,
|
||||
likeCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾拼图说明。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾拼图说明。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾大鱼',
|
||||
subtitle: '潮雾港',
|
||||
summary: '潮雾大鱼说明。',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 1,
|
||||
backgroundReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopCard(
|
||||
overrides: Partial<JumpHopGalleryCardResponse> = {},
|
||||
): JumpHopGalleryCardResponse {
|
||||
const profileId = overrides.profileId ?? 'jump-hop-profile-1';
|
||||
return {
|
||||
publicWorkCode: buildJumpHopPublicWorkCode(profileId),
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾跳一跳',
|
||||
workDescription: '潮雾跳一跳说明。',
|
||||
coverImageSrc: null,
|
||||
themeTags: [],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishCard(
|
||||
overrides: Partial<WoodenFishGalleryCardResponse> = {},
|
||||
): WoodenFishGalleryCardResponse {
|
||||
const profileId = overrides.profileId ?? 'wooden-fish-profile-1';
|
||||
return {
|
||||
publicWorkCode: buildWoodenFishPublicWorkCode(profileId),
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾木鱼',
|
||||
workDescription: '潮雾木鱼说明。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['敲木鱼'],
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-draft-1',
|
||||
profileId: 'baby-object-profile-1',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '潮雾识物',
|
||||
workDescription: '潮雾识物说明。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
buildBabyObjectMatchItemAsset('item-a', '苹果'),
|
||||
buildBabyObjectMatchItemAsset('item-b', '香蕉'),
|
||||
],
|
||||
visualPackage: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchItemAsset(itemId: string, itemName: string) {
|
||||
return {
|
||||
itemId,
|
||||
itemName,
|
||||
imageSrc: `/media/${itemId}.png`,
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder' as const,
|
||||
prompt: itemName,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '潮雾抓大鹅',
|
||||
themeText: '潮雾港',
|
||||
summary: '潮雾抓大鹅说明。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work-1',
|
||||
profileId: 'square-hole-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session-1',
|
||||
gameName: '潮雾方洞',
|
||||
themeText: '潮雾港',
|
||||
twistRule: '避开雾门',
|
||||
summary: '潮雾方洞说明。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '潮雾港',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 1,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '潮雾视觉小说',
|
||||
description: '潮雾视觉小说说明。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'published',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'bark-battle-work-1',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾声浪',
|
||||
summary: '潮雾声浪说明。',
|
||||
themeDescription: '潮雾港',
|
||||
playerImageDescription: '小狗',
|
||||
opponentImageDescription: '对手',
|
||||
onomatopoeia: ['汪'],
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
313
src/components/platform-entry/platformPublicCodeSearchModel.ts
Normal file
313
src/components/platform-entry/platformPublicCodeSearchModel.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
isSameBabyObjectMatchPublicWorkCode,
|
||||
isSameBarkBattlePublicWorkCode,
|
||||
isSameBigFishPublicWorkCode,
|
||||
isSameJumpHopPublicWorkCode,
|
||||
isSameMatch3DPublicWorkCode,
|
||||
isSamePuzzlePublicWorkCode,
|
||||
isSameSquareHolePublicWorkCode,
|
||||
isSameVisualNovelPublicWorkCode,
|
||||
isSameWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { mapBabyObjectMatchDraftToPlatformGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { canExposePublicWork } from './platformEdutainmentVisibility';
|
||||
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
|
||||
import {
|
||||
mapBarkBattleWorkToPublicWorkDetail,
|
||||
mapBigFishWorkToPublicWorkDetail,
|
||||
mapJumpHopWorkToPublicWorkDetail,
|
||||
mapPuzzleWorkToPublicWorkDetail,
|
||||
mapSquareHoleWorkToPublicWorkDetail,
|
||||
mapVisualNovelWorkToPublicWorkDetail,
|
||||
mapWoodenFishWorkToPublicWorkDetail,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
export type PlatformPublicCodeSearchStep =
|
||||
| 'user-id'
|
||||
| 'public-user-code'
|
||||
| 'rpg-work'
|
||||
| 'puzzle-work'
|
||||
| 'big-fish-work'
|
||||
| 'jump-hop-work'
|
||||
| 'wooden-fish-work'
|
||||
| 'baby-object-match-work'
|
||||
| 'match3d-work'
|
||||
| 'square-hole-work'
|
||||
| 'visual-novel-work'
|
||||
| 'bark-battle-work';
|
||||
|
||||
export type PlatformPublicCodeSearchPlan = {
|
||||
normalizedKeyword: string;
|
||||
steps: readonly PlatformPublicCodeSearchStep[];
|
||||
};
|
||||
|
||||
export type PlatformPublicCodeSearchMatch<TItem> = {
|
||||
item: TItem;
|
||||
detail: PlatformPublicGalleryCard;
|
||||
};
|
||||
|
||||
type PlatformPublicCodeSearchMatcherInput<TItem> = {
|
||||
keyword: string;
|
||||
entries: readonly TItem[];
|
||||
mapEntry: (item: TItem) => PlatformPublicGalleryCard;
|
||||
matchesEntry: (keyword: string, item: TItem) => boolean;
|
||||
canExposeEntry?: (entry: PlatformPublicGalleryCard) => boolean;
|
||||
};
|
||||
|
||||
const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu;
|
||||
const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u;
|
||||
|
||||
const DIRECT_WORK_PREFIX_STEPS: ReadonlyArray<
|
||||
readonly [prefix: string, step: PlatformPublicCodeSearchStep]
|
||||
> = [
|
||||
['PZ', 'puzzle-work'],
|
||||
['BF', 'big-fish-work'],
|
||||
['JH', 'jump-hop-work'],
|
||||
['WF', 'wooden-fish-work'],
|
||||
['BO', 'baby-object-match-work'],
|
||||
['M3', 'match3d-work'],
|
||||
['SH', 'square-hole-work'],
|
||||
['VN', 'visual-novel-work'],
|
||||
['BB', 'bark-battle-work'],
|
||||
];
|
||||
|
||||
/** 收口公开码搜索顺序,壳层只按步骤执行网络读取与打开副作用。 */
|
||||
export function resolvePlatformPublicCodeSearchPlan(
|
||||
keyword: string,
|
||||
): PlatformPublicCodeSearchPlan | null {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PLATFORM_PUBLIC_USER_ID_PATTERN.test(normalizedKeyword)) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: ['user-id'],
|
||||
};
|
||||
}
|
||||
|
||||
const upperKeyword = normalizedKeyword.toUpperCase();
|
||||
const directWorkStep = DIRECT_WORK_PREFIX_STEPS.find(([prefix]) =>
|
||||
upperKeyword.startsWith(prefix),
|
||||
)?.[1];
|
||||
if (directWorkStep) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: [directWorkStep],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
upperKeyword.startsWith('CW') ||
|
||||
PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN.test(normalizedKeyword)
|
||||
) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: ['rpg-work', 'public-user-code'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: [
|
||||
'public-user-code',
|
||||
'rpg-work',
|
||||
'bark-battle-work',
|
||||
'public-user-code',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapRpgPublicCodeSearchDetailToGalleryCard(
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
playCount: entry.playCount ?? 0,
|
||||
remixCount: entry.remixCount ?? 0,
|
||||
likeCount: entry.likeCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePuzzlePublicCodeSearchMatch(
|
||||
entries: readonly PuzzleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapPuzzleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSamePuzzlePublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBigFishPublicCodeSearchMatch(
|
||||
entries: readonly BigFishWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBigFishWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBigFishPublicWorkCode(searchKeyword, item.sourceSessionId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveJumpHopPublicCodeSearchMatch(
|
||||
entries: readonly JumpHopGalleryCardResponse[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapJumpHopWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameJumpHopPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWoodenFishPublicCodeSearchMatch(
|
||||
entries: readonly WoodenFishGalleryCardResponse[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapWoodenFishWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameWoodenFishPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBabyObjectMatchPublicCodeSearchMatch(
|
||||
entries: readonly BabyObjectMatchDraft[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBabyObjectMatchPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatch3DPublicCodeSearchMatch(
|
||||
entries: readonly Match3DWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapMatch3DWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameMatch3DPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSquareHolePublicCodeSearchMatch(
|
||||
entries: readonly SquareHoleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapSquareHoleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameSquareHolePublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVisualNovelPublicCodeSearchMatch(
|
||||
entries: readonly VisualNovelWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapVisualNovelWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameVisualNovelPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBarkBattlePublicCodeSearchMatch(
|
||||
entries: readonly BarkBattleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBarkBattleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBarkBattlePublicWorkCode(searchKeyword, item.workId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMappedPublicCodeSearchMatch<TItem>({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry,
|
||||
matchesEntry,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
}: PlatformPublicCodeSearchMatcherInput<TItem>):
|
||||
| PlatformPublicCodeSearchMatch<TItem>
|
||||
| null {
|
||||
for (const item of entries) {
|
||||
const detail = mapEntry(item);
|
||||
if (canExposeEntry(detail) && matchesEntry(keyword, item)) {
|
||||
return { item, detail };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
883
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
883
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
@@ -0,0 +1,883 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildPlatformPublicGalleryFeeds,
|
||||
getPlatformPublicGalleryEntryKey,
|
||||
getPlatformPublicGalleryEntryTime,
|
||||
getPlatformRecommendRuntimeKind,
|
||||
isPlatformRecommendRuntimeReadyForEntry,
|
||||
isSamePlatformPublicGalleryEntry,
|
||||
mergePlatformPublicGalleryEntries,
|
||||
type PlatformRecommendRuntimeStartIntentDeps,
|
||||
type RecommendRuntimeKind,
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision,
|
||||
resolvePlatformRecommendRuntimeStartIntent,
|
||||
} from './platformPublicGalleryFlow';
|
||||
import {
|
||||
mapBarkBattlePublicDetailToWorkSummary,
|
||||
mapPublicWorkDetailToBigFishWork,
|
||||
mapPublicWorkDetailToPuzzleWork,
|
||||
mapPublicWorkDetailToSquareHoleWork,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
type TypedPlatformPublicGalleryCard = Extract<
|
||||
PlatformPublicGalleryCard,
|
||||
{ sourceType: string }
|
||||
>;
|
||||
type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType'];
|
||||
type TypedPlatformPublicGalleryCardOverrides = Partial<
|
||||
Omit<TypedPlatformPublicGalleryCard, 'sourceType'>
|
||||
>;
|
||||
|
||||
function buildRpgEntry(
|
||||
overrides: Partial<CustomWorldGalleryCard> = {},
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'rpg-profile',
|
||||
publicWorkCode: 'CW-RPG',
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: 'RPG 世界',
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work',
|
||||
sourceSessionId: 'big-fish-session',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '海湾',
|
||||
summary: '一路长大。',
|
||||
coverImageSrc: '/big-fish-cover.png',
|
||||
status: 'published',
|
||||
updatedAt: '2026-06-01T02:00:00.000Z',
|
||||
publishedAt: '2026-06-01T02:00:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 1,
|
||||
backgroundReady: true,
|
||||
playCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
const itemAsset = {
|
||||
itemId: 'item-a',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder' as const,
|
||||
prompt: '苹果',
|
||||
};
|
||||
return {
|
||||
draftId: 'baby-draft',
|
||||
profileId: 'baby-profile',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '认识水果。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [itemAsset, { ...itemAsset, itemId: 'item-b', itemName: '香蕉' }],
|
||||
visualPackage: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T03:00:00.000Z',
|
||||
publishedAt: '2026-06-01T03:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopEntry(
|
||||
overrides: Partial<JumpHopGalleryCardResponse> = {},
|
||||
): JumpHopGalleryCardResponse {
|
||||
return {
|
||||
publicWorkCode: 'JH-JUMP',
|
||||
workId: 'jump-hop-work',
|
||||
profileId: 'jump-hop-profile',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: '跳一跳',
|
||||
workDescription: '一路向前。',
|
||||
coverImageSrc: '/jump-hop-cover.png',
|
||||
themeTags: ['跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
publicationStatus: 'published',
|
||||
playCount: 1,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypedEntry(
|
||||
sourceType: PlatformGallerySourceType,
|
||||
overrides: TypedPlatformPublicGalleryCardOverrides = {},
|
||||
): PlatformPublicGalleryCard {
|
||||
const common = {
|
||||
workId: `${sourceType}-work`,
|
||||
profileId: `${sourceType}-profile`,
|
||||
publicWorkCode: `${sourceType}-code`,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: `${sourceType} 作品`,
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: [sourceType],
|
||||
visibility: 'published' as const,
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
};
|
||||
|
||||
switch (sourceType) {
|
||||
case 'puzzle':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'big-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'match3d':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'square-hole':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'visual-novel':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'jump-hop':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'wooden-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'edutainment':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
};
|
||||
case 'bark-battle':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
authorPublicUserCode: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = sourceType;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work',
|
||||
profileId: 'puzzle-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session',
|
||||
authorDisplayName: '玩家',
|
||||
levelName: '拼图作品',
|
||||
summary: '拼图摘要',
|
||||
themeTags: ['拼图'],
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
playCount: 3,
|
||||
remixCount: 2,
|
||||
likeCount: 1,
|
||||
pointIncentiveTotalHalfPoints: 0,
|
||||
pointIncentiveClaimedPoints: 0,
|
||||
pointIncentiveTotalPoints: 0,
|
||||
pointIncentiveClaimablePoints: 0,
|
||||
publishReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work',
|
||||
profileId: 'match3d-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session',
|
||||
gameName: '抓大鹅作品',
|
||||
themeText: '经典消除',
|
||||
summary: '抓大鹅摘要',
|
||||
tags: ['抓大鹅'],
|
||||
coverImageSrc: '/match3d-cover.png',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: 10,
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'bark-battle-work',
|
||||
draftId: 'bark-battle-draft',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '汪汪声浪作品',
|
||||
summary: '汪汪摘要',
|
||||
themeDescription: '森林擂台',
|
||||
playerImageDescription: '小狗',
|
||||
opponentImageDescription: '对手',
|
||||
playerCharacterImageSrc: '/player.png',
|
||||
opponentCharacterImageSrc: '/opponent.png',
|
||||
uiBackgroundImageSrc: '/bark-bg.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 9,
|
||||
recentPlayCount7d: 2,
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecommendRuntimeStartDeps(
|
||||
overrides: Partial<PlatformRecommendRuntimeStartIntentDeps> = {},
|
||||
): PlatformRecommendRuntimeStartIntentDeps {
|
||||
return {
|
||||
selectedPuzzleDetail: null,
|
||||
barkBattleGalleryEntries: [],
|
||||
mapMatch3DWork: () => buildMatch3DWork(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => {
|
||||
const cases: Array<
|
||||
[sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind]
|
||||
> = [
|
||||
['big-fish', 'big-fish', 'big-fish'],
|
||||
['puzzle', 'puzzle', 'puzzle'],
|
||||
['jump-hop', 'jump-hop', 'jump-hop'],
|
||||
['wooden-fish', 'wooden-fish', 'wooden-fish'],
|
||||
['match3d', 'match3d', 'match3d'],
|
||||
['square-hole', 'square-hole', 'square-hole'],
|
||||
['visual-novel', 'visual-novel', 'visual-novel'],
|
||||
['bark-battle', 'bark-battle', 'bark-battle'],
|
||||
[
|
||||
'edutainment',
|
||||
`edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`,
|
||||
'edutainment',
|
||||
],
|
||||
];
|
||||
|
||||
cases.forEach(([sourceType, keyKind, kind]) => {
|
||||
const entry = buildTypedEntry(sourceType);
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(entry)).toBe(
|
||||
`${keyKind}:user-1:${sourceType}-profile`,
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind);
|
||||
});
|
||||
|
||||
const rpgEntry = buildRpgEntry();
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe(
|
||||
'rpg:user-1:rpg-profile',
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg');
|
||||
});
|
||||
|
||||
test('platform public gallery flow compares entries by resolved identity', () => {
|
||||
const left = buildTypedEntry('puzzle');
|
||||
const sameIdentity = buildTypedEntry('puzzle', {
|
||||
workId: 'other-work',
|
||||
worldName: '新标题',
|
||||
});
|
||||
const otherKind = buildTypedEntry('match3d', {
|
||||
ownerUserId: left.ownerUserId,
|
||||
profileId: left.profileId,
|
||||
});
|
||||
|
||||
expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true);
|
||||
expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime start intent', () => {
|
||||
const bigFishEntry = buildTypedEntry('big-fish');
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
bigFishEntry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
const selectedPuzzleDetail = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('puzzle'),
|
||||
buildRecommendRuntimeStartDeps({ selectedPuzzleDetail }),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-puzzle',
|
||||
work: selectedPuzzleDetail,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
const puzzleEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'fallback-puzzle-profile',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
puzzleEntry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }),
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-puzzle',
|
||||
work: mapPublicWorkDetailToPuzzleWork(puzzleEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('jump-hop'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-jump-hop',
|
||||
profileId: 'jump-hop-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('wooden-fish'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-wooden-fish',
|
||||
profileId: 'wooden-fish-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('visual-novel'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-visual-novel',
|
||||
profileId: 'visual-novel-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('edutainment'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-edutainment',
|
||||
entry: buildTypedEntry('edutainment'),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildRpgEntry(),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'mark-ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime mapper-backed start intent', () => {
|
||||
const match3DEntry = buildTypedEntry('match3d');
|
||||
const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
match3DEntry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
mapMatch3DWork: (entry) =>
|
||||
entry === match3DEntry ? match3DWork : null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-match3d',
|
||||
work: match3DWork,
|
||||
returnStage: 'work-detail',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
match3DEntry,
|
||||
buildRecommendRuntimeStartDeps({ mapMatch3DWork: () => null }),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'blocked',
|
||||
errorTarget: 'match3d',
|
||||
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
|
||||
});
|
||||
|
||||
const squareHoleEntry = buildTypedEntry('square-hole');
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
squareHoleEntry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-square-hole',
|
||||
work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime bark battle priority', () => {
|
||||
const entry = buildTypedEntry('bark-battle');
|
||||
const galleryWork = buildBarkBattleWork({
|
||||
workId: 'bark-battle-work',
|
||||
title: '推荐缓存',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
barkBattleGalleryEntries: [galleryWork],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-bark-battle',
|
||||
work: galleryWork,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-bark-battle',
|
||||
work: mapBarkBattlePublicDetailToWorkSummary(entry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime readiness', () => {
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
|
||||
activeKind: 'puzzle',
|
||||
hasBigFishRun: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
|
||||
activeKind: 'big-fish',
|
||||
hasBigFishRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('jump-hop'), {
|
||||
activeKind: 'jump-hop',
|
||||
hasJumpHopRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('wooden-fish'), {
|
||||
activeKind: 'wooden-fish',
|
||||
hasWoodenFishRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('match3d'), {
|
||||
activeKind: 'match3d',
|
||||
hasMatch3DRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('square-hole'), {
|
||||
activeKind: 'square-hole',
|
||||
hasSquareHoleRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('visual-novel'), {
|
||||
activeKind: 'visual-novel',
|
||||
hasVisualNovelRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('bark-battle'), {
|
||||
activeKind: 'bark-battle',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildRpgEntry(), {
|
||||
activeKind: 'rpg',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves puzzle and edutainment readiness details', () => {
|
||||
const puzzleEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'puzzle-profile',
|
||||
});
|
||||
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'other-profile',
|
||||
puzzleRunCurrentLevelProfileId: 'puzzle-profile',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'other-profile',
|
||||
puzzleRunCurrentLevelProfileId: 'another-profile',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
|
||||
activeKind: 'edutainment',
|
||||
hasBabyObjectMatchDraft: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
|
||||
activeKind: 'edutainment',
|
||||
hasBabyObjectMatchDraft: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start gates', () => {
|
||||
const entry = buildTypedEntry('big-fish');
|
||||
const baseInput: Parameters<
|
||||
typeof resolvePlatformRecommendRuntimeAutoStartDecision
|
||||
>[0] = {
|
||||
isDesktopLayout: false,
|
||||
selectionStage: 'platform',
|
||||
platformTab: 'home',
|
||||
isLoadingPlatform: false,
|
||||
entries: [entry],
|
||||
activeEntryKey: null,
|
||||
isStarting: false,
|
||||
readyState: { activeKind: null },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
isDesktopLayout: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
platformTab: 'discover',
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
entries: [],
|
||||
}),
|
||||
).toEqual({ type: 'clear' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
isStarting: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
|
||||
const firstEntry = buildTypedEntry('big-fish', {
|
||||
profileId: 'big-fish-first',
|
||||
});
|
||||
const activeEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'puzzle-active',
|
||||
});
|
||||
const activeEntryKey = getPlatformPublicGalleryEntryKey(activeEntry);
|
||||
const baseInput: Parameters<
|
||||
typeof resolvePlatformRecommendRuntimeAutoStartDecision
|
||||
>[0] = {
|
||||
isDesktopLayout: false,
|
||||
selectionStage: 'platform',
|
||||
platformTab: 'home',
|
||||
isLoadingPlatform: false,
|
||||
entries: [firstEntry, activeEntry],
|
||||
activeEntryKey,
|
||||
isStarting: false,
|
||||
readyState: { activeKind: 'puzzle' },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
readyState: {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'puzzle-active',
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(resolvePlatformRecommendRuntimeAutoStartDecision(baseInput)).toEqual({
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
activeEntryKey: 'missing-entry',
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'start',
|
||||
entry: firstEntry,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow merges duplicate identities and sorts newest first', () => {
|
||||
const staleRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '旧版 RPG',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
});
|
||||
const freshRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '新版 RPG',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
});
|
||||
const middleRpgEntry = buildRpgEntry({
|
||||
profileId: 'middle-rpg',
|
||||
worldName: '中间 RPG',
|
||||
publishedAt: '2026-06-02T00:00:00.000Z',
|
||||
});
|
||||
const updatedOnlyEntry = buildTypedEntry('big-fish', {
|
||||
profileId: 'updated-only',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
});
|
||||
const invalidTimeEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'invalid-time',
|
||||
publishedAt: 'not-a-date',
|
||||
updatedAt: 'still-not-a-date',
|
||||
});
|
||||
|
||||
const merged = mergePlatformPublicGalleryEntries(
|
||||
[staleRpgEntry, middleRpgEntry],
|
||||
[invalidTimeEntry, updatedOnlyEntry, freshRpgEntry],
|
||||
);
|
||||
|
||||
expect(merged).toHaveLength(4);
|
||||
expect(merged.map((entry) => entry.profileId)).toEqual([
|
||||
'shared-rpg',
|
||||
'updated-only',
|
||||
'middle-rpg',
|
||||
'invalid-time',
|
||||
]);
|
||||
expect(merged[0]?.worldName).toBe('新版 RPG');
|
||||
expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0);
|
||||
});
|
||||
|
||||
test('platform public gallery flow builds feeds with visibility gates and bark battle fallback', () => {
|
||||
const hiddenBigFish = buildBigFishWork({
|
||||
workId: 'hidden-big-fish',
|
||||
sourceSessionId: 'hidden-big-fish-session',
|
||||
});
|
||||
const hiddenBabyDraft = buildBabyObjectMatchDraft({
|
||||
profileId: 'hidden-baby',
|
||||
});
|
||||
const publishedBarkFallback = buildBarkBattleWork({
|
||||
workId: 'fallback-bark',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
});
|
||||
const draftBarkFallback = buildBarkBattleWork({
|
||||
workId: 'draft-bark',
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
const hiddenFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [
|
||||
buildRpgEntry({
|
||||
profileId: 'rpg-visible',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
bigFishEntries: [hiddenBigFish],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [draftBarkFallback, publishedBarkFallback],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [hiddenBabyDraft],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
hiddenFeeds.latestEntries.map((entry) =>
|
||||
'sourceType' in entry ? entry.sourceType : 'rpg',
|
||||
),
|
||||
).toEqual(['bark-battle', 'rpg']);
|
||||
expect(hiddenFeeds.latestEntries[0]?.profileId).toBe('fallback-bark');
|
||||
|
||||
const visibleFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [],
|
||||
bigFishEntries: [hiddenBigFish],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [
|
||||
buildBarkBattleWork({
|
||||
workId: 'gallery-bark',
|
||||
publishedAt: '2026-06-05T00:00:00.000Z',
|
||||
updatedAt: '2026-06-05T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
barkBattleWorks: [publishedBarkFallback],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [hiddenBabyDraft],
|
||||
isBigFishCreationVisible: true,
|
||||
isBabyObjectMatchVisible: true,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(visibleFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
|
||||
'gallery-bark',
|
||||
'hidden-baby',
|
||||
'hidden-big-fish-session',
|
||||
]);
|
||||
expect(visibleFeeds.featuredEntries).toEqual(
|
||||
visibleFeeds.latestEntries.slice(0, 6),
|
||||
);
|
||||
});
|
||||
|
||||
test('platform public gallery flow preserves feed tie order and featured slice', () => {
|
||||
const sameTime = '2026-06-04T00:00:00.000Z';
|
||||
const tieFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [],
|
||||
bigFishEntries: [],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [
|
||||
buildBarkBattleWork({
|
||||
workId: 'fallback-bark',
|
||||
publishedAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
}),
|
||||
],
|
||||
jumpHopEntries: [
|
||||
buildJumpHopEntry({
|
||||
profileId: 'jump-hop',
|
||||
publishedAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
}),
|
||||
],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(tieFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
|
||||
'jump-hop',
|
||||
'fallback-bark',
|
||||
]);
|
||||
|
||||
const sliceFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: Array.from({ length: 7 }, (_, index) =>
|
||||
buildRpgEntry({
|
||||
profileId: `rpg-${index}`,
|
||||
publishedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
|
||||
updatedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
|
||||
}),
|
||||
),
|
||||
bigFishEntries: [],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
expect(sliceFeeds.featuredEntries).toHaveLength(6);
|
||||
});
|
||||
556
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
556
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
mapJumpHopWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
mapSquareHoleWorkToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
mapWoodenFishWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
|
||||
import {
|
||||
mapBarkBattlePublicDetailToWorkSummary,
|
||||
mapPublicWorkDetailToBigFishWork,
|
||||
mapPublicWorkDetailToPuzzleWork,
|
||||
mapPublicWorkDetailToSquareHoleWork,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
export type RecommendRuntimeKind =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'edutainment'
|
||||
| 'jump-hop'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'square-hole'
|
||||
| 'wooden-fish'
|
||||
| 'visual-novel'
|
||||
| 'rpg';
|
||||
|
||||
export type PlatformRecommendRuntimeStartErrorTarget =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'square-hole';
|
||||
|
||||
export type PlatformRecommendRuntimeStartIntent =
|
||||
| {
|
||||
type: 'blocked';
|
||||
errorTarget: PlatformRecommendRuntimeStartErrorTarget;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: 'start-big-fish';
|
||||
work: BigFishWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-puzzle';
|
||||
work: PuzzleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-jump-hop';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-wooden-fish';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-match3d';
|
||||
work: Match3DWorkSummary;
|
||||
returnStage: 'work-detail';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-square-hole';
|
||||
work: SquareHoleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-visual-novel';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-bark-battle';
|
||||
work: BarkBattleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-edutainment';
|
||||
entry: PlatformPublicGalleryCard;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'mark-ready';
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeStartIntentDeps = {
|
||||
selectedPuzzleDetail?: PuzzleWorkSummary | null;
|
||||
barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[];
|
||||
mapMatch3DWork: (
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) => Match3DWorkSummary | null;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeReadyState = {
|
||||
activeKind: RecommendRuntimeKind | null;
|
||||
hasBabyObjectMatchDraft?: boolean;
|
||||
hasBigFishRun?: boolean;
|
||||
hasJumpHopRun?: boolean;
|
||||
hasMatch3DRun?: boolean;
|
||||
hasSquareHoleRun?: boolean;
|
||||
hasVisualNovelRun?: boolean;
|
||||
hasWoodenFishRun?: boolean;
|
||||
puzzleRunEntryProfileId?: string | null;
|
||||
puzzleRunCurrentLevelProfileId?: string | null;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeAutoStartDecision =
|
||||
| { type: 'noop' }
|
||||
| { type: 'clear' }
|
||||
| { type: 'start'; entry: PlatformPublicGalleryCard };
|
||||
|
||||
export type PlatformRecommendRuntimeAutoStartInput = {
|
||||
isDesktopLayout: boolean;
|
||||
selectionStage: string;
|
||||
platformTab: string;
|
||||
isLoadingPlatform: boolean;
|
||||
entries: readonly PlatformPublicGalleryCard[];
|
||||
activeEntryKey: string | null;
|
||||
isStarting: boolean;
|
||||
readyState: PlatformRecommendRuntimeReadyState;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryFeedsInput = {
|
||||
rpgEntries: readonly CustomWorldGalleryCard[];
|
||||
bigFishEntries: readonly BigFishWorkSummary[];
|
||||
match3dEntries: readonly Match3DWorkSummary[];
|
||||
puzzleEntries: readonly PuzzleWorkSummary[];
|
||||
barkBattleGalleryEntries: readonly BarkBattleWorkSummary[];
|
||||
barkBattleWorks: readonly BarkBattleWorkSummary[];
|
||||
jumpHopEntries: readonly JumpHopGalleryCardResponse[];
|
||||
woodenFishEntries: readonly WoodenFishGalleryCardResponse[];
|
||||
squareHoleEntries: readonly SquareHoleWorkSummary[];
|
||||
visualNovelEntries: readonly VisualNovelWorkSummary[];
|
||||
babyObjectMatchDrafts: readonly BabyObjectMatchDraft[];
|
||||
isBigFishCreationVisible: boolean;
|
||||
isBabyObjectMatchVisible: boolean;
|
||||
isVisualNovelCreationOpen: boolean;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryFeeds = {
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
export function getPlatformPublicGalleryEntryTime(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
const timestamp = new Date(rawTime).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
export function getPlatformPublicGalleryEntryKey(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
// 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
export function getPlatformRecommendRuntimeKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): RecommendRuntimeKind {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return 'jump-hop';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return 'wooden-fish';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return 'edutainment';
|
||||
}
|
||||
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
deps: PlatformRecommendRuntimeStartIntentDeps,
|
||||
): PlatformRecommendRuntimeStartIntent {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
const work = mapPublicWorkDetailToBigFishWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'big-fish',
|
||||
errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-big-fish',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.selectedPuzzleDetail?.profileId === entry.profileId
|
||||
? deps.selectedPuzzleDetail
|
||||
: mapPublicWorkDetailToPuzzleWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'puzzle',
|
||||
errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-puzzle',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-jump-hop',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-wooden-fish',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
// 中文注释:抓大鹅推荐 runtime 仍接 Match3D Module 的 Adapter,避免复制素材归一规则。
|
||||
const work = deps.mapMatch3DWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'match3d',
|
||||
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-match3d',
|
||||
work,
|
||||
returnStage: 'work-detail',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
const work = mapPublicWorkDetailToSquareHoleWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'square-hole',
|
||||
errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-square-hole',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-visual-novel',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.barkBattleGalleryEntries?.find(
|
||||
(item) => item.workId === entry.workId,
|
||||
) ?? mapBarkBattlePublicDetailToWorkSummary(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'bark-battle',
|
||||
errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-bark-battle',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-edutainment',
|
||||
entry,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'mark-ready',
|
||||
};
|
||||
}
|
||||
|
||||
export function isPlatformRecommendRuntimeReadyForEntry(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
state: PlatformRecommendRuntimeReadyState,
|
||||
) {
|
||||
const expectedKind = getPlatformRecommendRuntimeKind(entry);
|
||||
if (state.activeKind !== expectedKind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedKind === 'big-fish') {
|
||||
return Boolean(state.hasBigFishRun);
|
||||
}
|
||||
if (expectedKind === 'jump-hop') {
|
||||
return Boolean(state.hasJumpHopRun);
|
||||
}
|
||||
if (expectedKind === 'wooden-fish') {
|
||||
return Boolean(state.hasWoodenFishRun);
|
||||
}
|
||||
if (expectedKind === 'match3d') {
|
||||
return Boolean(state.hasMatch3DRun);
|
||||
}
|
||||
if (expectedKind === 'puzzle') {
|
||||
return (
|
||||
state.puzzleRunEntryProfileId === entry.profileId ||
|
||||
state.puzzleRunCurrentLevelProfileId === entry.profileId
|
||||
);
|
||||
}
|
||||
if (expectedKind === 'square-hole') {
|
||||
return Boolean(state.hasSquareHoleRun);
|
||||
}
|
||||
if (expectedKind === 'visual-novel') {
|
||||
return Boolean(state.hasVisualNovelRun);
|
||||
}
|
||||
if (expectedKind === 'bark-battle') {
|
||||
return true;
|
||||
}
|
||||
if (expectedKind === 'edutainment') {
|
||||
return Boolean(state.hasBabyObjectMatchDraft);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeAutoStartDecision(
|
||||
input: PlatformRecommendRuntimeAutoStartInput,
|
||||
): PlatformRecommendRuntimeAutoStartDecision {
|
||||
if (
|
||||
input.isDesktopLayout ||
|
||||
input.selectionStage !== 'platform' ||
|
||||
input.platformTab !== 'home' ||
|
||||
input.isLoadingPlatform
|
||||
) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
if (input.entries.length === 0) {
|
||||
return { type: 'clear' };
|
||||
}
|
||||
|
||||
const activeEntry = input.activeEntryKey
|
||||
? (input.entries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === input.activeEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRuntimeReady =
|
||||
activeEntry !== null &&
|
||||
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
|
||||
|
||||
if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
const nextEntry = activeEntry ?? input.entries[0];
|
||||
return nextEntry ? { type: 'start', entry: nextEntry } : { type: 'clear' };
|
||||
}
|
||||
|
||||
export function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return (
|
||||
getPlatformPublicGalleryEntryKey(left) ===
|
||||
getPlatformPublicGalleryEntryKey(right)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergePlatformPublicGalleryEntries(
|
||||
rpgEntries: readonly CustomWorldGalleryCard[],
|
||||
puzzleEntries: readonly PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
|
||||
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values()).sort(
|
||||
(left, right) =>
|
||||
getPlatformPublicGalleryEntryTime(right) -
|
||||
getPlatformPublicGalleryEntryTime(left),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPlatformPublicGalleryFeeds(
|
||||
input: PlatformPublicGalleryFeedsInput,
|
||||
): PlatformPublicGalleryFeeds {
|
||||
const bigFishEntries = input.isBigFishCreationVisible
|
||||
? input.bigFishEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const babyObjectMatchEntries = input.isBabyObjectMatchVisible
|
||||
? input.babyObjectMatchDrafts
|
||||
.filter((draft) => draft.publicationStatus === 'published')
|
||||
.map(mapBabyObjectMatchDraftToPlatformGalleryCard)
|
||||
: [];
|
||||
const barkBattleGalleryEntries = input.barkBattleGalleryEntries.map(
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
);
|
||||
const barkBattleFallbackEntries =
|
||||
input.barkBattleGalleryEntries.length === 0
|
||||
? input.barkBattleWorks
|
||||
.filter((work) => work.status === 'published')
|
||||
.map(mapBarkBattleWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const visualNovelEntries = input.isVisualNovelCreationOpen
|
||||
? input.visualNovelEntries.map(mapVisualNovelWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const latestEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
|
||||
...bigFishEntries,
|
||||
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
|
||||
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
...barkBattleGalleryEntries,
|
||||
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
|
||||
...barkBattleFallbackEntries,
|
||||
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
|
||||
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
|
||||
...visualNovelEntries,
|
||||
...babyObjectMatchEntries,
|
||||
]);
|
||||
const featuredEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
|
||||
...bigFishEntries,
|
||||
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
|
||||
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
...(barkBattleGalleryEntries.length > 0
|
||||
? barkBattleGalleryEntries
|
||||
: barkBattleFallbackEntries),
|
||||
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
|
||||
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
|
||||
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
|
||||
...visualNovelEntries,
|
||||
...babyObjectMatchEntries,
|
||||
]).slice(0, 6);
|
||||
|
||||
return {
|
||||
featuredEntries,
|
||||
latestEntries,
|
||||
};
|
||||
}
|
||||
1351
src/components/platform-entry/platformPublicWorkDetailFlow.test.ts
Normal file
1351
src/components/platform-entry/platformPublicWorkDetailFlow.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1102
src/components/platform-entry/platformPublicWorkDetailFlow.ts
Normal file
1102
src/components/platform-entry/platformPublicWorkDetailFlow.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleDraftLevel,
|
||||
PuzzleGeneratedImageCandidate,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
hasRecoverableGeneratedPuzzleDraft,
|
||||
normalizeRecoveredPuzzleDraftSession,
|
||||
} from './platformPuzzleDraftRecoveryModel';
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
overrides: Partial<PuzzleGeneratedImageCandidate> = {},
|
||||
): PuzzleGeneratedImageCandidate {
|
||||
return {
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/candidate-cover.png',
|
||||
assetId: 'asset-candidate-cover',
|
||||
prompt: '星桥机关',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLevel(overrides: Partial<PuzzleDraftLevel> = {}): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥机关画面',
|
||||
candidates: [buildCandidate()],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraft(overrides: Partial<PuzzleResultDraft> = {}): PuzzleResultDraft {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把碎片拼回原位。',
|
||||
themeTags: ['星桥', '机关', '修复'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [buildLevel()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
anchorPack,
|
||||
draft: buildDraft(),
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withCompleteLevelAssets(
|
||||
overrides: Partial<PuzzleDraftLevel> = {},
|
||||
): PuzzleDraftLevel {
|
||||
return buildLevel({
|
||||
levelSceneImageSrc: '/level-scene.png',
|
||||
uiSpritesheetImageSrc: '/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc: '/level-background.png',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('platformPuzzleDraftRecoveryModel', () => {
|
||||
test('normalizes and marks recovered puzzle draft ready when asset pack is complete', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
levels: [withCompleteLevelAssets()],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(true);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps half-finished draft generating when only cover candidate exists', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(buildSession());
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(false);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires level scene, ui spritesheet and level background assets together', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
withCompleteLevelAssets({
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts object keys as recovered asset references', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
buildLevel({
|
||||
levelSceneImageObjectKey: 'level-scene.png',
|
||||
uiSpritesheetImageObjectKey: 'ui-spritesheet.png',
|
||||
levelBackgroundImageObjectKey: 'level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('leaves sessions without draft unchanged and unrecoverable', () => {
|
||||
const session = buildSession({ draft: null });
|
||||
|
||||
expect(normalizeRecoveredPuzzleDraftSession(session)).toBe(session);
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
|
||||
function normalizeRecoveryText(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function hasPuzzleAssetReference(
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
return Boolean(normalizeRecoveryText(imageSrc) || normalizeRecoveryText(objectKey));
|
||||
}
|
||||
|
||||
function resolvePrimaryPuzzleLevel(session: PuzzleAgentSessionSnapshot) {
|
||||
return session.draft?.levels?.[0] ?? null;
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCandidate(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
primaryLevel: PuzzleDraftLevel | null,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
primaryLevel?.candidates.find((candidate) => candidate.selected) ??
|
||||
primaryLevel?.candidates[0] ??
|
||||
draft.candidates.find((candidate) => candidate.selected) ??
|
||||
draft.candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCoverFields(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const selectedCandidate = resolvePuzzleRecoveryCandidate(
|
||||
session,
|
||||
primaryLevel,
|
||||
);
|
||||
|
||||
return {
|
||||
coverImageSrc:
|
||||
normalizeRecoveryText(draft?.coverImageSrc) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverImageSrc) ??
|
||||
normalizeRecoveryText(selectedCandidate?.imageSrc),
|
||||
coverAssetId:
|
||||
normalizeRecoveryText(draft?.coverAssetId) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverAssetId) ??
|
||||
normalizeRecoveryText(selectedCandidate?.assetId),
|
||||
selectedCandidateId:
|
||||
draft?.selectedCandidateId ??
|
||||
primaryLevel?.selectedCandidateId ??
|
||||
selectedCandidate?.candidateId ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasCompleteGeneratedPuzzleLevelAssets(
|
||||
level: PuzzleDraftLevel | null,
|
||||
coverImageSrc: string | null,
|
||||
) {
|
||||
return Boolean(
|
||||
normalizeRecoveryText(coverImageSrc) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelSceneImageSrc,
|
||||
level?.levelSceneImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.uiSpritesheetImageSrc,
|
||||
level?.uiSpritesheetImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelBackgroundImageSrc,
|
||||
level?.levelBackgroundImageObjectKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRecoverableGeneratedPuzzleDraft(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const { coverImageSrc } = resolvePuzzleRecoveryCoverFields(session);
|
||||
return hasCompleteGeneratedPuzzleLevelAssets(primaryLevel, coverImageSrc);
|
||||
}
|
||||
|
||||
export function normalizeRecoveredPuzzleDraftSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const { coverImageSrc, coverAssetId, selectedCandidateId } =
|
||||
resolvePuzzleRecoveryCoverFields(session);
|
||||
const nextLevels = draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
coverImageSrc: normalizeRecoveryText(level.coverImageSrc)
|
||||
? level.coverImageSrc
|
||||
: coverImageSrc,
|
||||
coverAssetId: normalizeRecoveryText(level.coverAssetId)
|
||||
? level.coverAssetId
|
||||
: coverAssetId,
|
||||
selectedCandidateId:
|
||||
level.selectedCandidateId ?? selectedCandidateId,
|
||||
}
|
||||
: level,
|
||||
);
|
||||
const nextSession = {
|
||||
...session,
|
||||
draft: {
|
||||
...draft,
|
||||
coverImageSrc,
|
||||
coverAssetId,
|
||||
selectedCandidateId,
|
||||
levels: nextLevels,
|
||||
},
|
||||
} satisfies PuzzleAgentSessionSnapshot;
|
||||
const isRecoverable = hasRecoverableGeneratedPuzzleDraft(nextSession);
|
||||
|
||||
if (!isRecoverable) {
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextSession,
|
||||
draft: {
|
||||
...nextSession.draft,
|
||||
generationStatus: 'ready',
|
||||
levels: nextSession.draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
generationStatus: 'ready',
|
||||
}
|
||||
: level,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
buildPuzzleSessionIdFromProfileId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
describe('platformPuzzleIdentityModel', () => {
|
||||
test('builds stable puzzle result identities from a session id', () => {
|
||||
expect(buildPuzzleResultProfileId(' puzzle-session-ocean ')).toBe(
|
||||
'puzzle-profile-ocean',
|
||||
);
|
||||
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-work-ocean',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps legacy suffix inputs usable', () => {
|
||||
expect(buildPuzzleResultProfileId('ocean')).toBe('puzzle-profile-ocean');
|
||||
expect(buildPuzzleResultWorkId('ocean')).toBe('puzzle-work-ocean');
|
||||
});
|
||||
|
||||
test('builds draft runtime session ids from profile ids', () => {
|
||||
expect(buildPuzzleSessionIdFromProfileId(' puzzle-profile-ocean ')).toBe(
|
||||
'puzzle-session-ocean',
|
||||
);
|
||||
expect(buildPuzzleSessionIdFromProfileId('puzzle-work-ocean')).toBeNull();
|
||||
expect(buildPuzzleSessionIdFromProfileId('puzzle-profile-')).toBeNull();
|
||||
});
|
||||
});
|
||||
36
src/components/platform-entry/platformPuzzleIdentityModel.ts
Normal file
36
src/components/platform-entry/platformPuzzleIdentityModel.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** 收口拼图草稿在 session/profile/work 之间的稳定身份互推规则。 */
|
||||
export function buildPuzzleResultProfileId(
|
||||
sessionId: string | null | undefined,
|
||||
) {
|
||||
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
|
||||
return stableSuffix ? `puzzle-profile-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
export function buildPuzzleResultWorkId(sessionId: string | null | undefined) {
|
||||
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
|
||||
return stableSuffix ? `puzzle-work-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
export function buildPuzzleSessionIdFromProfileId(
|
||||
profileId: string | null | undefined,
|
||||
) {
|
||||
const normalizedProfileId = profileId?.trim();
|
||||
if (!normalizedProfileId?.startsWith('puzzle-profile-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length);
|
||||
return stableSuffix ? `puzzle-session-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
function resolvePuzzleSessionStableSuffix(
|
||||
sessionId: string | null | undefined,
|
||||
) {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
return normalizedSessionId.startsWith('puzzle-session-')
|
||||
? normalizedSessionId.slice('puzzle-session-'.length)
|
||||
: normalizedSessionId;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleRunSnapshot,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
|
||||
|
||||
const currentLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '本地玩家',
|
||||
elapsedMs: 12000,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
|
||||
const serviceLevelLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '服务端玩家',
|
||||
elapsedMs: 9000,
|
||||
},
|
||||
];
|
||||
|
||||
const serviceRunLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 2,
|
||||
nickname: '全局玩家',
|
||||
elapsedMs: 15000,
|
||||
},
|
||||
];
|
||||
|
||||
function buildPuzzleLevel(
|
||||
overrides: Partial<PuzzleRuntimeLevelSnapshot> = {},
|
||||
): PuzzleRuntimeLevelSnapshot {
|
||||
return {
|
||||
runId: 'run-current',
|
||||
levelIndex: 0,
|
||||
levelId: 'level-1',
|
||||
gridSize: 3,
|
||||
profileId: 'puzzle-profile-current',
|
||||
levelName: '星桥机关',
|
||||
authorDisplayName: '玩家',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: '/cover.png',
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
pieces: [],
|
||||
mergedGroups: [],
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: true,
|
||||
},
|
||||
status: 'cleared',
|
||||
startedAtMs: 1000,
|
||||
clearedAtMs: 13000,
|
||||
elapsedMs: 12000,
|
||||
timeLimitMs: 120000,
|
||||
remainingMs: 108000,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
leaderboardEntries: currentLeaderboard,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleRun(
|
||||
overrides: Partial<PuzzleRunSnapshot> = {},
|
||||
): PuzzleRunSnapshot {
|
||||
return {
|
||||
runId: 'run-current',
|
||||
entryProfileId: 'puzzle-profile-current',
|
||||
clearedLevelCount: 1,
|
||||
currentLevelIndex: 0,
|
||||
currentGridSize: 3,
|
||||
playedProfileIds: ['puzzle-profile-current'],
|
||||
previousLevelTags: ['星桥'],
|
||||
currentLevel: buildPuzzleLevel(),
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: null,
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: currentLeaderboard,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformPuzzleRuntimeStateModel', () => {
|
||||
test('keeps current run when either current level is missing', () => {
|
||||
const currentRun = buildPuzzleRun({ currentLevel: null });
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, buildPuzzleRun()),
|
||||
).toBe(currentRun);
|
||||
|
||||
const serviceRun = buildPuzzleRun({ currentLevel: null });
|
||||
const playableCurrentRun = buildPuzzleRun();
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(playableCurrentRun, serviceRun),
|
||||
).toBe(playableCurrentRun);
|
||||
});
|
||||
|
||||
test('merges service leaderboard and next-level handoff without replacing local level state', () => {
|
||||
const currentRun = buildPuzzleRun({
|
||||
clearedLevelCount: 2,
|
||||
currentLevel: buildPuzzleLevel({
|
||||
runId: 'run-current',
|
||||
status: 'cleared',
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
pieces: [
|
||||
{
|
||||
pieceId: 'piece-local',
|
||||
correctRow: 0,
|
||||
correctCol: 0,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
mergedGroupId: null,
|
||||
},
|
||||
],
|
||||
mergedGroups: [],
|
||||
selectedPieceId: 'piece-local',
|
||||
allTilesResolved: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const serviceRun = buildPuzzleRun({
|
||||
runId: 'run-service',
|
||||
entryProfileId: 'puzzle-profile-service',
|
||||
clearedLevelCount: 1,
|
||||
recommendedNextProfileId: 'next-recommended',
|
||||
nextLevelMode: 'similarWorks',
|
||||
nextLevelProfileId: 'next-profile',
|
||||
nextLevelId: 'next-level',
|
||||
recommendedNextWorks: [
|
||||
{
|
||||
profileId: 'next-profile',
|
||||
levelName: '月桥机关',
|
||||
authorDisplayName: '推荐作者',
|
||||
themeTags: ['月桥'],
|
||||
coverImageSrc: '/next-cover.png',
|
||||
similarityScore: 0.91,
|
||||
},
|
||||
],
|
||||
currentLevel: buildPuzzleLevel({
|
||||
runId: 'run-service-level',
|
||||
status: 'playing',
|
||||
leaderboardEntries: serviceLevelLeaderboard,
|
||||
}),
|
||||
});
|
||||
|
||||
const merged = mergePuzzleServiceRuntimeState(currentRun, serviceRun);
|
||||
|
||||
expect(merged.runId).toBe('run-service');
|
||||
expect(merged.entryProfileId).toBe('puzzle-profile-service');
|
||||
expect(merged.clearedLevelCount).toBe(2);
|
||||
expect(merged.recommendedNextProfileId).toBe('next-recommended');
|
||||
expect(merged.nextLevelMode).toBe('similarWorks');
|
||||
expect(merged.nextLevelProfileId).toBe('next-profile');
|
||||
expect(merged.nextLevelId).toBe('next-level');
|
||||
expect(merged.recommendedNextWorks).toEqual(serviceRun.recommendedNextWorks);
|
||||
expect(merged.leaderboardEntries).toEqual(serviceLevelLeaderboard);
|
||||
expect(merged.currentLevel?.status).toBe('cleared');
|
||||
expect(merged.currentLevel?.board.pieces).toEqual(
|
||||
currentRun.currentLevel?.board.pieces,
|
||||
);
|
||||
expect(merged.currentLevel?.leaderboardEntries).toEqual(
|
||||
serviceLevelLeaderboard,
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to service run leaderboard, then current level leaderboard', () => {
|
||||
const currentRun = buildPuzzleRun();
|
||||
const serviceRun = buildPuzzleRun({
|
||||
currentLevel: buildPuzzleLevel({ leaderboardEntries: [] }),
|
||||
leaderboardEntries: serviceRunLeaderboard,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, serviceRun).currentLevel
|
||||
?.leaderboardEntries,
|
||||
).toEqual(serviceRunLeaderboard);
|
||||
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, {
|
||||
...serviceRun,
|
||||
leaderboardEntries: [],
|
||||
}).currentLevel?.leaderboardEntries,
|
||||
).toEqual(currentLeaderboard);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
|
||||
export function mergePuzzleServiceRuntimeState(
|
||||
currentRun: PuzzleRunSnapshot,
|
||||
serviceRun: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!currentRun.currentLevel || !serviceRun.currentLevel) {
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
const serviceLevel = serviceRun.currentLevel;
|
||||
const leaderboardEntries =
|
||||
serviceLevel.leaderboardEntries.length > 0
|
||||
? serviceLevel.leaderboardEntries
|
||||
: serviceRun.leaderboardEntries;
|
||||
|
||||
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
|
||||
return {
|
||||
...currentRun,
|
||||
runId: serviceRun.runId,
|
||||
entryProfileId: serviceRun.entryProfileId,
|
||||
clearedLevelCount: Math.max(
|
||||
currentRun.clearedLevelCount,
|
||||
serviceRun.clearedLevelCount,
|
||||
),
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
nextLevelId: serviceRun.nextLevelId,
|
||||
recommendedNextWorks: serviceRun.recommendedNextWorks,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentRun.currentLevel,
|
||||
leaderboardEntries:
|
||||
leaderboardEntries.length > 0
|
||||
? leaderboardEntries
|
||||
: currentRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolvePlatformRecommendRuntimeAuthPlan,
|
||||
shouldUsePlatformRecommendRuntimeGuestAuth,
|
||||
} from './platformRecommendRuntimeAuthModel';
|
||||
|
||||
test('uses runtime guest auth for anonymous embedded recommendation runtime', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses background auth for signed-in embedded recommendation runtime', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: 'user-1',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'background',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses background auth when embedded runtime has only a stored access token', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: true,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'background',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not alter auth for non-embedded runtime launches by default', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses isolated guest auth for anonymous puzzle isolated launch', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to default puzzle auth when isolated launch has account auth', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: 'user-1',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('guest auth decision trims user id before treating account as signed in', () => {
|
||||
expect(
|
||||
shouldUsePlatformRecommendRuntimeGuestAuth({
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: ' ',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export type PlatformRecommendRuntimeRequestKind =
|
||||
| 'none'
|
||||
| 'background'
|
||||
| 'runtime-guest';
|
||||
|
||||
export type PlatformPuzzleRuntimeAuthMode = 'default' | 'isolated';
|
||||
|
||||
export type PlatformRecommendRuntimeAuthPlan = {
|
||||
requestKind: PlatformRecommendRuntimeRequestKind;
|
||||
puzzleRuntimeAuthMode: PlatformPuzzleRuntimeAuthMode;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeAuthInput = {
|
||||
embedded?: boolean;
|
||||
allowRuntimeGuestAuth?: boolean;
|
||||
authUserId?: string | null;
|
||||
hasStoredAccessToken?: boolean;
|
||||
};
|
||||
|
||||
function hasAccountAuth(input: {
|
||||
authUserId?: string | null;
|
||||
hasStoredAccessToken?: boolean;
|
||||
}) {
|
||||
return Boolean(input.authUserId?.trim() || input.hasStoredAccessToken);
|
||||
}
|
||||
|
||||
export function shouldUsePlatformRecommendRuntimeGuestAuth(
|
||||
input: Pick<
|
||||
PlatformRecommendRuntimeAuthInput,
|
||||
'allowRuntimeGuestAuth' | 'authUserId' | 'hasStoredAccessToken'
|
||||
>,
|
||||
) {
|
||||
return Boolean(input.allowRuntimeGuestAuth) && !hasAccountAuth(input);
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeAuthPlan(
|
||||
input: PlatformRecommendRuntimeAuthInput,
|
||||
): PlatformRecommendRuntimeAuthPlan {
|
||||
const embedded = Boolean(input.embedded);
|
||||
const allowRuntimeGuestAuth = input.allowRuntimeGuestAuth ?? embedded;
|
||||
const useRuntimeGuestAuth = shouldUsePlatformRecommendRuntimeGuestAuth({
|
||||
allowRuntimeGuestAuth,
|
||||
authUserId: input.authUserId,
|
||||
hasStoredAccessToken: input.hasStoredAccessToken,
|
||||
});
|
||||
|
||||
if (useRuntimeGuestAuth) {
|
||||
return {
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestKind: embedded ? 'background' : 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { createRpgCreationPublishedProfileFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
buildPlatformRpgAgentResultPublishGateView,
|
||||
type PlatformRpgAgentResultBlockerView,
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel,
|
||||
} from './platformRpgAgentResultPreviewModel';
|
||||
|
||||
function buildProfile(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): CustomWorldProfile {
|
||||
return {
|
||||
...createRpgCreationPublishedProfileFixture(),
|
||||
worldHook: '潮雾列岛旧灯塔重新点亮。',
|
||||
playerPremise: '玩家从回潮旧灯塔切入沉船旧案。',
|
||||
coreConflicts: ['守灯会与沉船旧案的冲突'],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
const missingWorldHookBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_world_hook',
|
||||
message: '缺少世界钩子',
|
||||
};
|
||||
const missingPlayerPremiseBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_player_premise',
|
||||
message: '缺少玩家前提',
|
||||
};
|
||||
const missingCoreConflictBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_core_conflict',
|
||||
message: '缺少核心冲突',
|
||||
};
|
||||
const missingMainChapterBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_main_chapter',
|
||||
message: '缺少主章节',
|
||||
};
|
||||
const missingFirstActBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_first_act',
|
||||
message: '缺少首幕',
|
||||
};
|
||||
const structuralBlockers: PlatformRpgAgentResultBlockerView[] = [
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingCoreConflictBlocker,
|
||||
missingMainChapterBlocker,
|
||||
missingFirstActBlocker,
|
||||
];
|
||||
|
||||
describe('platformRpgAgentResultPreviewModel', () => {
|
||||
test('uses fallback blockers and publish readiness without a profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
null,
|
||||
structuralBlockers.slice(0, 2),
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['缺少世界钩子', '缺少玩家前提'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('filters structural blockers already satisfied by the profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile(),
|
||||
[
|
||||
...structuralBlockers,
|
||||
{
|
||||
code: 'future_blocker',
|
||||
message: '未知服务端阻断',
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['未知服务端阻断'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unresolved structural blockers when profile fields are empty', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: null,
|
||||
anchorContent: null,
|
||||
coreConflicts: [],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: [],
|
||||
sceneChapters: [],
|
||||
}),
|
||||
structuralBlockers,
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: structuralBlockers.map((entry) => entry.message),
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves structural blockers from nested profile compatibility fields', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: {
|
||||
worldHook: '旧灯塔潮路重新开启。',
|
||||
},
|
||||
anchorContent: {
|
||||
playerEntryPoint: {
|
||||
openingProblem: '玩家被卷入沉船旧案。',
|
||||
},
|
||||
},
|
||||
coreConflicts: [''],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: null,
|
||||
sceneChapters: [
|
||||
{
|
||||
acts: [{}],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingFirstActBlocker,
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: [],
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps preview source to result label', () => {
|
||||
expect(resolvePlatformRpgAgentResultPreviewSourceLabel(null)).toBeNull();
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('published_profile'),
|
||||
).toBe('已发布世界');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('session_preview'),
|
||||
).toBe('会话预览');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('future_source'),
|
||||
).toBe(
|
||||
'服务端预览',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { RpgCreationPreviewSource } from '../../../packages/shared/src/contracts/rpgCreationPreview';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformRpgAgentResultBlockerView = {
|
||||
code?: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PlatformRpgAgentResultPublishGateView = {
|
||||
blockers: string[];
|
||||
publishReady: boolean;
|
||||
};
|
||||
|
||||
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_world_hook',
|
||||
'publish_missing_player_premise',
|
||||
'publish_missing_core_conflict',
|
||||
'publish_missing_main_chapter',
|
||||
'publish_missing_first_act',
|
||||
]);
|
||||
|
||||
function readProfileTextField(
|
||||
profile: CustomWorldProfile | null,
|
||||
paths: string[],
|
||||
) {
|
||||
for (const path of paths) {
|
||||
let current: unknown = profile;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
if (typeof current === 'string' && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) {
|
||||
const value = profile
|
||||
? (profile as unknown as Record<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value)
|
||||
? value.some((entry) => typeof entry === 'string' && entry.trim())
|
||||
: false;
|
||||
}
|
||||
|
||||
function hasProfileArray(profile: CustomWorldProfile | null, key: string) {
|
||||
const value = profile
|
||||
? (profile as unknown as Record<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
||||
function hasSceneAct(profile: CustomWorldProfile | null) {
|
||||
const rawProfile = profile as unknown as Record<string, unknown> | null;
|
||||
const chapters =
|
||||
rawProfile &&
|
||||
(Array.isArray(rawProfile.sceneChapterBlueprints)
|
||||
? rawProfile.sceneChapterBlueprints
|
||||
: Array.isArray(rawProfile.sceneChapters)
|
||||
? rawProfile.sceneChapters
|
||||
: []);
|
||||
return Array.isArray(chapters)
|
||||
? chapters.some((chapter) => {
|
||||
const acts =
|
||||
chapter && typeof chapter === 'object'
|
||||
? (chapter as Record<string, unknown>).acts
|
||||
: null;
|
||||
return Array.isArray(acts) && acts.length > 0;
|
||||
})
|
||||
: false;
|
||||
}
|
||||
|
||||
function isAgentResultStructuralBlockerResolved(
|
||||
profile: CustomWorldProfile,
|
||||
code: string | null | undefined,
|
||||
) {
|
||||
if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code === 'publish_missing_world_hook') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'worldHook',
|
||||
'creatorIntent.worldHook',
|
||||
'anchorContent.worldPromise',
|
||||
'anchorContent.worldPromise.hook',
|
||||
'settingText',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_player_premise') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'playerPremise',
|
||||
'creatorIntent.playerPremise',
|
||||
'anchorContent.playerEntryPoint',
|
||||
'anchorContent.playerEntryPoint.openingIdentity',
|
||||
'anchorContent.playerEntryPoint.openingProblem',
|
||||
'anchorContent.playerEntryPoint.entryMotivation',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_core_conflict') {
|
||||
return hasProfileTextArray(profile, 'coreConflicts');
|
||||
}
|
||||
if (code === 'publish_missing_main_chapter') {
|
||||
return (
|
||||
hasProfileArray(profile, 'chapters') ||
|
||||
hasProfileArray(profile, 'sceneChapterBlueprints') ||
|
||||
hasProfileArray(profile, 'sceneChapters')
|
||||
);
|
||||
}
|
||||
return hasSceneAct(profile);
|
||||
}
|
||||
|
||||
export function buildPlatformRpgAgentResultPublishGateView(
|
||||
profile: CustomWorldProfile | null,
|
||||
fallbackBlockers: PlatformRpgAgentResultBlockerView[],
|
||||
fallbackPublishReady: boolean,
|
||||
): PlatformRpgAgentResultPublishGateView {
|
||||
if (!profile) {
|
||||
return {
|
||||
blockers: fallbackBlockers.map((entry) => entry.message),
|
||||
publishReady: fallbackPublishReady,
|
||||
};
|
||||
}
|
||||
|
||||
const blockers = fallbackBlockers
|
||||
.filter(
|
||||
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
|
||||
return {
|
||||
blockers,
|
||||
publishReady: blockers.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformRpgAgentResultPreviewSourceLabel(
|
||||
source: RpgCreationPreviewSource | string | null | undefined,
|
||||
) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (source === 'published_profile') {
|
||||
return '已发布世界';
|
||||
}
|
||||
if (source === 'session_preview') {
|
||||
return '会话预览';
|
||||
}
|
||||
return '服务端预览';
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import {
|
||||
type MissingCreationStateParams,
|
||||
resolveSelectionStageAfterMissingCreationState,
|
||||
resolveSelectionStageAfterProtectedDataLoss,
|
||||
} from './platformSelectionStageModel';
|
||||
|
||||
describe('platformSelectionStageModel', () => {
|
||||
test('keeps public and workspace stages after protected data loss', () => {
|
||||
const stableStages: SelectionStage[] = [
|
||||
'platform',
|
||||
'work-detail',
|
||||
'detail',
|
||||
'agent-workspace',
|
||||
'big-fish-agent-workspace',
|
||||
'match3d-agent-workspace',
|
||||
'square-hole-agent-workspace',
|
||||
'jump-hop-workspace',
|
||||
'wooden-fish-workspace',
|
||||
'puzzle-agent-workspace',
|
||||
'bark-battle-workspace',
|
||||
'visual-novel-agent-workspace',
|
||||
'baby-object-match-workspace',
|
||||
'creative-agent-workspace',
|
||||
'puzzle-gallery-detail',
|
||||
];
|
||||
|
||||
stableStages.forEach((stage) => {
|
||||
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(stage);
|
||||
});
|
||||
});
|
||||
|
||||
test('resets private result, generating, runtime and profile stages to platform', () => {
|
||||
const resetStages: SelectionStage[] = [
|
||||
'profile-feedback',
|
||||
'big-fish-generating',
|
||||
'big-fish-result',
|
||||
'big-fish-runtime',
|
||||
'match3d-generating',
|
||||
'match3d-result',
|
||||
'match3d-runtime',
|
||||
'square-hole-generating',
|
||||
'square-hole-result',
|
||||
'square-hole-runtime',
|
||||
'jump-hop-generating',
|
||||
'jump-hop-result',
|
||||
'jump-hop-runtime',
|
||||
'jump-hop-gallery-detail',
|
||||
'wooden-fish-generating',
|
||||
'wooden-fish-result',
|
||||
'wooden-fish-runtime',
|
||||
'visual-novel-generating',
|
||||
'visual-novel-result',
|
||||
'visual-novel-gallery-detail',
|
||||
'visual-novel-runtime',
|
||||
'baby-object-match-generating',
|
||||
'baby-object-match-result',
|
||||
'baby-object-match-runtime',
|
||||
'baby-love-drawing-runtime',
|
||||
'puzzle-generating',
|
||||
'puzzle-onboarding',
|
||||
'puzzle-result',
|
||||
'puzzle-runtime',
|
||||
'custom-world-generating',
|
||||
'custom-world-result',
|
||||
'bark-battle-generating',
|
||||
'bark-battle-result',
|
||||
'bark-battle-runtime',
|
||||
];
|
||||
|
||||
resetStages.forEach((stage) => {
|
||||
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(
|
||||
'platform',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves missing session draft result stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'match3d-result',
|
||||
match3d: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('match3d-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'square-hole-result',
|
||||
squareHole: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('square-hole-agent-workspace');
|
||||
});
|
||||
|
||||
test('resolves missing session run stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'match3d-runtime',
|
||||
match3d: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('match3d-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'square-hole-runtime',
|
||||
squareHole: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: true,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('square-hole-result');
|
||||
});
|
||||
|
||||
test('resolves visual novel and baby object missing state stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-result',
|
||||
visualNovel: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-runtime',
|
||||
visualNovel: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasWork: true,
|
||||
hasWorkDraft: true,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-gallery-detail',
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'baby-object-match-result',
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
|
||||
}),
|
||||
),
|
||||
).toBe('baby-object-match-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'baby-object-match-runtime',
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
});
|
||||
|
||||
test('keeps stages when required creation state exists', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: true },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-runtime');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-gallery-detail',
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: true,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-gallery-detail');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'platform',
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
});
|
||||
});
|
||||
|
||||
function buildMissingCreationStateParams(
|
||||
overrides: Partial<MissingCreationStateParams> = {},
|
||||
): MissingCreationStateParams {
|
||||
return {
|
||||
stage: 'platform',
|
||||
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
match3d: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
squareHole: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
153
src/components/platform-entry/platformSelectionStageModel.ts
Normal file
153
src/components/platform-entry/platformSelectionStageModel.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
'profile-feedback': false,
|
||||
'work-detail': true,
|
||||
detail: true,
|
||||
'agent-workspace': true,
|
||||
'big-fish-agent-workspace': true,
|
||||
'big-fish-generating': false,
|
||||
'big-fish-result': false,
|
||||
'big-fish-runtime': false,
|
||||
'match3d-agent-workspace': true,
|
||||
'match3d-generating': false,
|
||||
'match3d-result': false,
|
||||
'match3d-runtime': false,
|
||||
'square-hole-agent-workspace': true,
|
||||
'square-hole-generating': false,
|
||||
'square-hole-result': false,
|
||||
'square-hole-runtime': false,
|
||||
'jump-hop-workspace': true,
|
||||
'jump-hop-generating': false,
|
||||
'jump-hop-result': false,
|
||||
'jump-hop-runtime': false,
|
||||
'jump-hop-gallery-detail': false,
|
||||
'bark-battle-workspace': true,
|
||||
'bark-battle-generating': false,
|
||||
'bark-battle-result': false,
|
||||
'bark-battle-runtime': false,
|
||||
'wooden-fish-workspace': true,
|
||||
'wooden-fish-generating': false,
|
||||
'wooden-fish-result': false,
|
||||
'wooden-fish-runtime': false,
|
||||
'creative-agent-workspace': true,
|
||||
'visual-novel-agent-workspace': true,
|
||||
'visual-novel-generating': false,
|
||||
'visual-novel-result': false,
|
||||
'visual-novel-gallery-detail': false,
|
||||
'visual-novel-runtime': false,
|
||||
'baby-object-match-workspace': true,
|
||||
'baby-object-match-generating': false,
|
||||
'baby-object-match-result': false,
|
||||
'baby-object-match-runtime': false,
|
||||
'baby-love-drawing-runtime': false,
|
||||
'puzzle-agent-workspace': true,
|
||||
'puzzle-generating': false,
|
||||
'puzzle-onboarding': false,
|
||||
'puzzle-result': false,
|
||||
'puzzle-gallery-detail': true,
|
||||
'puzzle-runtime': false,
|
||||
'custom-world-generating': false,
|
||||
'custom-world-result': false,
|
||||
} as const satisfies Record<SelectionStage, boolean>;
|
||||
|
||||
export function resolveSelectionStageAfterProtectedDataLoss(
|
||||
stage: SelectionStage,
|
||||
): SelectionStage {
|
||||
return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform';
|
||||
}
|
||||
|
||||
type SessionDraftRunState = {
|
||||
hasSession: boolean;
|
||||
hasSessionDraft: boolean;
|
||||
hasRun: boolean;
|
||||
};
|
||||
|
||||
type VisualNovelCreationState = {
|
||||
hasSession: boolean;
|
||||
hasSessionDraft: boolean;
|
||||
hasWork: boolean;
|
||||
hasWorkDraft: boolean;
|
||||
hasRun: boolean;
|
||||
};
|
||||
|
||||
type BabyObjectMatchCreationState = {
|
||||
hasDraft: boolean;
|
||||
hasFormPayload: boolean;
|
||||
};
|
||||
|
||||
export type MissingCreationStateParams = {
|
||||
stage: SelectionStage;
|
||||
bigFish: SessionDraftRunState;
|
||||
match3d: SessionDraftRunState;
|
||||
squareHole: SessionDraftRunState;
|
||||
visualNovel: VisualNovelCreationState;
|
||||
babyObjectMatch: BabyObjectMatchCreationState;
|
||||
};
|
||||
|
||||
export function resolveSelectionStageAfterMissingCreationState(
|
||||
params: MissingCreationStateParams,
|
||||
): SelectionStage {
|
||||
const { stage } = params;
|
||||
|
||||
if (stage === 'big-fish-result' && !params.bigFish.hasSessionDraft) {
|
||||
return params.bigFish.hasSession ? 'big-fish-agent-workspace' : 'platform';
|
||||
}
|
||||
if (stage === 'big-fish-runtime' && !params.bigFish.hasRun) {
|
||||
return params.bigFish.hasSessionDraft ? 'big-fish-result' : 'platform';
|
||||
}
|
||||
|
||||
if (stage === 'match3d-result' && !params.match3d.hasSessionDraft) {
|
||||
return params.match3d.hasSession ? 'match3d-agent-workspace' : 'platform';
|
||||
}
|
||||
if (stage === 'match3d-runtime' && !params.match3d.hasRun) {
|
||||
return params.match3d.hasSessionDraft ? 'match3d-result' : 'platform';
|
||||
}
|
||||
|
||||
if (stage === 'square-hole-result' && !params.squareHole.hasSessionDraft) {
|
||||
return params.squareHole.hasSession
|
||||
? 'square-hole-agent-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'square-hole-runtime' && !params.squareHole.hasRun) {
|
||||
return params.squareHole.hasSessionDraft
|
||||
? 'square-hole-result'
|
||||
: 'platform';
|
||||
}
|
||||
|
||||
if (
|
||||
stage === 'visual-novel-result' &&
|
||||
!params.visualNovel.hasSessionDraft &&
|
||||
!params.visualNovel.hasWorkDraft
|
||||
) {
|
||||
return params.visualNovel.hasSession
|
||||
? 'visual-novel-agent-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'visual-novel-runtime' && !params.visualNovel.hasRun) {
|
||||
return params.visualNovel.hasSessionDraft || params.visualNovel.hasWorkDraft
|
||||
? 'visual-novel-result'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'visual-novel-gallery-detail' && !params.visualNovel.hasWork) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
if (
|
||||
stage === 'baby-object-match-result' &&
|
||||
!params.babyObjectMatch.hasDraft
|
||||
) {
|
||||
return params.babyObjectMatch.hasFormPayload
|
||||
? 'baby-object-match-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (
|
||||
stage === 'baby-object-match-runtime' &&
|
||||
!params.babyObjectMatch.hasDraft
|
||||
) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
return stage;
|
||||
}
|
||||
@@ -307,12 +307,21 @@ const {
|
||||
amountDelta: -1,
|
||||
balanceAfter: 29,
|
||||
sourceType: 'asset_operation_consume',
|
||||
createdAt: '2026-05-03T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-2',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 30,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-05-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-3',
|
||||
amountDelta: 5,
|
||||
balanceAfter: 35,
|
||||
sourceType: 'puzzle_author_incentive_claim',
|
||||
createdAt: '2026-05-03T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})),
|
||||
@@ -1222,6 +1231,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
expect(screen.getByText('-1')).toBeTruthy();
|
||||
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
expect(screen.getByText('拼图作者奖励')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile recharge modal shows native qr code on desktop web by default', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildProfileDashboardPresentation,
|
||||
formatCompactPlayTime,
|
||||
formatDashboardCount,
|
||||
formatPlayedWorkId,
|
||||
formatPlayedWorkType,
|
||||
formatTotalPlayTimeHours,
|
||||
} from './rpgEntryProfileDashboardPresentation';
|
||||
|
||||
function buildDashboard(
|
||||
overrides: Partial<ProfileDashboardSummary> = {},
|
||||
): ProfileDashboardSummary {
|
||||
return {
|
||||
walletBalance: 12345,
|
||||
totalPlayTimeMs: 3_780_000,
|
||||
playedWorldCount: 7,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedWork(
|
||||
overrides: Partial<ProfilePlayedWorkSummary> = {},
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: 'rpg:world-1',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldType: 'custom-world',
|
||||
worldTitle: '星桥',
|
||||
worldSubtitle: '',
|
||||
firstPlayedAt: '2026-06-03T00:00:00.000Z',
|
||||
lastPlayedAt: '2026-06-03T01:00:00.000Z',
|
||||
lastObservedPlayTimeMs: 60_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('profile dashboard presentation formats compact counts', () => {
|
||||
expect(formatDashboardCount(-1)).toBe('0');
|
||||
expect(formatDashboardCount(9999.4)).toBe('9,999');
|
||||
expect(formatDashboardCount(12000)).toBe('1.2万');
|
||||
expect(formatDashboardCount(230000000)).toBe('2.3亿');
|
||||
});
|
||||
|
||||
test('profile dashboard presentation formats play time for cards and modal rows', () => {
|
||||
expect(formatTotalPlayTimeHours(0)).toBe('0小时');
|
||||
expect(formatTotalPlayTimeHours(3_780_000)).toBe('1.1小时');
|
||||
expect(formatCompactPlayTime(59_000)).toBe('0分');
|
||||
expect(formatCompactPlayTime(3_600_000)).toBe('1.0小时');
|
||||
expect(formatCompactPlayTime(3 * 24 * 60 * 60 * 1000)).toBe('3天');
|
||||
expect(formatCompactPlayTime(12 * 24 * 60 * 60 * 1000)).toBe('12天');
|
||||
});
|
||||
|
||||
test('profile dashboard presentation normalizes played work labels and ids', () => {
|
||||
expect(formatPlayedWorkType('match_3d')).toBe('抓鹅');
|
||||
expect(formatPlayedWorkType('square-hole')).toBe('方洞');
|
||||
expect(formatPlayedWorkType('big_fish')).toBe('大鱼');
|
||||
expect(formatPlayedWorkType('unknown')).toBe('RPG');
|
||||
expect(formatPlayedWorkId(buildPlayedWork({ profileId: ' ' }))).toBe(
|
||||
'rpg:world-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('profile dashboard presentation builds stat labels from dashboard summary', () => {
|
||||
expect(buildProfileDashboardPresentation(buildDashboard())).toEqual({
|
||||
playedWorkCount: 7,
|
||||
playedWorkCountLabel: '7个',
|
||||
totalPlayTimeLabel: '1.1小时',
|
||||
walletBalance: 12345,
|
||||
walletBalanceLabel: '1.2万',
|
||||
walletBalanceWithUnitLabel: '1.2万泥点',
|
||||
});
|
||||
|
||||
expect(buildProfileDashboardPresentation(null)).toEqual({
|
||||
playedWorkCount: 0,
|
||||
playedWorkCountLabel: '0个',
|
||||
totalPlayTimeLabel: '0小时',
|
||||
walletBalance: 0,
|
||||
walletBalanceLabel: '0',
|
||||
walletBalanceWithUnitLabel: '0泥点',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type ProfileDashboardPresentation = {
|
||||
playedWorkCount: number;
|
||||
playedWorkCountLabel: string;
|
||||
totalPlayTimeLabel: string;
|
||||
walletBalance: number;
|
||||
walletBalanceLabel: string;
|
||||
walletBalanceWithUnitLabel: string;
|
||||
};
|
||||
|
||||
export function formatCompactPlayTime(playTimeMs: number) {
|
||||
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
|
||||
const days = totalMinutes / 1440;
|
||||
|
||||
if (days >= 10) {
|
||||
return `${Math.floor(days)}天`;
|
||||
}
|
||||
if (days >= 1) {
|
||||
return `${days.toFixed(days >= 3 ? 0 : 1)}天`;
|
||||
}
|
||||
|
||||
const hours = totalMinutes / 60;
|
||||
if (hours >= 1) {
|
||||
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
|
||||
}
|
||||
|
||||
return `${Math.max(0, totalMinutes)}分`;
|
||||
}
|
||||
|
||||
// “累计游戏时长”卡片固定用小时口径,避免卡片在分钟 / 天之间跳变。
|
||||
export function formatTotalPlayTimeHours(playTimeMs: number) {
|
||||
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
|
||||
|
||||
return `${roundedHours.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 1,
|
||||
})}小时`;
|
||||
}
|
||||
|
||||
export function formatDashboardCount(value: number) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (normalizedValue >= 100000000) {
|
||||
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
|
||||
}
|
||||
if (normalizedValue >= 10000) {
|
||||
return `${(normalizedValue / 10000).toFixed(1)}万`;
|
||||
}
|
||||
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
// 玩法标签沿用首页既有外显口径,未知类型暂归入 RPG。
|
||||
export function formatPlayedWorkType(value: string | null | undefined) {
|
||||
const normalizedValue = (value ?? '').toLowerCase();
|
||||
if (normalizedValue === 'puzzle') {
|
||||
return '拼图';
|
||||
}
|
||||
if (normalizedValue === 'match3d' || normalizedValue === 'match_3d') {
|
||||
return '抓鹅';
|
||||
}
|
||||
if (normalizedValue === 'square-hole' || normalizedValue === 'square_hole') {
|
||||
return '方洞';
|
||||
}
|
||||
if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') {
|
||||
return '大鱼';
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
// 现有契约尚未下发公开作品码,“玩过”列表先沿用 profileId,再兜底 worldKey。
|
||||
export function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
|
||||
return work.profileId?.trim() || work.worldKey;
|
||||
}
|
||||
|
||||
export function buildProfileDashboardPresentation(
|
||||
dashboard: ProfileDashboardSummary | null,
|
||||
): ProfileDashboardPresentation {
|
||||
const walletBalance = dashboard?.walletBalance ?? 0;
|
||||
const walletBalanceLabel = formatDashboardCount(walletBalance);
|
||||
const playedWorkCount = dashboard?.playedWorldCount ?? 0;
|
||||
|
||||
return {
|
||||
playedWorkCount,
|
||||
playedWorkCountLabel: `${formatDashboardCount(playedWorkCount)}个`,
|
||||
totalPlayTimeLabel: formatTotalPlayTimeHours(
|
||||
dashboard?.totalPlayTimeMs ?? 0,
|
||||
),
|
||||
walletBalance,
|
||||
walletBalanceLabel,
|
||||
walletBalanceWithUnitLabel: `${walletBalanceLabel}泥点`,
|
||||
};
|
||||
}
|
||||
161
src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts
Normal file
161
src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildMembershipLabel,
|
||||
buildRechargeProductValueLabel,
|
||||
buildWalletLedgerPresentation,
|
||||
formatRechargePrice,
|
||||
formatWalletLedgerAmount,
|
||||
getWalletLedgerSourceLabel,
|
||||
} from './rpgEntryProfileFundsViewModel';
|
||||
|
||||
function buildLedgerEntry(
|
||||
overrides: Partial<ProfileWalletLedgerEntry> = {},
|
||||
): ProfileWalletLedgerEntry {
|
||||
return {
|
||||
id: 'ledger-1',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 80,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRechargeProduct(
|
||||
overrides: Partial<ProfileRechargeProduct> = {},
|
||||
): ProfileRechargeProduct {
|
||||
return {
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60泥点',
|
||||
tier: 'normal',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMembership(
|
||||
overrides: Partial<ProfileMembership> = {},
|
||||
): ProfileMembership {
|
||||
return {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('profile funds ViewModel formats ledger amount labels', () => {
|
||||
expect(formatWalletLedgerAmount(-1)).toBe('-1');
|
||||
expect(formatWalletLedgerAmount(0)).toBe('0');
|
||||
expect(formatWalletLedgerAmount(30)).toBe('+30');
|
||||
});
|
||||
|
||||
test('profile funds ViewModel resolves ledger source labels with raw fallback', () => {
|
||||
expect(getWalletLedgerSourceLabel('asset_operation_consume')).toBe(
|
||||
'资产操作消耗',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('puzzle_author_incentive_claim')).toBe(
|
||||
'拼图作者奖励',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
|
||||
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
|
||||
});
|
||||
|
||||
test('profile funds ViewModel builds wallet ledger presentation', () => {
|
||||
const incomeEntry = buildLedgerEntry({
|
||||
id: 'ledger-income',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 80,
|
||||
sourceType: 'puzzle_author_incentive_claim',
|
||||
});
|
||||
const outcomeEntry = buildLedgerEntry({
|
||||
id: 'ledger-outcome',
|
||||
amountDelta: -1,
|
||||
balanceAfter: 79,
|
||||
sourceType: 'asset_operation_consume',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWalletLedgerPresentation(
|
||||
{ entries: [incomeEntry, outcomeEntry] },
|
||||
12,
|
||||
),
|
||||
).toEqual({
|
||||
balance: 80,
|
||||
balanceLabel: '80泥点',
|
||||
entries: [
|
||||
{
|
||||
amountLabel: '+30',
|
||||
balanceLabel: '余额 80',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-income',
|
||||
isIncome: true,
|
||||
sourceLabel: '拼图作者奖励',
|
||||
},
|
||||
{
|
||||
amountLabel: '-1',
|
||||
balanceLabel: '余额 79',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-outcome',
|
||||
isIncome: false,
|
||||
sourceLabel: '资产操作消耗',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildWalletLedgerPresentation({ entries: [] }, 12)).toEqual({
|
||||
balance: 12,
|
||||
balanceLabel: '12泥点',
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('profile funds ViewModel formats recharge product and membership labels', () => {
|
||||
expect(formatRechargePrice(600)).toBe('¥6');
|
||||
expect(formatRechargePrice(650)).toBe('¥6.50');
|
||||
expect(buildRechargeProductValueLabel(buildRechargeProduct())).toBe(
|
||||
'60+60泥点',
|
||||
);
|
||||
expect(
|
||||
buildRechargeProductValueLabel(
|
||||
buildRechargeProduct({
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
}),
|
||||
),
|
||||
).toBe('30天');
|
||||
expect(buildMembershipLabel(buildMembership(), (value) => value)).toBe(
|
||||
'普通用户',
|
||||
);
|
||||
expect(
|
||||
buildMembershipLabel(
|
||||
buildMembership({ status: 'active', expiresAt: null }),
|
||||
(value) => value,
|
||||
),
|
||||
).toBe('会员已生效');
|
||||
expect(
|
||||
buildMembershipLabel(
|
||||
buildMembership({
|
||||
status: 'active',
|
||||
expiresAt: '2026-06-03T00:00:00.000Z',
|
||||
}),
|
||||
() => '06/03 08:00',
|
||||
),
|
||||
).toBe('会员至 06/03 08:00');
|
||||
});
|
||||
108
src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts
Normal file
108
src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
ProfileWalletLedgerResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
new_user_registration_reward: '注册赠送',
|
||||
points_recharge: '泥点充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
puzzle_author_incentive_claim: '拼图作者奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
|
||||
|
||||
export type ProfileWalletLedgerEntryPresentation = {
|
||||
amountLabel: string;
|
||||
balanceLabel: string;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
isIncome: boolean;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export type ProfileWalletLedgerPresentation = {
|
||||
balance: number;
|
||||
balanceLabel: string;
|
||||
entries: ProfileWalletLedgerEntryPresentation[];
|
||||
};
|
||||
|
||||
export function getWalletLedgerSourceLabel(
|
||||
sourceType: string | null | undefined,
|
||||
) {
|
||||
const normalizedSourceType = sourceType?.trim() ?? '';
|
||||
if (!normalizedSourceType) {
|
||||
return '未知来源';
|
||||
}
|
||||
|
||||
return (
|
||||
PROFILE_WALLET_LEDGER_SOURCE_LABELS[
|
||||
normalizedSourceType as ProfileWalletLedgerEntry['sourceType']
|
||||
] ?? normalizedSourceType
|
||||
);
|
||||
}
|
||||
|
||||
export function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
export function buildWalletLedgerEntryPresentation(
|
||||
entry: ProfileWalletLedgerEntry,
|
||||
): ProfileWalletLedgerEntryPresentation {
|
||||
return {
|
||||
amountLabel: formatWalletLedgerAmount(entry.amountDelta),
|
||||
balanceLabel: `余额 ${entry.balanceAfter}`,
|
||||
createdAt: entry.createdAt,
|
||||
id: entry.id,
|
||||
isIncome: entry.amountDelta > 0,
|
||||
sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWalletLedgerPresentation(
|
||||
ledger: ProfileWalletLedgerResponse | null,
|
||||
fallbackBalance: number,
|
||||
): ProfileWalletLedgerPresentation {
|
||||
const entries = ledger?.entries ?? [];
|
||||
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
|
||||
|
||||
return {
|
||||
balance,
|
||||
balanceLabel: `${balance}泥点`,
|
||||
entries: entries.map(buildWalletLedgerEntryPresentation),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
|
||||
if (product.kind === 'membership') {
|
||||
return `${product.durationDays}天`;
|
||||
}
|
||||
|
||||
return `${product.pointsAmount}${
|
||||
product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''
|
||||
}泥点`;
|
||||
}
|
||||
|
||||
export function buildMembershipLabel(
|
||||
membership: ProfileMembership | null | undefined,
|
||||
formatTime: (value: string) => string,
|
||||
) {
|
||||
if (membership?.status !== 'active') {
|
||||
return '普通用户';
|
||||
}
|
||||
|
||||
return membership.expiresAt
|
||||
? `会员至 ${formatTime(membership.expiresAt)}`
|
||||
: '会员已生效';
|
||||
}
|
||||
127
src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts
Normal file
127
src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProfileTaskCenterResponse,
|
||||
ProfileTaskItem,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildProfileTaskCardSummary,
|
||||
buildProfileTaskProgressLabel,
|
||||
getProfileTaskClaimButtonLabel,
|
||||
getProfileTaskStatusLabel,
|
||||
selectProfileTaskCardTask,
|
||||
selectProfileTaskCenterTasks,
|
||||
} from './rpgEntryProfileTaskViewModel';
|
||||
|
||||
function buildTask(
|
||||
overrides: Partial<ProfileTaskItem> = {},
|
||||
): ProfileTaskItem {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
title: '游玩一次',
|
||||
description: '完成一次游戏',
|
||||
eventKey: 'work_play_start',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 0,
|
||||
rewardPoints: 10,
|
||||
status: 'incomplete',
|
||||
dayKey: 20260603,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCenter(
|
||||
tasks: ProfileTaskItem[],
|
||||
): ProfileTaskCenterResponse {
|
||||
return {
|
||||
dayKey: 20260603,
|
||||
walletBalance: 12,
|
||||
tasks,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
test('profile task ViewModel selects one actionable task by status priority and original order', () => {
|
||||
const firstIncomplete = buildTask({
|
||||
taskId: 'incomplete-1',
|
||||
status: 'incomplete',
|
||||
});
|
||||
const secondIncomplete = buildTask({
|
||||
taskId: 'incomplete-2',
|
||||
status: 'incomplete',
|
||||
});
|
||||
const claimable = buildTask({
|
||||
taskId: 'claimable-1',
|
||||
status: 'claimable',
|
||||
});
|
||||
|
||||
expect(
|
||||
selectProfileTaskCenterTasks([
|
||||
firstIncomplete,
|
||||
secondIncomplete,
|
||||
claimable,
|
||||
]),
|
||||
).toEqual([claimable]);
|
||||
expect(selectProfileTaskCenterTasks([firstIncomplete, secondIncomplete])).toEqual(
|
||||
[firstIncomplete],
|
||||
);
|
||||
});
|
||||
|
||||
test('profile task ViewModel falls back from card task to claimed and enabled tasks', () => {
|
||||
const claimed = buildTask({ taskId: 'claimed-1', status: 'claimed' });
|
||||
const disabled = buildTask({ taskId: 'disabled-1', status: 'disabled' });
|
||||
const incomplete = buildTask({
|
||||
taskId: 'incomplete-1',
|
||||
status: 'incomplete',
|
||||
});
|
||||
|
||||
expect(selectProfileTaskCardTask([disabled, claimed])).toBe(claimed);
|
||||
expect(selectProfileTaskCardTask([disabled, incomplete])).toBe(incomplete);
|
||||
expect(selectProfileTaskCardTask([disabled])).toBeNull();
|
||||
});
|
||||
|
||||
test('profile task ViewModel builds card summary with reward fallback and clamped progress', () => {
|
||||
expect(buildProfileTaskCardSummary(null)).toEqual({
|
||||
actionLabel: '去完成',
|
||||
progressCount: 0,
|
||||
progressPercent: 0,
|
||||
rewardPoints: 10,
|
||||
threshold: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildProfileTaskCardSummary(
|
||||
buildCenter([
|
||||
buildTask({
|
||||
progressCount: 5,
|
||||
rewardPoints: 25,
|
||||
status: 'claimable',
|
||||
threshold: 3,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
actionLabel: '领取',
|
||||
progressCount: 3,
|
||||
progressPercent: 100,
|
||||
rewardPoints: 25,
|
||||
threshold: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('profile task ViewModel exposes task labels for the modal', () => {
|
||||
const task = buildTask({ progressCount: -1, threshold: 0 });
|
||||
|
||||
expect(getProfileTaskStatusLabel('claimable')).toBe('可领取');
|
||||
expect(buildProfileTaskProgressLabel(task)).toBe('0/1');
|
||||
expect(getProfileTaskClaimButtonLabel(task, true)).toBe('领取中');
|
||||
expect(getProfileTaskClaimButtonLabel(buildTask({ status: 'claimed' }), false)).toBe(
|
||||
'已领取',
|
||||
);
|
||||
expect(
|
||||
getProfileTaskClaimButtonLabel(buildTask({ status: 'claimable' }), false),
|
||||
).toBe('领取');
|
||||
});
|
||||
107
src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts
Normal file
107
src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
ProfileTaskCenterResponse,
|
||||
ProfileTaskItem,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
|
||||
ProfileTaskItem['status'],
|
||||
number
|
||||
> = {
|
||||
claimable: 2,
|
||||
incomplete: 1,
|
||||
disabled: 0,
|
||||
claimed: -1,
|
||||
};
|
||||
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
|
||||
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
|
||||
incomplete: '未完成',
|
||||
claimable: '可领取',
|
||||
claimed: '已领取',
|
||||
disabled: '已停用',
|
||||
};
|
||||
|
||||
export type ProfileTaskCardSummary = {
|
||||
actionLabel: string;
|
||||
progressCount: number;
|
||||
progressPercent: number;
|
||||
rewardPoints: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
return tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.filter(
|
||||
({ task }) =>
|
||||
task.status === 'claimable' || task.status === 'incomplete',
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
|
||||
left.index - right.index,
|
||||
)
|
||||
.slice(0, 1)
|
||||
.map(({ task }) => task);
|
||||
}
|
||||
|
||||
export function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
|
||||
return (
|
||||
selectProfileTaskCenterTasks(tasks)[0] ??
|
||||
tasks.find((task) => task.status === 'claimed') ??
|
||||
tasks.find((task) => task.status !== 'disabled') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getProfileTaskStatusLabel(status: ProfileTaskItem['status']) {
|
||||
return PROFILE_TASK_STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
export function buildProfileTaskProgressLabel(task: ProfileTaskItem) {
|
||||
const threshold = Math.max(1, task.threshold);
|
||||
const progressCount = Math.min(Math.max(0, task.progressCount), threshold);
|
||||
return `${progressCount}/${threshold}`;
|
||||
}
|
||||
|
||||
export function getProfileTaskClaimButtonLabel(
|
||||
task: ProfileTaskItem,
|
||||
isClaiming: boolean,
|
||||
) {
|
||||
if (isClaiming) {
|
||||
return '领取中';
|
||||
}
|
||||
|
||||
if (task.status === 'claimed') {
|
||||
return '已领取';
|
||||
}
|
||||
|
||||
return task.status === 'claimable' ? '领取' : '未完成';
|
||||
}
|
||||
|
||||
export function buildProfileTaskCardSummary(
|
||||
center: ProfileTaskCenterResponse | null,
|
||||
): ProfileTaskCardSummary {
|
||||
const task = selectProfileTaskCardTask(center?.tasks ?? []);
|
||||
const threshold = Math.max(1, task?.threshold ?? 1);
|
||||
const progressCount = Math.min(
|
||||
Math.max(0, task?.progressCount ?? 0),
|
||||
threshold,
|
||||
);
|
||||
const rewardPoints =
|
||||
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
|
||||
const actionLabel =
|
||||
task?.status === 'claimable'
|
||||
? '领取'
|
||||
: task?.status === 'claimed'
|
||||
? '已完成'
|
||||
: '去完成';
|
||||
|
||||
return {
|
||||
actionLabel,
|
||||
progressCount,
|
||||
progressPercent: Math.round((progressCount / threshold) * 100),
|
||||
rewardPoints,
|
||||
threshold,
|
||||
};
|
||||
}
|
||||
505
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts
Normal file
505
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildPlatformRankingEntries,
|
||||
buildPlatformRecommendFeedEntries,
|
||||
buildPublicCategoryGroups,
|
||||
buildPublicGalleryCardKey,
|
||||
dedupePlatformPublicGalleryEntries,
|
||||
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
|
||||
DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
|
||||
DEFAULT_PLATFORM_RANKING_TAB,
|
||||
filterPlatformWorkSearchResults,
|
||||
filterTodayPublishedEntries,
|
||||
getNextPlatformCategorySortMode,
|
||||
getPlatformCategoryKindFilter,
|
||||
getPlatformCategoryKindFilterOption,
|
||||
getPlatformCategoryPrimaryMetric,
|
||||
getPlatformCategorySortOption,
|
||||
getPlatformPublicEntries,
|
||||
getPlatformRankingMetric,
|
||||
getPlatformRankingMetricValue,
|
||||
getPlatformRankingTabConfig,
|
||||
matchesPlatformCategoryKindFilter,
|
||||
parsePlatformEntryTimestamp,
|
||||
PLATFORM_CATEGORY_KIND_FILTERS,
|
||||
PLATFORM_CATEGORY_SORT_OPTIONS,
|
||||
PLATFORM_RANKING_TABS,
|
||||
type PlatformCategoryKindFilter,
|
||||
type PlatformCategorySortMode,
|
||||
selectAdjacentPlatformRecommendEntry,
|
||||
selectPlatformRecommendFeedWindow,
|
||||
sortPlatformCategoryEntries,
|
||||
} from './rpgEntryPublicGalleryViewModel';
|
||||
import type {
|
||||
PlatformJumpHopGalleryCard,
|
||||
PlatformPuzzleGalleryCard,
|
||||
PlatformWoodenFishGalleryCard,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
function buildPuzzleEntry(
|
||||
overrides: Partial<PlatformPuzzleGalleryCard> = {},
|
||||
): PlatformPuzzleGalleryCard {
|
||||
return {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work',
|
||||
profileId: 'shared-profile',
|
||||
publicWorkCode: 'PZ-SHARED',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
worldName: '星桥拼图',
|
||||
subtitle: '拼图副标题',
|
||||
summaryText: '星桥机关摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['星桥', '机关'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopEntry(
|
||||
overrides: Partial<PlatformJumpHopGalleryCard> = {},
|
||||
): PlatformJumpHopGalleryCard {
|
||||
return {
|
||||
sourceType: 'jump-hop',
|
||||
workId: 'jump-hop-work',
|
||||
profileId: 'shared-profile',
|
||||
publicWorkCode: 'JH-SHARED',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '跳一跳作者',
|
||||
worldName: '星桥跳一跳',
|
||||
subtitle: '跳一跳副标题',
|
||||
summaryText: '跳一跳摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['跳跃'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-02T00:00:00.000Z',
|
||||
updatedAt: '2026-05-02T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishEntry(
|
||||
overrides: Partial<PlatformWoodenFishGalleryCard> = {},
|
||||
): PlatformWoodenFishGalleryCard {
|
||||
return {
|
||||
sourceType: 'wooden-fish',
|
||||
workId: 'wooden-fish-work',
|
||||
profileId: 'shared-profile',
|
||||
publicWorkCode: 'WF-SHARED',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '木鱼作者',
|
||||
worldName: '星桥木鱼',
|
||||
subtitle: '木鱼副标题',
|
||||
summaryText: '木鱼摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['敲木鱼'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-03T00:00:00.000Z',
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRpgEntry(
|
||||
overrides: Partial<CustomWorldGalleryCard> = {},
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'shared-profile',
|
||||
publicWorkCode: 'CW-SHARED',
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-04T00:00:00.000Z',
|
||||
updatedAt: '2026-05-04T00:00:00.000Z',
|
||||
authorDisplayName: 'RPG 作者',
|
||||
worldName: '星桥 RPG',
|
||||
subtitle: 'RPG 副标题',
|
||||
summaryText: 'RPG 摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('public gallery ViewModel keeps play kinds distinct in card keys', () => {
|
||||
expect(buildPublicGalleryCardKey(buildPuzzleEntry())).toBe(
|
||||
'puzzle:user-1:shared-profile',
|
||||
);
|
||||
expect(buildPublicGalleryCardKey(buildJumpHopEntry())).toBe(
|
||||
'jump-hop:user-1:shared-profile',
|
||||
);
|
||||
expect(buildPublicGalleryCardKey(buildWoodenFishEntry())).toBe(
|
||||
'wooden-fish:user-1:shared-profile',
|
||||
);
|
||||
expect(buildPublicGalleryCardKey(buildRpgEntry())).toBe(
|
||||
'rpg:user-1:shared-profile',
|
||||
);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel dedupes merged public entries by latest source', () => {
|
||||
const oldPuzzle = buildPuzzleEntry({
|
||||
worldName: '旧拼图',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
});
|
||||
const latestPuzzle = buildPuzzleEntry({
|
||||
worldName: '新拼图',
|
||||
updatedAt: '2026-05-02T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(getPlatformPublicEntries([oldPuzzle], [latestPuzzle])).toEqual([
|
||||
latestPuzzle,
|
||||
]);
|
||||
const categoryGroups = buildPublicCategoryGroups([oldPuzzle], [latestPuzzle]);
|
||||
|
||||
expect(categoryGroups.find((group) => group.tag === '星桥')).toEqual({
|
||||
tag: '星桥',
|
||||
entries: [latestPuzzle],
|
||||
});
|
||||
});
|
||||
|
||||
test('public gallery ViewModel builds recommend feed from general public entries', () => {
|
||||
const featuredPuzzle = buildPuzzleEntry({
|
||||
profileId: 'shared',
|
||||
worldName: '精选旧拼图',
|
||||
});
|
||||
const latestPuzzle = buildPuzzleEntry({
|
||||
profileId: 'shared',
|
||||
worldName: '最新拼图',
|
||||
});
|
||||
const edutainmentPuzzle = buildPuzzleEntry({
|
||||
profileId: 'edutainment',
|
||||
themeTags: ['寓教于乐'],
|
||||
});
|
||||
const jumpHopEntry = buildJumpHopEntry({ profileId: 'jump-hop' });
|
||||
|
||||
expect(
|
||||
buildPlatformRecommendFeedEntries(
|
||||
[featuredPuzzle, edutainmentPuzzle],
|
||||
[latestPuzzle, jumpHopEntry],
|
||||
),
|
||||
).toEqual([latestPuzzle, jumpHopEntry]);
|
||||
expect(
|
||||
dedupePlatformPublicGalleryEntries([featuredPuzzle, latestPuzzle]),
|
||||
).toEqual([latestPuzzle]);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel selects recommend feed window with wraparound neighbors', () => {
|
||||
const firstEntry = buildPuzzleEntry({ profileId: 'first' });
|
||||
const secondEntry = buildJumpHopEntry({ profileId: 'second' });
|
||||
const thirdEntry = buildWoodenFishEntry({ profileId: 'third' });
|
||||
const entries = [firstEntry, secondEntry, thirdEntry];
|
||||
|
||||
expect(selectPlatformRecommendFeedWindow([], 'missing')).toEqual({
|
||||
activeEntry: null,
|
||||
activeEntryKey: null,
|
||||
activeIndex: -1,
|
||||
nextEntry: null,
|
||||
previousEntry: null,
|
||||
});
|
||||
expect(selectPlatformRecommendFeedWindow([firstEntry], null)).toEqual({
|
||||
activeEntry: firstEntry,
|
||||
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
|
||||
activeIndex: 0,
|
||||
nextEntry: null,
|
||||
previousEntry: null,
|
||||
});
|
||||
expect(
|
||||
selectPlatformRecommendFeedWindow(
|
||||
entries,
|
||||
buildPublicGalleryCardKey(secondEntry),
|
||||
),
|
||||
).toEqual({
|
||||
activeEntry: secondEntry,
|
||||
activeEntryKey: buildPublicGalleryCardKey(secondEntry),
|
||||
activeIndex: 1,
|
||||
nextEntry: thirdEntry,
|
||||
previousEntry: firstEntry,
|
||||
});
|
||||
|
||||
expect(selectPlatformRecommendFeedWindow(entries, 'missing')).toEqual({
|
||||
activeEntry: firstEntry,
|
||||
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
|
||||
activeIndex: 0,
|
||||
nextEntry: secondEntry,
|
||||
previousEntry: thirdEntry,
|
||||
});
|
||||
expect(selectPlatformRecommendFeedWindow(entries, null)).toEqual({
|
||||
activeEntry: firstEntry,
|
||||
activeEntryKey: buildPublicGalleryCardKey(firstEntry),
|
||||
activeIndex: 0,
|
||||
nextEntry: secondEntry,
|
||||
previousEntry: thirdEntry,
|
||||
});
|
||||
});
|
||||
|
||||
test('public gallery ViewModel selects adjacent recommend entry without self-loop', () => {
|
||||
const onlyEntry = buildPuzzleEntry({ profileId: 'only' });
|
||||
const nextEntry = buildJumpHopEntry({ profileId: 'next' });
|
||||
|
||||
expect(
|
||||
selectAdjacentPlatformRecommendEntry([onlyEntry], 1, 'missing'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
selectAdjacentPlatformRecommendEntry(
|
||||
[onlyEntry, nextEntry],
|
||||
1,
|
||||
buildPublicGalleryCardKey(onlyEntry),
|
||||
),
|
||||
).toBe(nextEntry);
|
||||
expect(
|
||||
selectAdjacentPlatformRecommendEntry([onlyEntry, nextEntry], -1, 'missing'),
|
||||
).toBe(nextEntry);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel searches compact work codes and ranks name prefix first', () => {
|
||||
const nameMatch = buildPuzzleEntry({
|
||||
profileId: 'name-match',
|
||||
publicWorkCode: 'PZ-OLDER',
|
||||
worldName: '星桥拼图',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
});
|
||||
const codeMatch = buildPuzzleEntry({
|
||||
profileId: 'code-match',
|
||||
publicWorkCode: 'PZ-XING-QIAO',
|
||||
worldName: '海雾机关',
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
});
|
||||
const jumpHopCodeMatch = buildJumpHopEntry({
|
||||
profileId: 'jump-code-match',
|
||||
publicWorkCode: 'JH-XING-QIAO',
|
||||
worldName: '海雾跳跃',
|
||||
});
|
||||
const woodenFishCodeMatch = buildWoodenFishEntry({
|
||||
profileId: 'wooden-code-match',
|
||||
publicWorkCode: 'WF-DEEP-CALM',
|
||||
worldName: '静心木鱼',
|
||||
});
|
||||
|
||||
expect(filterPlatformWorkSearchResults([codeMatch, nameMatch], '星桥')).toEqual(
|
||||
[nameMatch, codeMatch],
|
||||
);
|
||||
expect(filterPlatformWorkSearchResults([codeMatch], 'pz xing_qiao')).toEqual([
|
||||
codeMatch,
|
||||
]);
|
||||
expect(
|
||||
filterPlatformWorkSearchResults([jumpHopCodeMatch], 'jh xing-qiao'),
|
||||
).toEqual([jumpHopCodeMatch]);
|
||||
expect(
|
||||
filterPlatformWorkSearchResults([woodenFishCodeMatch], 'wf deep_calm'),
|
||||
).toEqual([woodenFishCodeMatch]);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel keeps source kinds behind one category filter seam', () => {
|
||||
const jumpHopEntry = buildJumpHopEntry();
|
||||
const woodenFishEntry = buildWoodenFishEntry();
|
||||
const rpgEntry = buildRpgEntry();
|
||||
|
||||
expect(getPlatformCategoryKindFilter(jumpHopEntry)).toBe('jump-hop');
|
||||
expect(getPlatformCategoryKindFilter(woodenFishEntry)).toBe('wooden-fish');
|
||||
expect(getPlatformCategoryKindFilter(rpgEntry)).toBe('custom-world');
|
||||
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'jump-hop')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'wooden-fish')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'custom-world')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'custom-world')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel exposes category filter and sort option interface', () => {
|
||||
expect(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER).toBe('all');
|
||||
expect(DEFAULT_PLATFORM_CATEGORY_SORT_MODE).toBe('composite');
|
||||
expect(PLATFORM_CATEGORY_KIND_FILTERS.map((option) => option.label)).toEqual([
|
||||
'全部',
|
||||
'拼图',
|
||||
'抓鹅',
|
||||
'方洞',
|
||||
'视觉',
|
||||
'汪汪',
|
||||
'大鱼',
|
||||
'跳跃',
|
||||
'木鱼',
|
||||
'RPG',
|
||||
]);
|
||||
expect(PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => option.label)).toEqual([
|
||||
'综合',
|
||||
'最新',
|
||||
'游玩',
|
||||
'点赞',
|
||||
]);
|
||||
expect(getPlatformCategoryKindFilterOption('match3d')).toEqual({
|
||||
id: 'match3d',
|
||||
label: '抓鹅',
|
||||
});
|
||||
expect(getPlatformCategorySortOption('latest')).toEqual({
|
||||
id: 'latest',
|
||||
label: '最新',
|
||||
});
|
||||
expect(
|
||||
getPlatformCategoryKindFilterOption(
|
||||
'unknown' as PlatformCategoryKindFilter,
|
||||
),
|
||||
).toEqual({ id: 'all', label: '全部' });
|
||||
expect(
|
||||
getPlatformCategorySortOption('unknown' as PlatformCategorySortMode),
|
||||
).toEqual({ id: 'composite', label: '综合' });
|
||||
expect(getNextPlatformCategorySortMode('composite')).toBe('latest');
|
||||
expect(getNextPlatformCategorySortMode('latest')).toBe('play');
|
||||
expect(getNextPlatformCategorySortMode('play')).toBe('like');
|
||||
expect(getNextPlatformCategorySortMode('like')).toBe('composite');
|
||||
expect(
|
||||
getNextPlatformCategorySortMode('unknown' as PlatformCategorySortMode),
|
||||
).toBe('composite');
|
||||
});
|
||||
|
||||
test('public gallery ViewModel ranks entries by selected metric', () => {
|
||||
const playWinner = buildJumpHopEntry({
|
||||
profileId: 'play-winner',
|
||||
playCount: 100,
|
||||
remixCount: 1,
|
||||
likeCount: 1,
|
||||
recentPlayCount7d: 1,
|
||||
});
|
||||
const remixWinner = buildPuzzleEntry({
|
||||
profileId: 'remix-winner',
|
||||
playCount: 2,
|
||||
remixCount: 50,
|
||||
likeCount: 2,
|
||||
recentPlayCount7d: 2,
|
||||
});
|
||||
const recentWinner = buildPuzzleEntry({
|
||||
profileId: 'recent-winner',
|
||||
playCount: 3,
|
||||
remixCount: 3,
|
||||
likeCount: 3,
|
||||
recentPlayCount7d: 30,
|
||||
});
|
||||
const likeWinner = buildWoodenFishEntry({
|
||||
profileId: 'like-winner',
|
||||
playCount: 4,
|
||||
remixCount: 4,
|
||||
likeCount: 40,
|
||||
recentPlayCount7d: 4,
|
||||
});
|
||||
const entries = [recentWinner, remixWinner, likeWinner, playWinner];
|
||||
|
||||
expect(DEFAULT_PLATFORM_RANKING_TAB).toBe('hot');
|
||||
expect(PLATFORM_RANKING_TABS.map((tab) => tab.label)).toEqual([
|
||||
'热门榜',
|
||||
'改造榜',
|
||||
'新品榜',
|
||||
'点赞榜',
|
||||
]);
|
||||
expect(getPlatformRankingTabConfig('new')).toEqual({
|
||||
id: 'new',
|
||||
label: '新品榜',
|
||||
metricLabel: '近7日',
|
||||
emptyText: '近 7 日暂时还没有新品。',
|
||||
});
|
||||
expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner);
|
||||
expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner);
|
||||
expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner);
|
||||
expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner);
|
||||
expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40);
|
||||
expect(getPlatformRankingMetric(recentWinner, 'new')).toEqual({
|
||||
label: '近7日',
|
||||
value: 30,
|
||||
});
|
||||
expect(getPlatformRankingMetric(playWinner, 'hot')).toEqual({
|
||||
label: '游玩',
|
||||
value: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test('public gallery ViewModel sorts category entries and exposes primary metric', () => {
|
||||
const latestEntry = buildWoodenFishEntry({
|
||||
profileId: 'latest',
|
||||
playCount: 1,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-05-05T00:00:00.000Z',
|
||||
updatedAt: '2026-05-05T00:00:00.000Z',
|
||||
});
|
||||
const playEntry = buildJumpHopEntry({
|
||||
profileId: 'play',
|
||||
playCount: 100,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-05-03T00:00:00.000Z',
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
});
|
||||
const likeEntry = buildPuzzleEntry({
|
||||
profileId: 'like',
|
||||
playCount: 1,
|
||||
likeCount: 20,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-05-02T00:00:00.000Z',
|
||||
updatedAt: '2026-05-02T00:00:00.000Z',
|
||||
});
|
||||
const compositeEntry = buildPuzzleEntry({
|
||||
profileId: 'composite',
|
||||
playCount: 30,
|
||||
remixCount: 30,
|
||||
likeCount: 30,
|
||||
recentPlayCount7d: 30,
|
||||
publishedAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
});
|
||||
const entries = [likeEntry, latestEntry, compositeEntry, playEntry];
|
||||
|
||||
expect(sortPlatformCategoryEntries(entries, 'latest')[0]).toBe(latestEntry);
|
||||
expect(sortPlatformCategoryEntries(entries, 'play')[0]).toBe(playEntry);
|
||||
expect(sortPlatformCategoryEntries(entries, 'like')[0]).toBe(compositeEntry);
|
||||
expect(sortPlatformCategoryEntries(entries, 'composite')[0]).toBe(
|
||||
compositeEntry,
|
||||
);
|
||||
expect(getPlatformCategoryPrimaryMetric(likeEntry)).toEqual({
|
||||
label: '点赞',
|
||||
value: 20,
|
||||
});
|
||||
expect(
|
||||
getPlatformCategoryPrimaryMetric(
|
||||
buildPuzzleEntry({ likeCount: 0, recentPlayCount7d: 8, playCount: 2 }),
|
||||
),
|
||||
).toEqual({ label: '近7日', value: 8 });
|
||||
});
|
||||
|
||||
test('public gallery ViewModel filters entries published on the local day', () => {
|
||||
const now = new Date(2026, 5, 3, 12);
|
||||
const todayEntry = buildPuzzleEntry({
|
||||
profileId: 'today',
|
||||
publishedAt: new Date(2026, 5, 3, 8).toISOString(),
|
||||
});
|
||||
const yesterdayEntry = buildPuzzleEntry({
|
||||
profileId: 'yesterday',
|
||||
publishedAt: new Date(2026, 5, 2, 8).toISOString(),
|
||||
});
|
||||
const unpublishedEntry = buildPuzzleEntry({
|
||||
profileId: 'unpublished',
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
filterTodayPublishedEntries(
|
||||
[yesterdayEntry, todayEntry, unpublishedEntry],
|
||||
now,
|
||||
),
|
||||
).toEqual([todayEntry]);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel parses backend numeric timestamps', () => {
|
||||
expect(parsePlatformEntryTimestamp('1778457601.234567Z')).toBe(
|
||||
1778457601234.567,
|
||||
);
|
||||
});
|
||||
657
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts
Normal file
657
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
import { filterGeneralPublicWorks } from '../platform-entry/platformEdutainmentVisibility';
|
||||
import { getPlatformPublicGalleryEntryKey } from '../platform-entry/platformPublicGalleryFlow';
|
||||
import {
|
||||
buildPlatformWorldDisplayTags,
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
export type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
|
||||
export type PlatformRankingTabConfig = {
|
||||
emptyText: string;
|
||||
id: PlatformRankingTab;
|
||||
label: string;
|
||||
metricLabel: string;
|
||||
};
|
||||
export type PlatformRankingMetric = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
export type PlatformCategoryKindFilter =
|
||||
| 'all'
|
||||
| 'puzzle'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'visual-novel'
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish'
|
||||
| 'custom-world';
|
||||
export type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
|
||||
export type PlatformCategoryKindFilterOption = {
|
||||
id: PlatformCategoryKindFilter;
|
||||
label: string;
|
||||
};
|
||||
export type PlatformCategorySortOption = {
|
||||
id: PlatformCategorySortMode;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicCategoryGroup = {
|
||||
tag: string;
|
||||
entries: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
export const DEFAULT_PLATFORM_RANKING_TAB: PlatformRankingTab = 'hot';
|
||||
export const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER: PlatformCategoryKindFilter =
|
||||
'all';
|
||||
export const DEFAULT_PLATFORM_CATEGORY_SORT_MODE: PlatformCategorySortMode =
|
||||
'composite';
|
||||
|
||||
export const PLATFORM_RANKING_TABS: PlatformRankingTabConfig[] = [
|
||||
{
|
||||
id: 'hot',
|
||||
label: '热门榜',
|
||||
metricLabel: '游玩',
|
||||
emptyText: '公开广场暂时还没有热门作品。',
|
||||
},
|
||||
{
|
||||
id: 'remix',
|
||||
label: '改造榜',
|
||||
metricLabel: '改造',
|
||||
emptyText: '公开广场暂时还没有改造作品。',
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
label: '新品榜',
|
||||
metricLabel: '近7日',
|
||||
emptyText: '近 7 日暂时还没有新品。',
|
||||
},
|
||||
{
|
||||
id: 'like',
|
||||
label: '点赞榜',
|
||||
metricLabel: '点赞',
|
||||
emptyText: '公开广场暂时还没有点赞作品。',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_PLATFORM_RANKING_CONFIG =
|
||||
PLATFORM_RANKING_TABS.find(
|
||||
(config) => config.id === DEFAULT_PLATFORM_RANKING_TAB,
|
||||
) ?? PLATFORM_RANKING_TABS[0]!;
|
||||
|
||||
export const PLATFORM_CATEGORY_KIND_FILTERS: PlatformCategoryKindFilterOption[] =
|
||||
[
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'puzzle', label: '拼图' },
|
||||
{ id: 'match3d', label: '抓鹅' },
|
||||
{ id: 'square-hole', label: '方洞' },
|
||||
{ id: 'visual-novel', label: '视觉' },
|
||||
{ id: 'bark-battle', label: '汪汪' },
|
||||
{ id: 'big-fish', label: '大鱼' },
|
||||
{ id: 'jump-hop', label: '跳跃' },
|
||||
{ id: 'wooden-fish', label: '木鱼' },
|
||||
{ id: 'custom-world', label: 'RPG' },
|
||||
];
|
||||
|
||||
export const PLATFORM_CATEGORY_SORT_OPTIONS: PlatformCategorySortOption[] = [
|
||||
{ id: 'composite', label: '综合' },
|
||||
{ id: 'latest', label: '最新' },
|
||||
{ id: 'play', label: '游玩' },
|
||||
{ id: 'like', label: '点赞' },
|
||||
];
|
||||
|
||||
const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION =
|
||||
PLATFORM_CATEGORY_KIND_FILTERS.find(
|
||||
(option) => option.id === DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
|
||||
) ?? PLATFORM_CATEGORY_KIND_FILTERS[0]!;
|
||||
const DEFAULT_PLATFORM_CATEGORY_SORT_OPTION =
|
||||
PLATFORM_CATEGORY_SORT_OPTIONS.find(
|
||||
(option) => option.id === DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
|
||||
) ?? PLATFORM_CATEGORY_SORT_OPTIONS[0]!;
|
||||
|
||||
export type PlatformRecommendFeedWindow = {
|
||||
activeEntry: PlatformPublicGalleryCard | null;
|
||||
activeEntryKey: string | null;
|
||||
activeIndex: number;
|
||||
nextEntry: PlatformPublicGalleryCard | null;
|
||||
previousEntry: PlatformPublicGalleryCard | null;
|
||||
};
|
||||
|
||||
export function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
return getPlatformPublicGalleryEntryKey(entry);
|
||||
}
|
||||
|
||||
export function dedupePlatformPublicGalleryEntries(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
entries.forEach((entry) => {
|
||||
entryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values());
|
||||
}
|
||||
|
||||
export function buildPlatformRecommendFeedEntries(
|
||||
featuredEntries: PlatformPublicGalleryCard[],
|
||||
latestEntries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
return dedupePlatformPublicGalleryEntries(
|
||||
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]),
|
||||
);
|
||||
}
|
||||
|
||||
export function selectAdjacentPlatformRecommendEntry(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
direction: 1 | -1,
|
||||
baseEntryKey?: string | null,
|
||||
) {
|
||||
if (entries.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedBaseEntryKey = baseEntryKey?.trim() ?? '';
|
||||
const activeIndex = normalizedBaseEntryKey
|
||||
? entries.findIndex(
|
||||
(entry) => buildPublicGalleryCardKey(entry) === normalizedBaseEntryKey,
|
||||
)
|
||||
: -1;
|
||||
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
|
||||
const nextIndex =
|
||||
(baseIndex + direction + entries.length) % entries.length;
|
||||
const nextEntry = entries[nextIndex] ?? null;
|
||||
if (
|
||||
nextEntry &&
|
||||
normalizedBaseEntryKey &&
|
||||
buildPublicGalleryCardKey(nextEntry) === normalizedBaseEntryKey
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
export function selectPlatformRecommendFeedWindow(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
activeEntryKey?: string | null,
|
||||
): PlatformRecommendFeedWindow {
|
||||
const normalizedActiveEntryKey = activeEntryKey?.trim() ?? '';
|
||||
const activeEntry =
|
||||
(normalizedActiveEntryKey
|
||||
? entries.find(
|
||||
(entry) =>
|
||||
buildPublicGalleryCardKey(entry) === normalizedActiveEntryKey,
|
||||
)
|
||||
: null) ??
|
||||
entries[0] ??
|
||||
null;
|
||||
const selectedActiveEntryKey = activeEntry
|
||||
? buildPublicGalleryCardKey(activeEntry)
|
||||
: null;
|
||||
const activeIndex = selectedActiveEntryKey
|
||||
? entries.findIndex(
|
||||
(entry) => buildPublicGalleryCardKey(entry) === selectedActiveEntryKey,
|
||||
)
|
||||
: -1;
|
||||
|
||||
return {
|
||||
activeEntry,
|
||||
activeEntryKey: selectedActiveEntryKey,
|
||||
activeIndex,
|
||||
nextEntry: selectAdjacentPlatformRecommendEntry(
|
||||
entries,
|
||||
1,
|
||||
selectedActiveEntryKey,
|
||||
),
|
||||
previousEntry: selectAdjacentPlatformRecommendEntry(
|
||||
entries,
|
||||
-1,
|
||||
selectedActiveEntryKey,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPublicCategoryGroups(
|
||||
featuredEntries: PlatformPublicGalleryCard[],
|
||||
latestEntries: PlatformPublicGalleryCard[],
|
||||
): PlatformPublicCategoryGroup[] {
|
||||
const publicEntries = buildPlatformRecommendFeedEntries(
|
||||
featuredEntries,
|
||||
latestEntries,
|
||||
);
|
||||
|
||||
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
|
||||
publicEntries.forEach((entry) => {
|
||||
const tags = buildPlatformWorldDisplayTags(entry, 3);
|
||||
const normalizedTags = tags.length > 0 ? tags : ['回响'];
|
||||
|
||||
normalizedTags.forEach((tag) => {
|
||||
const entries = categoryMap.get(tag) ?? [];
|
||||
entries.push(entry);
|
||||
categoryMap.set(tag, entries);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([tag, entries]) => ({ tag, entries }))
|
||||
.sort((left, right) => {
|
||||
if (right.entries.length !== left.entries.length) {
|
||||
return right.entries.length - left.entries.length;
|
||||
}
|
||||
|
||||
return left.tag.localeCompare(right.tag, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlatformPublicEntries(
|
||||
featuredEntries: PlatformPublicGalleryCard[],
|
||||
latestEntries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
return buildPlatformRecommendFeedEntries(featuredEntries, latestEntries);
|
||||
}
|
||||
|
||||
export function getAllPlatformPublicEntries(
|
||||
featuredEntries: PlatformPublicGalleryCard[],
|
||||
latestEntries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
return dedupePlatformPublicGalleryEntries([
|
||||
...featuredEntries,
|
||||
...latestEntries,
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizePlatformSearchText(value: string | null | undefined) {
|
||||
return (value ?? '').trim().toLocaleLowerCase('zh-CN');
|
||||
}
|
||||
|
||||
function normalizePlatformCompactSearchText(value: string | null | undefined) {
|
||||
return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
|
||||
}
|
||||
|
||||
export function getPlatformSearchableWorkIds(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const ids = [entry.publicWorkCode, entry.profileId];
|
||||
if ('workId' in entry) {
|
||||
ids.push(entry.workId);
|
||||
}
|
||||
|
||||
return ids.filter((value): value is string => Boolean(value?.trim()));
|
||||
}
|
||||
|
||||
function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) {
|
||||
return [
|
||||
...getPlatformSearchableWorkIds(entry),
|
||||
entry.worldName,
|
||||
entry.authorDisplayName,
|
||||
entry.summaryText,
|
||||
entry.subtitle,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function matchesPlatformWorkSearch(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
keyword: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePlatformSearchText(keyword);
|
||||
const compactKeyword = normalizePlatformCompactSearchText(keyword);
|
||||
if (!normalizedKeyword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedSearchText = normalizePlatformSearchText(
|
||||
buildPlatformWorkSearchText(entry),
|
||||
);
|
||||
if (normalizedSearchText.includes(normalizedKeyword)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Boolean(compactKeyword) &&
|
||||
normalizePlatformCompactSearchText(
|
||||
buildPlatformWorkSearchText(entry),
|
||||
).includes(compactKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterPlatformWorkSearchResults(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
keyword: string,
|
||||
) {
|
||||
return entries
|
||||
.filter((entry) => matchesPlatformWorkSearch(entry, keyword))
|
||||
.sort((left, right) => {
|
||||
const leftCode = getPlatformSearchableWorkIds(left)[0] ?? '';
|
||||
const rightCode = getPlatformSearchableWorkIds(right)[0] ?? '';
|
||||
const normalizedKeyword = normalizePlatformSearchText(keyword);
|
||||
const leftNameStarts = normalizePlatformSearchText(
|
||||
left.worldName,
|
||||
).startsWith(normalizedKeyword);
|
||||
const rightNameStarts = normalizePlatformSearchText(
|
||||
right.worldName,
|
||||
).startsWith(normalizedKeyword);
|
||||
if (leftNameStarts !== rightNameStarts) {
|
||||
return leftNameStarts ? -1 : 1;
|
||||
}
|
||||
|
||||
const compactKeyword = normalizePlatformCompactSearchText(keyword);
|
||||
const leftCodeStarts =
|
||||
normalizePlatformCompactSearchText(leftCode).startsWith(compactKeyword);
|
||||
const rightCodeStarts =
|
||||
normalizePlatformCompactSearchText(rightCode).startsWith(
|
||||
compactKeyword,
|
||||
);
|
||||
if (leftCodeStarts !== rightCodeStarts) {
|
||||
return leftCodeStarts ? -1 : 1;
|
||||
}
|
||||
|
||||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||||
});
|
||||
}
|
||||
|
||||
export function isExactPublicWorkCodeSearch(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
keyword: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePlatformSearchText(keyword);
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
Boolean(entry.publicWorkCode?.trim()) &&
|
||||
normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
return parsePlatformEntryTimestamp(rawTime);
|
||||
}
|
||||
|
||||
function isSameLocalCalendarDay(left: Date, right: Date) {
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function isPlatformEntryPublishedToday(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
now = new Date(),
|
||||
) {
|
||||
const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt);
|
||||
if (publishedAtTimestamp <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isSameLocalCalendarDay(new Date(publishedAtTimestamp), now);
|
||||
}
|
||||
|
||||
export function filterTodayPublishedEntries(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
now = new Date(),
|
||||
) {
|
||||
return entries.filter((entry) => isPlatformEntryPublishedToday(entry, now));
|
||||
}
|
||||
|
||||
export function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
|
||||
return Math.max(0, Math.round(('likeCount' in entry && entry.likeCount) || 0));
|
||||
}
|
||||
|
||||
export function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) {
|
||||
return Math.max(0, Math.round(('playCount' in entry && entry.playCount) || 0));
|
||||
}
|
||||
|
||||
export function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(('remixCount' in entry && entry.remixCount) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntriesByMetric(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
getMetric: (entry: PlatformPublicGalleryCard) => number,
|
||||
) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const metricDiff = getMetric(right) - getMetric(left);
|
||||
if (metricDiff !== 0) {
|
||||
return metricDiff;
|
||||
}
|
||||
|
||||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPlatformRankingEntries(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
tab: PlatformRankingTab,
|
||||
) {
|
||||
if (tab === 'hot') {
|
||||
return sortEntriesByMetric(entries, getPlatformWorldPlayCount);
|
||||
}
|
||||
|
||||
if (tab === 'remix') {
|
||||
return sortEntriesByMetric(entries, getPlatformWorldRemixCount);
|
||||
}
|
||||
|
||||
if (tab === 'like') {
|
||||
return sortEntriesByMetric(entries, getPlatformWorldLikeCount);
|
||||
}
|
||||
|
||||
return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount);
|
||||
}
|
||||
|
||||
export function getPlatformRankingTabConfig(
|
||||
tab: PlatformRankingTab,
|
||||
): PlatformRankingTabConfig {
|
||||
return (
|
||||
PLATFORM_RANKING_TABS.find((config) => config.id === tab) ??
|
||||
DEFAULT_PLATFORM_RANKING_CONFIG
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformRankingMetricValue(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
tab: PlatformRankingTab,
|
||||
) {
|
||||
if (tab === 'remix') {
|
||||
return getPlatformWorldRemixCount(entry);
|
||||
}
|
||||
|
||||
if (tab === 'like') {
|
||||
return getPlatformWorldLikeCount(entry);
|
||||
}
|
||||
|
||||
if (tab === 'new') {
|
||||
return getPlatformWorldRecentPlayCount(entry);
|
||||
}
|
||||
|
||||
return getPlatformWorldPlayCount(entry);
|
||||
}
|
||||
|
||||
export function getPlatformRankingMetric(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
tab: PlatformRankingTab,
|
||||
): PlatformRankingMetric {
|
||||
return {
|
||||
label: getPlatformRankingTabConfig(tab).metricLabel,
|
||||
value: getPlatformRankingMetricValue(entry, tab),
|
||||
};
|
||||
}
|
||||
|
||||
function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
|
||||
// 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。
|
||||
return (
|
||||
getPlatformWorldPlayCount(entry) +
|
||||
getPlatformWorldRemixCount(entry) +
|
||||
getPlatformWorldLikeCount(entry) +
|
||||
getPlatformWorldRecentPlayCount(entry)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformCategoryKindFilter(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): Exclude<PlatformCategoryKindFilter, 'all'> {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return 'jump-hop';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return 'wooden-fish';
|
||||
}
|
||||
|
||||
return 'custom-world';
|
||||
}
|
||||
|
||||
export function matchesPlatformCategoryKindFilter(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
kindFilter: PlatformCategoryKindFilter,
|
||||
) {
|
||||
return (
|
||||
kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter
|
||||
);
|
||||
}
|
||||
|
||||
export function sortPlatformCategoryEntries(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
sortMode: PlatformCategorySortMode,
|
||||
) {
|
||||
return [...entries].sort((left, right) => {
|
||||
if (sortMode === 'latest') {
|
||||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||||
}
|
||||
|
||||
const metricDiff =
|
||||
sortMode === 'play'
|
||||
? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left)
|
||||
: sortMode === 'like'
|
||||
? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left)
|
||||
: getPlatformCategoryCompositeScore(right) -
|
||||
getPlatformCategoryCompositeScore(left);
|
||||
|
||||
if (metricDiff !== 0) {
|
||||
return metricDiff;
|
||||
}
|
||||
|
||||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlatformCategoryPrimaryMetric(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
if (likeCount > 0) {
|
||||
return { label: '点赞', value: likeCount };
|
||||
}
|
||||
|
||||
const recentPlayCount = getPlatformWorldRecentPlayCount(entry);
|
||||
if (recentPlayCount > 0) {
|
||||
return { label: '近7日', value: recentPlayCount };
|
||||
}
|
||||
|
||||
return { label: '游玩', value: getPlatformWorldPlayCount(entry) };
|
||||
}
|
||||
|
||||
export function getPlatformCategoryKindFilterOption(
|
||||
kindFilter: PlatformCategoryKindFilter,
|
||||
): PlatformCategoryKindFilterOption {
|
||||
return (
|
||||
PLATFORM_CATEGORY_KIND_FILTERS.find((option) => option.id === kindFilter) ??
|
||||
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformCategorySortOption(
|
||||
sortMode: PlatformCategorySortMode,
|
||||
): PlatformCategorySortOption {
|
||||
return (
|
||||
PLATFORM_CATEGORY_SORT_OPTIONS.find((option) => option.id === sortMode) ??
|
||||
DEFAULT_PLATFORM_CATEGORY_SORT_OPTION
|
||||
);
|
||||
}
|
||||
|
||||
export function getNextPlatformCategorySortMode(
|
||||
sortMode: PlatformCategorySortMode,
|
||||
): PlatformCategorySortMode {
|
||||
const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex(
|
||||
(option) => option.id === sortMode,
|
||||
);
|
||||
const nextIndex =
|
||||
currentIndex >= 0
|
||||
? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ??
|
||||
DEFAULT_PLATFORM_CATEGORY_SORT_MODE
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePlatformEntryTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||||
if (numericTimestamp?.[1]) {
|
||||
const rawTimestamp = Number(numericTimestamp[1]);
|
||||
if (Number.isFinite(rawTimestamp)) {
|
||||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||||
const timestampMs =
|
||||
absoluteTimestamp >= 1_000_000_000_000_000
|
||||
? rawTimestamp / 1000
|
||||
: absoluteTimestamp >= 1_000_000_000_000
|
||||
? rawTimestamp
|
||||
: absoluteTimestamp >= 1_000_000_000
|
||||
? rawTimestamp * 1000
|
||||
: Number.NaN;
|
||||
return Number.isNaN(timestampMs) ? 0 : timestampMs;
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date(normalized).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRecommendShareText,
|
||||
buildRecommendSwipeRailClassName,
|
||||
clampRecommendDragOffset,
|
||||
hasRecommendDragStarted,
|
||||
resolveRecommendCommitOffset,
|
||||
resolveRecommendDragCommitDirection,
|
||||
shouldAnimateRecommendSwipe,
|
||||
} from './rpgEntryRecommendSwipeDeckModel';
|
||||
import type { PlatformPuzzleGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
describe('rpgEntryRecommendSwipeDeckModel', () => {
|
||||
test('detects drag start and clamps offset to the card stage', () => {
|
||||
expect(hasRecommendDragStarted(17)).toBe(false);
|
||||
expect(hasRecommendDragStarted(18)).toBe(true);
|
||||
expect(clampRecommendDragOffset(240, 120)).toBe(120);
|
||||
expect(clampRecommendDragOffset(-240, 120)).toBe(-120);
|
||||
expect(clampRecommendDragOffset(240, 0)).toBe(160);
|
||||
});
|
||||
|
||||
test('resolves commit direction and commit offset', () => {
|
||||
expect(resolveRecommendDragCommitDirection(35)).toBeNull();
|
||||
expect(resolveRecommendDragCommitDirection(-36)).toBe(1);
|
||||
expect(resolveRecommendDragCommitDirection(36)).toBe(-1);
|
||||
expect(resolveRecommendCommitOffset(1, 320, 720)).toBe(-320);
|
||||
expect(resolveRecommendCommitOffset(-1, 0, 720)).toBe(720);
|
||||
});
|
||||
|
||||
test('builds rail class and animation guard state', () => {
|
||||
expect(
|
||||
buildRecommendSwipeRailClassName({ offsetY: 0, commitDirection: null }),
|
||||
).toBe('platform-recommend-swipe-rail--settled');
|
||||
expect(
|
||||
buildRecommendSwipeRailClassName({ offsetY: 24, commitDirection: null }),
|
||||
).toBe('platform-recommend-swipe-rail--dragging');
|
||||
expect(
|
||||
buildRecommendSwipeRailClassName({ offsetY: -320, commitDirection: 1 }),
|
||||
).toBe('platform-recommend-swipe-rail--committing');
|
||||
|
||||
expect(
|
||||
shouldAnimateRecommendSwipe({
|
||||
isAuthenticated: true,
|
||||
hasActiveEntry: true,
|
||||
entryCount: 2,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAnimateRecommendSwipe({
|
||||
isAuthenticated: true,
|
||||
hasActiveEntry: true,
|
||||
entryCount: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('builds recommend share text from public work identity', () => {
|
||||
expect(
|
||||
buildRecommendShareText({
|
||||
entry: buildPuzzleEntry(),
|
||||
publicWorkCode: 'PZ-OCEAN',
|
||||
detailUrl: 'https://example.test/works/detail?work=PZ-OCEAN',
|
||||
}),
|
||||
).toBe(
|
||||
'邀请你来玩《潮汐拼图》\n作品号:PZ-OCEAN\nhttps://example.test/works/detail?work=PZ-OCEAN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildPuzzleEntry(): PlatformPuzzleGalleryCard {
|
||||
return {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
publicWorkCode: 'PZ-OCEAN',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
worldName: '潮汐拼图',
|
||||
subtitle: '潮汐副标题',
|
||||
summaryText: '潮汐摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['海潮'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-03T08:00:00.000Z',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
};
|
||||
}
|
||||
75
src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts
Normal file
75
src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
export const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||
export const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||
export const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
|
||||
export type RecommendSwipeDirection = 1 | -1;
|
||||
|
||||
export type RecommendSwipeRailState = {
|
||||
offsetY: number;
|
||||
commitDirection: RecommendSwipeDirection | null;
|
||||
};
|
||||
|
||||
/** 收口推荐卡纵向滑动的纯判定,页面只保留 pointer 与动画副作用。 */
|
||||
export function hasRecommendDragStarted(deltaY: number) {
|
||||
return Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
|
||||
}
|
||||
|
||||
export function clampRecommendDragOffset(
|
||||
deltaY: number,
|
||||
stageHeight: number,
|
||||
) {
|
||||
const dragLimit =
|
||||
stageHeight > 0 ? stageHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
|
||||
return Math.max(-dragLimit, Math.min(dragLimit, deltaY));
|
||||
}
|
||||
|
||||
export function resolveRecommendDragCommitDirection(
|
||||
deltaY: number,
|
||||
): RecommendSwipeDirection | null {
|
||||
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return deltaY < 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
export function resolveRecommendCommitOffset(
|
||||
direction: RecommendSwipeDirection,
|
||||
stageHeight: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const commitDistance = stageHeight > 0 ? stageHeight : viewportHeight;
|
||||
return direction === 1 ? -commitDistance : commitDistance;
|
||||
}
|
||||
|
||||
export function buildRecommendSwipeRailClassName(
|
||||
state: RecommendSwipeRailState,
|
||||
) {
|
||||
if (state.commitDirection) {
|
||||
return 'platform-recommend-swipe-rail--committing';
|
||||
}
|
||||
|
||||
return state.offsetY === 0
|
||||
? 'platform-recommend-swipe-rail--settled'
|
||||
: 'platform-recommend-swipe-rail--dragging';
|
||||
}
|
||||
|
||||
export function shouldAnimateRecommendSwipe(params: {
|
||||
isAuthenticated: boolean;
|
||||
hasActiveEntry: boolean;
|
||||
entryCount: number;
|
||||
}) {
|
||||
return (
|
||||
params.isAuthenticated && params.hasActiveEntry && params.entryCount > 1
|
||||
);
|
||||
}
|
||||
|
||||
export function buildRecommendShareText(params: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
publicWorkCode: string;
|
||||
detailUrl: string;
|
||||
}) {
|
||||
return `邀请你来玩《${params.entry.worldName}》\n作品号:${params.publicWorkCode}\n${params.detailUrl}`;
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { expect, test } from 'vitest';
|
||||
import {
|
||||
buildPlatformWorldDisplayTags,
|
||||
buildPuzzleWorkCoverSlides,
|
||||
describePlatformPublicWorkKind,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
formatPlatformCompactCount,
|
||||
formatPlatformPublicAuthorAvatarLabel,
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
@@ -18,10 +21,13 @@ import {
|
||||
mapPuzzleClearWorkToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
mapWoodenFishWorkToPlatformGalleryCard,
|
||||
type PlatformBarkBattleGalleryCard,
|
||||
type PlatformBigFishGalleryCard,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkAuthorLookup,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
@@ -55,6 +61,48 @@ test('platform work display text limits names and tags by character count', () =
|
||||
).toEqual(['超长机关', '星桥']);
|
||||
});
|
||||
|
||||
test('platform public work presentation formats compact counts and kind labels', () => {
|
||||
const puzzleCard: PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work-kind',
|
||||
profileId: 'puzzle-profile-kind',
|
||||
publicWorkCode: 'PZ-KIND',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '机关拼图',
|
||||
subtitle: '拼图关卡',
|
||||
summaryText: '公开作品',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['拼图'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-18T00:00:00.000Z',
|
||||
updatedAt: '2026-05-18T00:00:00.000Z',
|
||||
};
|
||||
const bigFishCard: PlatformBigFishGalleryCard = {
|
||||
sourceType: 'big-fish',
|
||||
workId: 'big-fish-work-kind',
|
||||
profileId: 'big-fish-profile-kind',
|
||||
publicWorkCode: 'BF-KIND',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '大鱼海湾',
|
||||
subtitle: '大鱼关卡',
|
||||
summaryText: '公开作品',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['大鱼'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-18T00:00:00.000Z',
|
||||
updatedAt: '2026-05-18T00:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(formatPlatformCompactCount(-1)).toBe('0');
|
||||
expect(formatPlatformCompactCount(9999)).toBe('9999');
|
||||
expect(formatPlatformCompactCount(10000)).toBe('1.0万');
|
||||
expect(formatPlatformCompactCount(100000000)).toBe('1.0亿');
|
||||
expect(describePlatformPublicWorkKind(puzzleCard)).toBe('拼图');
|
||||
expect(describePlatformPublicWorkKind(bigFishCard)).toBe('大鱼吃小');
|
||||
});
|
||||
|
||||
test('platform public cards use play type reference images as cover fallback', () => {
|
||||
const puzzleCard: PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle',
|
||||
@@ -308,6 +356,57 @@ test('public work author display keeps phone masks and hides bare public user co
|
||||
);
|
||||
});
|
||||
|
||||
test('public work author lookup keeps public user code priority and avatar labels', () => {
|
||||
const barkBattleCard: PlatformBarkBattleGalleryCard = {
|
||||
sourceType: 'bark-battle',
|
||||
workId: 'bark-battle-work-author',
|
||||
profileId: 'bark-battle-profile-author',
|
||||
sourceSessionId: null,
|
||||
publicWorkCode: 'BB-AUTHOR',
|
||||
ownerUserId: 'user-author-id',
|
||||
authorPublicUserCode: ' SY-00012345 ',
|
||||
authorDisplayName: '声浪玩家',
|
||||
worldName: '声浪擂台',
|
||||
subtitle: '汪汪声浪',
|
||||
summaryText: '公开作品',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
themeTags: ['声浪'],
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-22T00:00:00.000Z',
|
||||
updatedAt: '2026-05-22T00:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(resolvePlatformPublicWorkAuthorLookup(barkBattleCard)).toEqual({
|
||||
key: 'code:SY-00012345',
|
||||
source: 'publicUserCode',
|
||||
value: 'SY-00012345',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkAuthorLookup({
|
||||
...barkBattleCard,
|
||||
authorPublicUserCode: ' ',
|
||||
}),
|
||||
).toEqual({
|
||||
key: 'id:user-author-id',
|
||||
source: 'ownerUserId',
|
||||
value: 'user-author-id',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkAuthorLookup({
|
||||
...barkBattleCard,
|
||||
authorPublicUserCode: null,
|
||||
ownerUserId: ' ',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(formatPlatformPublicAuthorAvatarLabel(' 声浪玩家')).toBe('声');
|
||||
expect(formatPlatformPublicAuthorAvatarLabel('')).toBe('玩');
|
||||
});
|
||||
|
||||
test('keeps baby object match public card code and template label intact', () => {
|
||||
const card: PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
@@ -331,6 +331,12 @@ export type PlatformPublicGalleryCard =
|
||||
| PlatformBarkBattleGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export type PlatformPublicWorkAuthorLookup = {
|
||||
key: string;
|
||||
source: 'publicUserCode' | 'ownerUserId';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
@@ -991,6 +997,52 @@ export function formatPlatformWorkDisplayTags(
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
export function formatPlatformCompactCount(value: number) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (normalizedValue >= 100000000) {
|
||||
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
|
||||
}
|
||||
if (normalizedValue >= 10000) {
|
||||
return `${(normalizedValue / 10000).toFixed(1)}万`;
|
||||
}
|
||||
|
||||
return `${normalizedValue}`;
|
||||
}
|
||||
|
||||
export function describePlatformPublicWorkKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('大鱼吃小鱼');
|
||||
}
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('拼图');
|
||||
}
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('抓大鹅');
|
||||
}
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('方洞挑战');
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('跳一跳');
|
||||
}
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('敲木鱼');
|
||||
}
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('视觉小说');
|
||||
}
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('汪汪声浪');
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag(entry.templateName);
|
||||
}
|
||||
|
||||
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||||
}
|
||||
|
||||
export function resolvePlatformWorkAuthorDisplayName(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
authorSummary?: PublicUserSummary | null,
|
||||
@@ -1005,6 +1057,36 @@ export function resolvePlatformWorkAuthorDisplayName(
|
||||
return displayName || entryAuthorName || '玩家';
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkAuthorLookup(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): PlatformPublicWorkAuthorLookup | null {
|
||||
if ('authorPublicUserCode' in entry) {
|
||||
const authorPublicUserCode = entry.authorPublicUserCode?.trim();
|
||||
if (authorPublicUserCode) {
|
||||
return {
|
||||
key: `code:${authorPublicUserCode}`,
|
||||
source: 'publicUserCode',
|
||||
value: authorPublicUserCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ownerUserId = entry.ownerUserId.trim();
|
||||
return ownerUserId
|
||||
? {
|
||||
key: `id:${ownerUserId}`,
|
||||
source: 'ownerUserId',
|
||||
value: ownerUserId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function formatPlatformPublicAuthorAvatarLabel(
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
|
||||
}
|
||||
|
||||
function normalizePlatformPublicAuthorName(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (!normalized || normalized === 'null' || normalized === 'undefined') {
|
||||
|
||||
@@ -35,13 +35,14 @@ import type {
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeProjection,
|
||||
getStoryRuntimeProjection,
|
||||
resolveRuntimeStoryAction,
|
||||
} from './rpg-runtime/rpgRuntimeStoryClient';
|
||||
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
import { parseSseJsonObject, readSseJsonStream, readSseStream } from './sseStream';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
@@ -108,81 +109,96 @@ async function requestPlainTextStream(
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseStream(response, ({ data }) => {
|
||||
if (data === '[DONE]') {
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames.
|
||||
}
|
||||
}
|
||||
const parsed = parseSseJsonObject(data);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const delta = readPlainTextStreamDelta(parsed);
|
||||
if (delta) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
});
|
||||
|
||||
return accumulatedText.trim();
|
||||
}
|
||||
|
||||
type ParsedSseEvent = {
|
||||
event: string | null;
|
||||
data: string;
|
||||
};
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
||||
let eventName: string | null = null;
|
||||
const dataLines: string[] = [];
|
||||
function readPlainTextStreamDelta(parsed: Record<string, unknown>) {
|
||||
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
|
||||
const firstChoice = asRecord(choices[0]);
|
||||
const delta = asRecord(firstChoice?.delta);
|
||||
const content = delta?.content;
|
||||
return typeof content === 'string' ? content : '';
|
||||
}
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
function readSseEventMessage(
|
||||
parsed: Record<string, unknown>,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return typeof parsed.message === 'string' ? parsed.message : fallbackMessage;
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
function coerceNpcChatTurnResult(
|
||||
parsed: Record<string, unknown>,
|
||||
): NpcChatTurnResult {
|
||||
return parsed as unknown as NpcChatTurnResult;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
data: dataLines.join('\n'),
|
||||
function readNpcReplyDelta(parsed: Record<string, unknown>) {
|
||||
return typeof parsed.text === 'string' ? parsed.text : '';
|
||||
}
|
||||
|
||||
function readNpcCompletedReply(result: NpcChatTurnResult) {
|
||||
return typeof result.npcReply === 'string' ? result.npcReply : '';
|
||||
}
|
||||
|
||||
async function readNpcChatTurnFromSse(
|
||||
response: Response,
|
||||
options: { onReplyUpdate?: (text: string) => void } = {},
|
||||
): Promise<NpcChatTurnResult> {
|
||||
let accumulatedReply = '';
|
||||
const completedResultRef: { current: NpcChatTurnResult | null } = {
|
||||
current: null,
|
||||
};
|
||||
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
if (eventName === 'reply_delta') {
|
||||
accumulatedReply = readNpcReplyDelta(parsed);
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'complete') {
|
||||
completedResultRef.current = coerceNpcChatTurnResult(parsed);
|
||||
accumulatedReply = readNpcCompletedReply(completedResultRef.current);
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (eventName === 'error') {
|
||||
throw new Error(readSseEventMessage(parsed, 'NPC 聊天续写失败'));
|
||||
}
|
||||
});
|
||||
|
||||
if (!completedResultRef.current) {
|
||||
throw new Error('NPC 聊天续写结果为空');
|
||||
}
|
||||
|
||||
return completedResultRef.current;
|
||||
}
|
||||
|
||||
export async function generateInitialStory(
|
||||
@@ -508,72 +524,9 @@ export async function streamNpcChatTurn(
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedReply = '';
|
||||
let completedResult: NpcChatTurnResult | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
const parsedEvent = parseSseEventBlock(eventBlock);
|
||||
if (!parsedEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'reply_delta') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const nextText =
|
||||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||||
accumulatedReply = nextText;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'complete') {
|
||||
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
||||
accumulatedReply = completedResult.npcReply;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'error') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
throw new Error(
|
||||
typeof payloadRecord.message === 'string'
|
||||
? payloadRecord.message
|
||||
: 'NPC 聊天续写失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedResult) {
|
||||
throw new Error('NPC 聊天续写结果为空');
|
||||
}
|
||||
|
||||
return completedResult;
|
||||
return readNpcChatTurnFromSse(response, {
|
||||
onReplyUpdate: options.onReplyUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
|
||||
@@ -5,15 +5,11 @@ import type {
|
||||
BarkBattleRunStartResponse,
|
||||
BarkBattleRuntimeConfig,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type ApiRetryOptions } from '../apiClient';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const BARK_BATTLE_RUNTIME_API_BASE = '/api/runtime/bark-battle';
|
||||
|
||||
const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
@@ -34,16 +30,17 @@ export function getBarkBattleRuntimeConfig(
|
||||
workId: string,
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BarkBattleRuntimeConfig>(
|
||||
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`,
|
||||
{ method: 'GET', headers: buildRuntimeGuestHeaders(options) },
|
||||
'读取汪汪声浪大作战配置失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BarkBattleRuntimeConfig>({
|
||||
url: buildRuntimeApiPath(
|
||||
BARK_BATTLE_RUNTIME_API_BASE,
|
||||
'works',
|
||||
workId,
|
||||
'config',
|
||||
),
|
||||
fallbackMessage: '读取汪汪声浪大作战配置失败',
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function startBarkBattleRun(
|
||||
@@ -51,39 +48,34 @@ export function startBarkBattleRun(
|
||||
payload: Partial<BarkBattleRunStartRequest> = {},
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BarkBattleRunStartResponse>(
|
||||
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
workId: payload.workId ?? workId,
|
||||
}),
|
||||
return requestRuntimeJson<BarkBattleRunStartResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BARK_BATTLE_RUNTIME_API_BASE,
|
||||
'works',
|
||||
workId,
|
||||
'runs',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
workId: payload.workId ?? workId,
|
||||
},
|
||||
'启动汪汪声浪大作战正式局失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
fallbackMessage: '启动汪汪声浪大作战正式局失败',
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBarkBattleRun(
|
||||
runId: string,
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<unknown>(
|
||||
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET', headers: buildRuntimeGuestHeaders(options) },
|
||||
'读取汪汪声浪大作战单局失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<unknown>({
|
||||
url: buildRuntimeApiPath(BARK_BATTLE_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取汪汪声浪大作战单局失败',
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function finishBarkBattleRun(
|
||||
@@ -91,21 +83,20 @@ export function finishBarkBattleRun(
|
||||
payload: BarkBattleRunFinishRequest,
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BarkBattleFinishResponse>(
|
||||
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
}),
|
||||
return requestRuntimeJson<BarkBattleFinishResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BARK_BATTLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'finish',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
},
|
||||
'提交汪汪声浪大作战成绩失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
fallbackMessage: '提交汪汪声浪大作战成绩失败',
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,16 +4,11 @@ import type {
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type ApiRetryOptions } from '../apiClient';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const BIG_FISH_RUNTIME_API_BASE = '/api/runtime/big-fish';
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
@@ -30,51 +25,44 @@ export function recordBigFishPlay(
|
||||
payload: RecordBigFishPlayRequest,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'记录大鱼吃小鱼游玩失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BigFishWorksResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BIG_FISH_RUNTIME_API_BASE,
|
||||
'sessions',
|
||||
sessionId,
|
||||
'play',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '记录大鱼吃小鱼游玩失败',
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function startBigFishRun(
|
||||
sessionId: string,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
'启动大鱼吃小鱼玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BigFishRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BIG_FISH_RUNTIME_API_BASE,
|
||||
'sessions',
|
||||
sessionId,
|
||||
'runs',
|
||||
),
|
||||
method: 'POST',
|
||||
fallbackMessage: '启动大鱼吃小鱼玩法失败',
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBigFishRun(runId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼玩法失败',
|
||||
);
|
||||
return requestRuntimeJson<BigFishRunResponse>({
|
||||
url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取大鱼吃小鱼玩法失败',
|
||||
});
|
||||
}
|
||||
|
||||
export function submitBigFishInput(
|
||||
@@ -82,20 +70,12 @@ export function submitBigFishInput(
|
||||
payload: SubmitBigFishInputRequest,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'同步大鱼吃小鱼输入失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BigFishRunResponse>({
|
||||
url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId, 'input'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '同步大鱼吃小鱼输入失败',
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
@@ -24,65 +25,6 @@ type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
| null;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type NormalizedCreationAgentSseEvent = NonNullable<
|
||||
CreationAgentSseOptions<unknown>['normalizeEvent']
|
||||
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
|
||||
@@ -147,71 +89,30 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
||||
response: Response,
|
||||
options: CreationAgentSseOptions<TSession>,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const resolveSession =
|
||||
options.resolveSession ??
|
||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||
let buffer = '';
|
||||
let finalSession: TSession | null = null;
|
||||
const normalizeEvent =
|
||||
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const normalized = normalizeEvent(eventName, parsed);
|
||||
|
||||
if (normalized?.kind === 'reply_delta') {
|
||||
options.onUpdate?.(normalized.text);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized?.kind === 'session') {
|
||||
finalSession = resolveSession(normalized.session);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized?.kind === 'error') {
|
||||
throw new Error(normalized.message || options.fallbackMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
});
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CreativeDraftEditResult,
|
||||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
type CreativeAgentSseOptions = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
@@ -16,65 +17,6 @@ type CreativeAgentSseResult = {
|
||||
draftEditResult: CreativeDraftEditResult | null;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCreativeAgentSseEvent(
|
||||
eventName: string,
|
||||
data: Record<string, unknown>,
|
||||
@@ -105,13 +47,9 @@ function normalizeCreativeAgentSseEvent(
|
||||
|
||||
function handleParsedCreativeAgentEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown> | null,
|
||||
parsed: Record<string, unknown>,
|
||||
options: CreativeAgentSseOptions,
|
||||
): Partial<CreativeAgentSseResult> | null {
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEvent = normalizeCreativeAgentSseEvent(eventName, parsed);
|
||||
if (normalizedEvent) {
|
||||
options.onEvent?.(normalizedEvent);
|
||||
@@ -168,59 +106,24 @@ export async function readCreativeAgentResultFromSse(
|
||||
response: Response,
|
||||
options: CreativeAgentSseOptions,
|
||||
): Promise<CreativeAgentSseResult> {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
const result: CreativeAgentSseResult = {
|
||||
session: null,
|
||||
draftEditResult: null,
|
||||
};
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextResult = handleParsedCreativeAgentEvent(
|
||||
eventName,
|
||||
parseJsonObject(data),
|
||||
options,
|
||||
);
|
||||
if (nextResult?.session) {
|
||||
result.session = nextResult.session;
|
||||
}
|
||||
if (nextResult?.draftEditResult) {
|
||||
result.draftEditResult = nextResult.draftEditResult;
|
||||
}
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const nextResult = handleParsedCreativeAgentEvent(
|
||||
eventName,
|
||||
parsed,
|
||||
options,
|
||||
);
|
||||
if (nextResult?.session) {
|
||||
result.session = nextResult.session;
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
if (nextResult?.draftEditResult) {
|
||||
result.draftEditResult = nextResult.draftEditResult;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
});
|
||||
|
||||
if (!result.session) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
108
src/services/jump-hop/jumpHopClient.runtime.test.ts
Normal file
108
src/services/jump-hop/jumpHopClient.runtime.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../apiClient')>('../apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
restartJumpHopRuntimeRun,
|
||||
startJumpHopRuntimeRun,
|
||||
submitJumpHopJump,
|
||||
} from './jumpHopClient';
|
||||
|
||||
describe('jumpHopClient runtime requests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1780000000000);
|
||||
apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts runs through the shared runtime request skeleton', async () => {
|
||||
await startJumpHopRuntimeRun('profile/1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/jump-hop/runs',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({ profileId: 'profile/1' }),
|
||||
}),
|
||||
'启动跳一跳运行态失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits jump input with a generated client event id', async () => {
|
||||
await submitJumpHopJump(
|
||||
'run/1',
|
||||
{ chargeMs: 320 },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/jump-hop/runs/run%2F1/jump',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chargeMs: 320,
|
||||
clientEventId: 'jump-run/1-1780000000000',
|
||||
}),
|
||||
}),
|
||||
'提交跳一跳起跳失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts runs with the same guest auth request skeleton', async () => {
|
||||
await restartJumpHopRuntimeRun('run/1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/jump-hop/runs/run%2F1/restart',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientActionId: 'restart-run/1-1780000000000',
|
||||
}),
|
||||
}),
|
||||
'重新开始跳一跳失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,8 @@ import {
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
||||
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
|
||||
@@ -255,23 +252,13 @@ export async function startJumpHopRuntimeRun(
|
||||
profileId: string,
|
||||
options: JumpHopStartRunOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const runtimeMode = options.runtimeMode ?? 'published';
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({ profileId, runtimeMode }),
|
||||
},
|
||||
'启动跳一跳运行态失败',
|
||||
{
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<JumpHopRunResponse>({
|
||||
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs'),
|
||||
method: 'POST',
|
||||
jsonBody: { profileId },
|
||||
fallbackMessage: '启动跳一跳运行态失败',
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitJumpHopJump(
|
||||
@@ -279,7 +266,6 @@ export async function submitJumpHopJump(
|
||||
payload: JumpHopJumpPayload,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload = {
|
||||
dragDistance: payload.dragDistance,
|
||||
dragVectorX: payload.dragVectorX,
|
||||
@@ -287,19 +273,13 @@ export async function submitJumpHopJump(
|
||||
clientEventId: `jump-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/jump`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'提交跳一跳起跳失败',
|
||||
requestOptions,
|
||||
);
|
||||
return requestRuntimeJson<JumpHopRunResponse>({
|
||||
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs', runId, 'jump'),
|
||||
method: 'POST',
|
||||
jsonBody: requestPayload,
|
||||
fallbackMessage: '提交跳一跳起跳失败',
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJumpHopLeaderboard(
|
||||
@@ -322,22 +302,20 @@ export async function restartJumpHopRuntimeRun(
|
||||
runId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientActionId: `restart-${runId}-${Date.now()}`,
|
||||
}),
|
||||
return requestRuntimeJson<JumpHopRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
JUMP_HOP_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'restart',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
clientActionId: `restart-${runId}-${Date.now()}`,
|
||||
},
|
||||
'重新开始跳一跳失败',
|
||||
requestOptions,
|
||||
);
|
||||
fallbackMessage: '重新开始跳一跳失败',
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export const jumpHopClient = {
|
||||
|
||||
51
src/services/llmClient.test.ts
Normal file
51
src/services/llmClient.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { streamPlainTextCompletion } from './llmClient';
|
||||
|
||||
function createSseResponse(body: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(body));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('llmClient streamPlainTextCompletion', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads OpenAI compatible SSE through the shared stream reader', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
createSseResponse(
|
||||
[
|
||||
'data: {"choices":[{"delta":{"content":"溪上"}}]}\r\n\r\n',
|
||||
'data: not-json\r\n\r\n',
|
||||
'data: {"choices":[{"delta":{"content":"春风"}}]}\r\n\r\n',
|
||||
'data: [DONE]\r\n\r\n',
|
||||
'data: {"choices":[{"delta":{"content":"不应读取"}}]}\r\n\r\n',
|
||||
].join(''),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await streamPlainTextCompletion('system', 'user', {
|
||||
onUpdate,
|
||||
});
|
||||
|
||||
expect(result).toBe('溪上春风');
|
||||
expect(onUpdate).toHaveBeenNthCalledWith(1, '溪上');
|
||||
expect(onUpdate).toHaveBeenNthCalledWith(2, '溪上春风');
|
||||
expect(onUpdate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
import { parseSseJsonObject, readSseStream } from './sseStream';
|
||||
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
@@ -44,6 +45,26 @@ function resolveHeaders(headers?: HeadersInit) {
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function readLlmStreamDeltaContent(parsed: Record<string, unknown>) {
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [firstChoice] = choices;
|
||||
if (typeof firstChoice !== 'object' || firstChoice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delta = (firstChoice as {delta?: unknown}).delta;
|
||||
if (typeof delta !== 'object' || delta === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = (delta as {content?: unknown}).content;
|
||||
return typeof content === 'string' && content.length > 0 ? content : null;
|
||||
}
|
||||
|
||||
const NODE_ENV = getNodeEnv();
|
||||
const IS_SERVER_RUNTIME = typeof window === 'undefined';
|
||||
const SERVER_API_KEY =
|
||||
@@ -291,48 +312,20 @@ export async function streamPlainTextCompletion(
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseStream(response, ({ data }) => {
|
||||
if (data === '[DONE]') {
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames and continue consuming the stream.
|
||||
}
|
||||
}
|
||||
const parsed = parseSseJsonObject(data);
|
||||
const delta = parsed ? readLlmStreamDeltaContent(parsed) : null;
|
||||
if (delta) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accumulatedText.trim();
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,14 +10,11 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const MATCH3D_RUNTIME_API_BASE = '/api/runtime/match3d';
|
||||
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
@@ -74,39 +71,30 @@ export function startMatch3DRun(
|
||||
profileId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const payload: StartMatch3DRunRequest = {
|
||||
profileId,
|
||||
itemTypeCountOverride: options.itemTypeCountOverride ?? null,
|
||||
};
|
||||
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动抓大鹅玩法失败',
|
||||
{
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'works', profileId, 'runs'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '启动抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取抓大鹅运行态快照。
|
||||
*/
|
||||
export function getMatch3DRun(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取抓大鹅运行快照失败',
|
||||
{ retry: MATCH3D_RUNTIME_READ_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取抓大鹅运行快照失败',
|
||||
retry: MATCH3D_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,19 +104,16 @@ export async function clickMatch3DItem(
|
||||
runId: string,
|
||||
payload: Match3DClickItemRequest,
|
||||
) {
|
||||
const response = await requestJson<Match3DClickResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
}),
|
||||
const response = await requestRuntimeJson<Match3DClickResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'click'),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
},
|
||||
'确认抓大鹅点击失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
fallbackMessage: '确认抓大鹅点击失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
|
||||
return mapClickConfirmation(payload, response.confirmation);
|
||||
}
|
||||
@@ -142,40 +127,37 @@ export function stopMatch3DRun(
|
||||
clientActionId: `match3d-stop-${Date.now()}`,
|
||||
},
|
||||
) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'停止抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'stop'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '停止抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前 run 重开一局。
|
||||
*/
|
||||
export function restartMatch3DRun(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{ method: 'POST' },
|
||||
'重新开始抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'restart'),
|
||||
method: 'POST',
|
||||
fallbackMessage: '重新开始抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端倒计时归零后通知后端确认失败状态。
|
||||
*/
|
||||
export function finishMatch3DTimeUp(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`,
|
||||
{ method: 'POST' },
|
||||
'同步抓大鹅倒计时失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'time-up'),
|
||||
method: 'POST',
|
||||
fallbackMessage: '同步抓大鹅倒计时失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export const match3dRuntimeClient = {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildCustomWorldPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildPuzzleClearPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
isSameCustomWorldPublicWorkCode,
|
||||
isSameJumpHopPublicWorkCode,
|
||||
isSamePuzzleClearPublicWorkCode,
|
||||
isSameMatch3DPublicWorkCode,
|
||||
isSameWoodenFishPublicWorkCode,
|
||||
} from './publicWorkCode';
|
||||
|
||||
@@ -54,6 +54,24 @@ describe('publicWorkCode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches current and legacy match3d public work prefixes', () => {
|
||||
expect(buildMatch3DPublicWorkCode('match3d-profile-12345678')).toBe(
|
||||
'M3-12345678',
|
||||
);
|
||||
expect(
|
||||
isSameMatch3DPublicWorkCode(
|
||||
'M3-12345678',
|
||||
'match3d-profile-12345678',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameMatch3DPublicWorkCode(
|
||||
'M3D-12345678',
|
||||
'match3d-profile-12345678',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('builds and matches custom world public work codes from profile ids', () => {
|
||||
expect(buildCustomWorldPublicWorkCode('world-public-1')).toBe('CW-00000001');
|
||||
expect(isSameCustomWorldPublicWorkCode('cw-00000001', 'world-public-1')).toBe(
|
||||
|
||||
@@ -37,6 +37,14 @@ export function buildMatch3DPublicWorkCode(profileId: string) {
|
||||
return `M3-${suffix}`;
|
||||
}
|
||||
|
||||
function buildLegacyMatch3DPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `M3D-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildSquareHolePublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
@@ -155,6 +163,8 @@ export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string)
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildMatch3DPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildLegacyMatch3DPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
92
src/services/puzzle-runtime/puzzleRuntimeClient.test.ts
Normal file
92
src/services/puzzle-runtime/puzzleRuntimeClient.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../apiClient')>('../apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
getPuzzleRun,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
describe('puzzleRuntimeClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' });
|
||||
});
|
||||
|
||||
it('reads runs through the shared encoded runtime path', async () => {
|
||||
await getPuzzleRun('run/1');
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/runs/run%2F1',
|
||||
{ method: 'GET' },
|
||||
'读取拼图运行快照失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits puzzle swaps through the shared json request skeleton', async () => {
|
||||
await swapPuzzlePieces('run/1', {
|
||||
firstPieceId: 'piece-a',
|
||||
secondPieceId: 'piece-b',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/runs/run%2F1/swap',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstPieceId: 'piece-a',
|
||||
secondPieceId: 'piece-b',
|
||||
}),
|
||||
}),
|
||||
'交换拼图块失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ retryUnsafeMethods: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps pause requests on account auth options instead of guest auth', async () => {
|
||||
await updatePuzzleRunPause(
|
||||
'run/1',
|
||||
{ paused: true },
|
||||
{
|
||||
authImpact: 'local',
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
|
||||
const [, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paused: true }),
|
||||
}),
|
||||
);
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
expect(options).not.toMatchObject({ skipAuth: true });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
AdvancePuzzleNextLevelRequest,
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
@@ -12,11 +12,8 @@ import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
|
||||
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -42,38 +39,25 @@ export async function startPuzzleRun(
|
||||
payload: StartPuzzleRunRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
PUZZLE_RUNTIME_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动拼图玩法失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: PUZZLE_RUNTIME_API_BASE,
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '启动拼图玩法失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图运行态快照。
|
||||
*/
|
||||
export async function getPuzzleRun(runId: string) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图运行快照失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId),
|
||||
fallbackMessage: '读取拼图运行快照失败',
|
||||
retry: PUZZLE_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,18 +67,13 @@ export async function swapPuzzlePieces(
|
||||
runId: string,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'交换拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'swap'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '交换拼图块失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,18 +83,13 @@ export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'拖动拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'drag'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '拖动拼图块失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +100,6 @@ export async function advancePuzzleNextLevel(
|
||||
payload: AdvancePuzzleNextLevelRequest = {},
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||
const preferSimilarWork = payload.preferSimilarWork === true;
|
||||
const requestPayload = {
|
||||
@@ -134,27 +107,14 @@ export async function advancePuzzleNextLevel(
|
||||
...(preferSimilarWork ? { preferSimilarWork: true } : {}),
|
||||
};
|
||||
const hasRequestPayload = Object.keys(requestPayload).length > 0;
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
...(hasRequestPayload
|
||||
? {
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(requestPayload),
|
||||
}
|
||||
: {
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
}),
|
||||
},
|
||||
'进入下一关失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'next-level'),
|
||||
method: 'POST',
|
||||
...(hasRequestPayload ? { jsonBody: requestPayload } : {}),
|
||||
fallbackMessage: '进入下一关失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,22 +125,14 @@ export async function submitPuzzleLeaderboard(
|
||||
payload: SubmitPuzzleLeaderboardRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'leaderboard'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '提交拼图排行榜失败',
|
||||
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,7 +144,7 @@ export async function updatePuzzleRunPause(
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
|
||||
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -218,7 +170,7 @@ export async function usePuzzleRuntimeProp(
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
|
||||
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -13,8 +13,8 @@ vi.mock('./apiClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
|
||||
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
|
||||
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
|
||||
import {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type {
|
||||
ClaimProfileTaskRewardResponse,
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ClaimProfileTaskRewardResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileTaskCenterResponse,
|
||||
@@ -24,10 +24,11 @@ import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { fetchWithApiAuth } from '../apiClient';
|
||||
import {
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
requestRpgRuntimeJson,
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
type RuntimeRequestOptions,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
@@ -132,65 +133,6 @@ type RechargeOrderSseEvent =
|
||||
payload: { message: string };
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRechargeOrderSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
@@ -264,81 +206,33 @@ export async function watchWechatRpgProfileRechargeOrder(
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let streamDone = false;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
finalResponse = normalized.payload;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'done') {
|
||||
streamDone = true;
|
||||
if (!finalResponse && lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
if (finalResponse) {
|
||||
break;
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
finalResponse = normalized.payload;
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (streamDone) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
|
||||
if (!finalResponse) {
|
||||
if (lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
if (normalized.type === 'done') {
|
||||
if (!finalResponse && lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||
});
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('充值订单状态流返回不完整');
|
||||
|
||||
88
src/services/runtimeRequest.test.ts
Normal file
88
src/services/runtimeRequest.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildRuntimeApiPath,
|
||||
requestRuntimeJson,
|
||||
} from './runtimeRequest';
|
||||
|
||||
describe('runtimeRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('builds encoded runtime api paths', () => {
|
||||
expect(buildRuntimeApiPath('/api/runtime/demo/', 'work/a b', 'run/1')).toBe(
|
||||
'/api/runtime/demo/work%2Fa%20b/run%2F1',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends json runtime requests with guest auth and retry options', async () => {
|
||||
const retry = { maxRetries: 1, retryUnsafeMethods: true };
|
||||
|
||||
await requestRuntimeJson({
|
||||
url: '/api/runtime/demo/runs',
|
||||
method: 'POST',
|
||||
jsonBody: { profileId: 'profile-1' },
|
||||
fallbackMessage: '启动失败',
|
||||
retry,
|
||||
requestOptions: {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/demo/runs',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({ profileId: 'profile-1' }),
|
||||
},
|
||||
'启动失败',
|
||||
{
|
||||
retry,
|
||||
authImpact: undefined,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: undefined,
|
||||
clearAuthOnUnauthorized: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('omits empty headers and body for plain runtime reads', async () => {
|
||||
await requestRuntimeJson({
|
||||
url: '/api/runtime/demo/runs/run-1',
|
||||
fallbackMessage: '读取失败',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/demo/runs/run-1',
|
||||
{ method: 'GET' },
|
||||
'读取失败',
|
||||
{
|
||||
authImpact: undefined,
|
||||
skipAuth: undefined,
|
||||
skipRefresh: undefined,
|
||||
notifyAuthStateChange: undefined,
|
||||
clearAuthOnUnauthorized: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
62
src/services/runtimeRequest.ts
Normal file
62
src/services/runtimeRequest.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from './apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from './runtimeGuestAuth';
|
||||
|
||||
export type RuntimeJsonRequestParams = {
|
||||
url: string;
|
||||
method?: string;
|
||||
jsonBody?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
fallbackMessage: string;
|
||||
retry?: ApiRetryOptions;
|
||||
requestOptions?: RuntimeGuestRequestOptions;
|
||||
};
|
||||
|
||||
export function buildRuntimeApiPath(
|
||||
basePath: string,
|
||||
...segments: string[]
|
||||
) {
|
||||
const normalizedBasePath = basePath.endsWith('/')
|
||||
? basePath.slice(0, -1)
|
||||
: basePath;
|
||||
return [
|
||||
normalizedBasePath,
|
||||
...segments.map((segment) => encodeURIComponent(segment)),
|
||||
].join('/');
|
||||
}
|
||||
|
||||
export function requestRuntimeJson<T>(params: RuntimeJsonRequestParams) {
|
||||
const {
|
||||
fallbackMessage,
|
||||
headers = {},
|
||||
jsonBody,
|
||||
method = 'GET',
|
||||
requestOptions = {},
|
||||
retry,
|
||||
url,
|
||||
} = params;
|
||||
const hasJsonBody = jsonBody !== undefined;
|
||||
const requestHeaders = buildRuntimeGuestHeaders(requestOptions, {
|
||||
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers,
|
||||
});
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
...(Object.keys(requestHeaders).length > 0
|
||||
? { headers: requestHeaders }
|
||||
: {}),
|
||||
...(hasJsonBody ? { body: JSON.stringify(jsonBody) } : {}),
|
||||
};
|
||||
const authOptions = buildRuntimeGuestAuthOptions(requestOptions);
|
||||
|
||||
return requestJson<T>(url, init, fallbackMessage, {
|
||||
...(retry ? { retry } : {}),
|
||||
...authOptions,
|
||||
});
|
||||
}
|
||||
@@ -6,14 +6,11 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const SQUARE_HOLE_RUNTIME_API_BASE = '/api/runtime/square-hole';
|
||||
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
@@ -34,34 +31,30 @@ export function startSquareHoleRun(
|
||||
profileId: string,
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动方洞挑战失败',
|
||||
{
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'works',
|
||||
profileId,
|
||||
'runs',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: { profileId },
|
||||
fallbackMessage: '启动方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取方洞挑战运行态快照。
|
||||
*/
|
||||
export function getSquareHoleRun(runId: string) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取方洞挑战运行快照失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_READ_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取方洞挑战运行快照失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,19 +64,21 @@ export function dropSquareHoleShape(
|
||||
runId: string,
|
||||
payload: DropSquareHoleShapeRequest,
|
||||
) {
|
||||
return requestJson<SquareHoleDropResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/drop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
}),
|
||||
return requestRuntimeJson<SquareHoleDropResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'drop',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
},
|
||||
'确认方洞挑战投入失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
fallbackMessage: '确认方洞挑战投入失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,40 +90,47 @@ export function stopSquareHoleRun(
|
||||
clientActionId: `square-hole-stop-${Date.now()}`,
|
||||
},
|
||||
) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/stop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'停止方洞挑战失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId, 'stop'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '停止方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前 run 重开一局。
|
||||
*/
|
||||
export function restartSquareHoleRun(runId: string) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{ method: 'POST' },
|
||||
'重新开始方洞挑战失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'restart',
|
||||
),
|
||||
method: 'POST',
|
||||
fallbackMessage: '重新开始方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端倒计时归零后通知后端确认失败状态。
|
||||
*/
|
||||
export function finishSquareHoleTimeUp(runId: string) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/time-up`,
|
||||
{ method: 'POST' },
|
||||
'同步方洞挑战倒计时失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'time-up',
|
||||
),
|
||||
method: 'POST',
|
||||
fallbackMessage: '同步方洞挑战倒计时失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export const squareHoleRuntimeClient = {
|
||||
|
||||
98
src/services/sseStream.test.ts
Normal file
98
src/services/sseStream.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { readSseJsonStream, readSseStream } from './sseStream';
|
||||
|
||||
function createChunkedStreamResponse(chunks: Uint8Array[]) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('readSseJsonStream flushes decoder tail and handles CRLF boundaries', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"');
|
||||
const replyBytes = encoder.encode('溪上春风');
|
||||
const suffix = encoder.encode('"}\r\n\r\n');
|
||||
const splitIndex = replyBytes.length - 1;
|
||||
const events: Array<{ eventName: string; parsed: Record<string, unknown> }> =
|
||||
[];
|
||||
|
||||
await readSseJsonStream(
|
||||
createChunkedStreamResponse([
|
||||
new Uint8Array([...prefix, ...replyBytes.slice(0, splitIndex)]),
|
||||
new Uint8Array([...replyBytes.slice(splitIndex), ...suffix]),
|
||||
]),
|
||||
({ eventName, parsed }) => {
|
||||
events.push({ eventName, parsed });
|
||||
},
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
eventName: 'reply_delta',
|
||||
parsed: { text: '溪上春风' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('readSseJsonStream skips malformed json and keeps valid LF events', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const events: Array<{ eventName: string; parsed: Record<string, unknown> }> =
|
||||
[];
|
||||
|
||||
await readSseJsonStream(
|
||||
createChunkedStreamResponse([
|
||||
encoder.encode(
|
||||
'event: malformed\ndata: not-json\n\n' +
|
||||
'event: ready\ndata: {"value":7}\n\n',
|
||||
),
|
||||
]),
|
||||
({ eventName, parsed }) => {
|
||||
events.push({ eventName, parsed });
|
||||
},
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
eventName: 'ready',
|
||||
parsed: { value: 7 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('readSseStream can stop early and cancel the reader', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: first\ndata: one\n\n' + 'event: second\ndata: two\n\n',
|
||||
),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const events: string[] = [];
|
||||
|
||||
await readSseStream(new Response(stream), ({ eventName }) => {
|
||||
events.push(eventName);
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(events).toEqual(['first']);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
168
src/services/sseStream.ts
Normal file
168
src/services/sseStream.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type SseStreamEvent = {
|
||||
eventName: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type SseJsonStreamEvent = SseStreamEvent & {
|
||||
parsed: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type SseEventBoundary = {
|
||||
index: number;
|
||||
length: number;
|
||||
};
|
||||
|
||||
type SseStreamEventHandler<TEvent extends SseStreamEvent> = (
|
||||
event: TEvent,
|
||||
) => void | boolean;
|
||||
|
||||
function findSseEventBoundary(buffer: string): SseEventBoundary | null {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string): SseStreamEvent | null {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSseJsonObject(data: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as unknown;
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSseStream(
|
||||
response: Response,
|
||||
onEvent: SseStreamEventHandler<SseStreamEvent>,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let shouldContinue = true;
|
||||
let completed = false;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
}
|
||||
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const event = parseSseEventBlock(eventBlock);
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onEvent(event) === false) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldContinue) {
|
||||
// 流结束后 flush 解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
completed = true;
|
||||
}
|
||||
} finally {
|
||||
if (!completed && typeof reader.cancel === 'function') {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
reader.releaseLock?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function readSseJsonStream(
|
||||
response: Response,
|
||||
onEvent: SseStreamEventHandler<SseJsonStreamEvent>,
|
||||
) {
|
||||
return readSseStream(response, (event) => {
|
||||
const parsed = parseSseJsonObject(event.data);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return onEvent({
|
||||
...event,
|
||||
parsed,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,10 +10,12 @@ vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
buildVisualNovelRuntimeCheckpoint,
|
||||
buildVisualNovelSaveArchiveState,
|
||||
type VisualNovelRuntimeStreamOptions,
|
||||
getVisualNovelHistory,
|
||||
getVisualNovelRun,
|
||||
listVisualNovelGallery,
|
||||
listVisualNovelSaveArchives,
|
||||
putVisualNovelRuntimeSnapshot,
|
||||
@@ -21,8 +23,8 @@ import {
|
||||
resumeVisualNovelSaveArchive,
|
||||
startVisualNovelRun,
|
||||
streamVisualNovelRuntimeAction,
|
||||
type VisualNovelRuntimeStreamOptions,
|
||||
} from './visualNovelRuntimeClient';
|
||||
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
function createMockRun(
|
||||
overrides: Partial<VisualNovelRunSnapshot> = {},
|
||||
@@ -108,6 +110,32 @@ test('startVisualNovelRun uses the visual novel runtime work route', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('getVisualNovelRun and getVisualNovelHistory use encoded runtime run routes', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({ run: createMockRun() })
|
||||
.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
await getVisualNovelRun('vn/run-1');
|
||||
await getVisualNovelHistory('vn/run-1');
|
||||
|
||||
expect(requestJsonMock.mock.calls[0]).toEqual([
|
||||
'/api/runtime/visual-novel/runs/vn%2Frun-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取视觉小说运行快照失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
]);
|
||||
expect(requestJsonMock.mock.calls[1]).toEqual([
|
||||
'/api/runtime/visual-novel/runs/vn%2Frun-1/history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取视觉小说历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('streamVisualNovelRuntimeAction posts to the SSE action stream route', async () => {
|
||||
const response = createSseResponse(
|
||||
[
|
||||
@@ -146,6 +174,10 @@ test('streamVisualNovelRuntimeAction posts to the SSE action stream route', asyn
|
||||
}),
|
||||
signal: undefined,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
skipAuth: undefined,
|
||||
skipRefresh: undefined,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ runId: 'vn-run-route-1' });
|
||||
});
|
||||
|
||||
@@ -23,12 +23,13 @@ import {
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse';
|
||||
|
||||
const VISUAL_NOVEL_RUNTIME_API_BASE = '/api/runtime/visual-novel';
|
||||
const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -57,17 +58,13 @@ export type VisualNovelSaveArchiveResumeResponse =
|
||||
>;
|
||||
|
||||
export async function listVisualNovelGallery() {
|
||||
return requestJson<VisualNovelWorksResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说公开作品列表失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
// 中文注释:公开广场是游客可读入口,避免未登录态先触发 refresh 再读取公开列表。
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<VisualNovelWorksResponse>({
|
||||
url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'gallery'),
|
||||
fallbackMessage: '读取视觉小说公开作品列表失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
// 中文注释:公开广场是游客可读入口,避免未登录态先触发 refresh 再读取公开列表。
|
||||
requestOptions: { skipAuth: true, skipRefresh: true },
|
||||
});
|
||||
}
|
||||
|
||||
function buildJsonInit(method: 'POST' | 'PUT', payload: unknown): RequestInit {
|
||||
@@ -117,7 +114,12 @@ export async function startVisualNovelRun(
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
|
||||
buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
'works',
|
||||
profileId,
|
||||
'runs',
|
||||
),
|
||||
{
|
||||
...buildJsonInit('POST', payload),
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
@@ -134,25 +136,24 @@ export async function startVisualNovelRun(
|
||||
}
|
||||
|
||||
export async function getVisualNovelRun(runId: string) {
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说运行快照失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<VisualNovelRunResponse>({
|
||||
url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取视觉小说运行快照失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVisualNovelHistory(runId: string) {
|
||||
return requestJson<VisualNovelHistoryResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/history`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说历史失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<VisualNovelHistoryResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'history',
|
||||
),
|
||||
fallbackMessage: '读取视觉小说历史失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamVisualNovelRuntimeAction(
|
||||
@@ -161,7 +162,13 @@ export async function streamVisualNovelRuntimeAction(
|
||||
options: VisualNovelRuntimeStreamOptions = {},
|
||||
) {
|
||||
const response = await openVisualNovelRuntimeSsePost(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream`,
|
||||
buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'actions',
|
||||
'stream',
|
||||
),
|
||||
payload,
|
||||
'推进视觉小说失败',
|
||||
options.signal,
|
||||
@@ -179,14 +186,18 @@ export async function regenerateVisualNovelRun(
|
||||
runId: string,
|
||||
payload: VisualNovelRegenerateRequest,
|
||||
) {
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/regenerate`,
|
||||
buildJsonInit('POST', payload),
|
||||
'重生成视觉小说历史失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<VisualNovelRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'regenerate',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '重生成视觉小说历史失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listVisualNovelSaveArchives(profileId?: string | null) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
VisualNovelRunSnapshot,
|
||||
VisualNovelRuntimeStreamEvent,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
type VisualNovelRuntimeSseOptions = {
|
||||
fallbackMessage: string;
|
||||
@@ -9,65 +10,6 @@ type VisualNovelRuntimeSseOptions = {
|
||||
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVisualNovelRuntimeEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
@@ -115,59 +57,19 @@ export async function readVisualNovelRuntimeRunFromSse(
|
||||
response: Response,
|
||||
options: VisualNovelRuntimeSseOptions,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalRun: VisualNovelRunSnapshot | null = null;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextRun = handleVisualNovelRuntimeEvent(event, options);
|
||||
if (nextRun) {
|
||||
finalRun = nextRun;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
const nextRun = handleVisualNovelRuntimeEvent(event, options);
|
||||
if (nextRun) {
|
||||
finalRun = nextRun;
|
||||
}
|
||||
});
|
||||
|
||||
if (!finalRun) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -27,6 +27,10 @@ beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
|
||||
await import('./woodenFishClient');
|
||||
|
||||
@@ -51,15 +55,85 @@ test('wooden fish list works uses creation works endpoint', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish delete work uses creation works endpoint', async () => {
|
||||
test('wooden fish start run uses runtime guest json skeleton', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
await woodenFishClient.deleteWork('wooden-fish-profile-1');
|
||||
await woodenFishClient.startRun('profile/1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/wooden-fish/works/wooden-fish-profile-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除敲木鱼作品失败',
|
||||
'/api/runtime/wooden-fish/runs',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({ profileId: 'profile/1' }),
|
||||
}),
|
||||
'启动敲木鱼运行态失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ retryUnsafeMethods: true }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish checkpoint run keeps client event id local to the client', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1780000000000);
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
await woodenFishClient.checkpointRun(
|
||||
'run/1',
|
||||
{
|
||||
totalTapCount: 12,
|
||||
wordCounters: [{ text: '功德', count: 3 }],
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [, init] = requestJsonMock.mock.calls[0];
|
||||
const body = JSON.parse(init.body);
|
||||
expect(requestJsonMock.mock.calls[0][0]).toBe(
|
||||
'/api/runtime/wooden-fish/runs/run%2F1/checkpoint',
|
||||
);
|
||||
expect(body).toEqual({
|
||||
totalTapCount: 12,
|
||||
wordCounters: [{ text: '功德', count: 3 }],
|
||||
clientEventId: 'checkpoint-run/1-1780000000000',
|
||||
});
|
||||
expect(body).not.toHaveProperty('runId');
|
||||
expect(body).not.toHaveProperty('checkpointAtMs');
|
||||
});
|
||||
|
||||
test('wooden fish finish run keeps finish event id local to the client', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1780000000001);
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
await woodenFishClient.finishRun(
|
||||
'run/1',
|
||||
{
|
||||
totalTapCount: 18,
|
||||
wordCounters: [{ text: '清净', count: 2 }],
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [, init] = requestJsonMock.mock.calls[0];
|
||||
const body = JSON.parse(init.body);
|
||||
expect(requestJsonMock.mock.calls[0][0]).toBe(
|
||||
'/api/runtime/wooden-fish/runs/run%2F1/finish',
|
||||
);
|
||||
expect(body).toEqual({
|
||||
totalTapCount: 18,
|
||||
wordCounters: [{ text: '清净', count: 2 }],
|
||||
clientEventId: 'finish-run/1-1780000000001',
|
||||
});
|
||||
expect(body).not.toHaveProperty('runId');
|
||||
expect(body).not.toHaveProperty('finishedAtMs');
|
||||
});
|
||||
|
||||
@@ -13,17 +13,14 @@ import type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions';
|
||||
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
|
||||
@@ -58,8 +55,8 @@ export type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorksResponse,
|
||||
};
|
||||
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
|
||||
export type WoodenFishSessionSnapshot = WoodenFishSessionSnapshotResponse;
|
||||
@@ -245,23 +242,14 @@ export async function startWoodenFishRuntimeRun(
|
||||
profileId: string,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动敲木鱼运行态失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<WoodenFishRunResponse>({
|
||||
url: buildRuntimeApiPath(WOODEN_FISH_RUNTIME_API_BASE, 'runs'),
|
||||
method: 'POST',
|
||||
jsonBody: { profileId },
|
||||
fallbackMessage: '启动敲木鱼运行态失败',
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkpointWoodenFishRun(
|
||||
@@ -269,28 +257,24 @@ export async function checkpointWoodenFishRun(
|
||||
payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload: WoodenFishCheckpointRunRequest = {
|
||||
...payload,
|
||||
clientEventId: `checkpoint-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/checkpoint`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'保存敲木鱼进度失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<WoodenFishRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
WOODEN_FISH_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'checkpoint',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: requestPayload,
|
||||
fallbackMessage: '保存敲木鱼进度失败',
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function finishWoodenFishRun(
|
||||
@@ -298,28 +282,24 @@ export async function finishWoodenFishRun(
|
||||
payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload: WoodenFishFinishRunRequest = {
|
||||
...payload,
|
||||
clientEventId: `finish-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/finish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'结束敲木鱼运行失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<WoodenFishRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
WOODEN_FISH_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'finish',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: requestPayload,
|
||||
fallbackMessage: '结束敲木鱼运行失败',
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export const woodenFishClient = {
|
||||
|
||||
Reference in New Issue
Block a user