Files
Genarrative/src/components/platform-entry/platformDraftGenerationShelfModel.ts

844 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/customWorldWorkSummary';
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 { 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 {
type CreationWorkShelfItem,
type CreationWorkShelfKind,
type CreationWorkShelfRuntimeState,
resolvePuzzleWorkCoverImageSrc,
} from '../custom-world-home/creationWorkShelf';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
} from './platformPuzzleIdentityModel';
export {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
} from './platformPuzzleIdentityModel';
export type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed';
export type DraftGenerationNotice = {
status: DraftGenerationNoticeStatus;
seen: boolean;
completedAtMs?: number;
message?: string;
};
export type DraftGenerationNoticeMap = Record<string, DraftGenerationNotice>;
export type PendingDraftShelfState = {
status: DraftGenerationNoticeStatus;
seen: boolean;
updatedAt: string;
title?: string;
summary?: string;
};
export type PendingDraftShelfKind = Exclude<CreationWorkShelfKind, 'rpg'>;
export type PendingDraftShelfMap = Partial<
Record<PendingDraftShelfKind, Record<string, PendingDraftShelfState>>
>;
export type PendingDraftShelfMetadata = {
title?: string | null;
summary?: string | null;
};
export type PlatformDraftGenerationVisibleShelfSources = {
rpgItems: readonly CustomWorldWorkSummary[];
bigFishItems: readonly BigFishWorkSummary[];
jumpHopItems: readonly JumpHopWorkSummaryResponse[];
woodenFishItems: readonly WoodenFishWorkSummaryResponse[];
match3dItems: readonly Match3DWorkSummary[];
squareHoleItems: readonly SquareHoleWorkSummary[];
puzzleItems: readonly PuzzleWorkSummary[];
visualNovelItems: readonly VisualNovelWorkSummary[];
barkBattleItems: readonly BarkBattleWorkSummary[];
babyObjectMatchItems: readonly BabyObjectMatchDraft[];
};
export function buildDraftNoticeKey(
kind: CreationWorkShelfKind,
id: string,
) {
return `${kind}:${id}`;
}
export function collectDraftNoticeKeys(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
) {
const keys = new Set<string>();
for (const id of ids) {
const normalizedId = id?.trim();
if (normalizedId) {
keys.add(buildDraftNoticeKey(kind, normalizedId));
}
}
return Array.from(keys);
}
export function normalizeDraftNoticeId(id: string | null | undefined) {
return id?.trim() || null;
}
export function normalizePendingDraftShelfLookupId(
kind: PendingDraftShelfKind,
id: string | null | undefined,
) {
const normalizedId = normalizeDraftNoticeId(id);
if (!normalizedId) {
return null;
}
const noticePrefix = `${kind}:`;
if (!normalizedId.startsWith(noticePrefix)) {
return normalizedId;
}
return normalizeDraftNoticeId(normalizedId.slice(noticePrefix.length));
}
export function createPendingDraftShelfState(
status: DraftGenerationNoticeStatus,
seen = false,
updatedAt = new Date().toISOString(),
metadata?: PendingDraftShelfMetadata,
): PendingDraftShelfState {
const title = metadata?.title?.trim();
const summary = metadata?.summary?.trim();
return {
status,
seen,
updatedAt,
...(title ? { title } : {}),
...(summary ? { summary } : {}),
};
}
export function buildDraftFailedShelfSummary(kind: CreationWorkShelfKind) {
switch (kind) {
case 'puzzle':
return '拼图草稿生成失败,可重新打开处理。';
case 'match3d':
return '玩法素材生成失败,可重新打开处理。';
case 'big-fish':
return '草稿生成失败,可重新打开处理。';
case 'square-hole':
return '挑战素材生成失败,可重新打开处理。';
case 'jump-hop':
return '跳一跳玩法草稿生成失败,可重新打开处理。';
case 'wooden-fish':
return '敲木鱼草稿生成失败,可重新打开处理。';
case 'visual-novel':
return '视觉小说草稿生成失败,可重新打开处理。';
case 'bark-battle':
return '声浪竞技素材生成失败,可重新打开处理。';
case 'baby-object-match':
return '宝贝识物草稿生成失败,可重新打开处理。';
default:
return '草稿生成失败,可重新打开处理。';
}
}
export function buildDraftCompletionDialogSource(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
): string {
const sourceId = pickDraftCompletionDialogSourceId(ids);
switch (kind) {
case 'rpg':
return formatDraftTaskCompletionSource('RPG 草稿', sourceId);
case 'big-fish':
return formatDraftTaskCompletionSource('大鱼吃小鱼草稿', sourceId);
case 'match3d':
return formatDraftTaskCompletionSource('抓大鹅草稿', sourceId);
case 'square-hole':
return formatDraftTaskCompletionSource('方洞挑战草稿', sourceId);
case 'jump-hop':
return formatDraftTaskCompletionSource('跳一跳草稿', sourceId);
case 'wooden-fish':
return formatDraftTaskCompletionSource('敲木鱼草稿', sourceId);
case 'puzzle':
return formatDraftTaskCompletionSource('拼图草稿', sourceId);
case 'visual-novel':
return formatDraftTaskCompletionSource('视觉小说草稿', sourceId);
case 'bark-battle':
return formatDraftTaskCompletionSource('汪汪声浪草稿', sourceId);
case 'baby-object-match':
return formatDraftTaskCompletionSource('宝贝识物草稿', sourceId);
}
return formatDraftTaskCompletionSource('创作草稿', sourceId);
}
export function isDraftShelfSummaryPlaceholder(
value: string | null | undefined,
) {
const normalized = value?.trim();
if (!normalized) {
return true;
}
return /^(|.*$|$)/u.test(
normalized,
);
}
export function isPersistedDraftGenerating(value: string | null | undefined) {
return value?.trim() === 'generating';
}
export function isPersistedDraftFailed(value: string | null | undefined) {
const normalized = value?.trim();
return normalized === 'failed' || normalized === 'partial_failed';
}
export function getGenerationNoticeShelfKeys(
item: CreationWorkShelfItem,
): string[] {
switch (item.source.kind) {
case 'rpg':
return collectDraftNoticeKeys('rpg', [
item.id,
item.source.item.workId,
item.source.item.sessionId,
item.source.item.profileId,
]);
case 'big-fish':
return collectDraftNoticeKeys('big-fish', [
item.id,
item.source.item.workId,
item.source.item.sourceSessionId,
]);
case 'match3d':
return collectDraftNoticeKeys('match3d', [
item.id,
item.source.item.workId,
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
case 'square-hole':
return collectDraftNoticeKeys('square-hole', [
item.id,
item.source.item.workId,
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
case 'jump-hop':
return collectDraftNoticeKeys('jump-hop', [
item.id,
item.source.item.workId,
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
case 'puzzle':
return collectDraftNoticeKeys('puzzle', [
item.id,
item.source.item.workId,
item.source.item.profileId,
item.source.item.sourceSessionId,
buildPuzzleResultWorkId(item.source.item.sourceSessionId),
buildPuzzleResultProfileId(item.source.item.sourceSessionId),
]);
case 'visual-novel':
return collectDraftNoticeKeys('visual-novel', [
item.id,
item.source.item.profileId,
]);
case 'baby-object-match':
return collectDraftNoticeKeys('baby-object-match', [
item.id,
item.source.item.profileId,
item.source.item.draftId,
]);
case 'bark-battle':
return collectDraftNoticeKeys('bark-battle', [
item.id,
item.source.item.workId,
item.source.item.draftId,
]);
case 'wooden-fish':
return collectDraftNoticeKeys('wooden-fish', [
item.id,
item.source.item.workId,
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
default:
return [];
}
}
export function getDraftGenerationNotice(
notices: DraftGenerationNoticeMap,
keys: readonly string[],
) {
for (const key of keys) {
const notice = notices[key];
if (notice) {
return notice;
}
}
return null;
}
export function getPendingDraftShelfState(
pendingShelfItems: PendingDraftShelfMap,
kind: PendingDraftShelfKind,
keys: readonly string[],
) {
const entries = pendingShelfItems[kind];
if (!entries) {
return null;
}
for (const key of keys) {
const normalizedKey = normalizePendingDraftShelfLookupId(kind, key);
const pending = normalizedKey ? entries[normalizedKey] : null;
if (pending) {
return pending;
}
}
return null;
}
export function hasDraftGenerationNoticeStatus(
notices: DraftGenerationNoticeMap,
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
status: DraftGenerationNoticeStatus,
) {
return collectDraftNoticeKeys(kind, ids).some(
(key) => notices[key]?.status === status,
);
}
export function hasUnreadReadyDraftGenerationNotice(
notices: DraftGenerationNoticeMap,
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
) {
return collectDraftNoticeKeys(kind, ids).some((key) => {
const notice = notices[key];
return notice?.status === 'ready' && !notice.seen;
});
}
export function buildCreationWorkShelfRuntimeState(params: {
item: CreationWorkShelfItem;
notices: DraftGenerationNoticeMap;
pendingShelfItems: PendingDraftShelfMap;
}): CreationWorkShelfRuntimeState {
const { item, notices, pendingShelfItems } = params;
const noticeKeys = getGenerationNoticeShelfKeys(item);
const notice = getDraftGenerationNotice(notices, noticeKeys);
if (notice?.status === 'failed') {
const failedSummary = buildDraftFailedShelfSummary(item.source.kind);
const pending =
item.source.kind === 'rpg'
? null
: getPendingDraftShelfState(
pendingShelfItems,
item.source.kind,
noticeKeys,
);
const pendingSummary = pending?.summary?.trim();
return {
isGenerating: false,
hasGenerationFailure: true,
generationFailureSummary: failedSummary,
hasUnreadUpdate: false,
suppressPersistedGenerating: true,
titleOverride:
item.source.kind === 'puzzle' &&
item.status === 'draft' &&
!item.source.item.workTitle?.trim()
? '拼图草稿'
: undefined,
summaryOverride: isDraftShelfSummaryPlaceholder(item.summary)
? (pendingSummary ?? failedSummary)
: undefined,
};
}
if (
item.source.kind === 'puzzle' &&
isPersistedDraftFailed(item.source.item.generationStatus)
) {
const failedSummary = buildDraftFailedShelfSummary('puzzle');
return {
isGenerating: false,
hasGenerationFailure: true,
generationFailureSummary: failedSummary,
hasUnreadUpdate: false,
suppressPersistedGenerating: true,
titleOverride:
item.status === 'draft' && !item.source.item.workTitle?.trim()
? '拼图草稿'
: undefined,
summaryOverride: isDraftShelfSummaryPlaceholder(item.summary)
? failedSummary
: undefined,
};
}
const isNoticeGenerating =
notice?.status === 'generating' &&
(item.source.kind !== 'puzzle' ||
!resolvePuzzleWorkCoverImageSrc(item.source.item));
return {
isGenerating: isNoticeGenerating || item.isGenerating === true,
hasUnreadUpdate: notice?.status === 'ready' && !notice.seen,
};
}
export function collectVisibleDraftNoticeKeys(
sources: PlatformDraftGenerationVisibleShelfSources,
) {
return [
...sources.rpgItems.flatMap((item) =>
collectDraftNoticeKeys('rpg', [
item.workId,
item.sessionId,
item.profileId,
]),
),
...sources.bigFishItems.flatMap((item) =>
collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]),
),
...sources.jumpHopItems.flatMap((item) =>
collectDraftNoticeKeys('jump-hop', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
),
...sources.woodenFishItems.flatMap((item) =>
collectDraftNoticeKeys('wooden-fish', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
),
...sources.match3dItems.flatMap((item) =>
collectDraftNoticeKeys('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
),
...sources.squareHoleItems.flatMap((item) =>
collectDraftNoticeKeys('square-hole', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
),
...sources.puzzleItems.flatMap((item) =>
collectDraftNoticeKeys('puzzle', [
item.workId,
item.profileId,
item.sourceSessionId,
buildPuzzleResultWorkId(item.sourceSessionId),
buildPuzzleResultProfileId(item.sourceSessionId),
]),
),
...sources.visualNovelItems.flatMap((item) =>
collectDraftNoticeKeys('visual-novel', [item.profileId]),
),
...sources.barkBattleItems.flatMap((item) =>
collectDraftNoticeKeys('bark-battle', [item.workId, item.draftId]),
),
...sources.babyObjectMatchItems.flatMap((item) =>
collectDraftNoticeKeys('baby-object-match', [
item.profileId,
item.draftId,
]),
),
];
}
export function hasUnreadDraftGenerationUpdates(
notices: DraftGenerationNoticeMap,
visibleKeys: readonly string[],
) {
return visibleKeys.some((key) => {
const notice = notices[key];
return notice?.status === 'ready' && !notice.seen;
});
}
export function buildPendingBigFishWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly BigFishWorkSummary[],
): BigFishWorkSummary[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => {
const isFailed = state.status === 'failed';
return {
workId: `big-fish-work-${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: '',
authorDisplayName: '',
title: '大鱼吃小鱼草稿',
subtitle: isFailed ? '生成失败待重试' : '草稿生成中',
summary: isFailed
? '草稿生成失败,可重新打开处理。'
: '正在生成玩法草稿。',
coverImageSrc: null,
status: 'draft',
updatedAt: state.updatedAt,
publishedAt: null,
publishReady: false,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
playCount: 0,
remixCount: 0,
likeCount: 0,
};
});
}
export function buildPendingJumpHopWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly JumpHopWorkSummaryResponse[],
): JumpHopWorkSummaryResponse[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => {
const generationStatus =
state.status === 'failed'
? 'failed'
: state.status === 'generating'
? 'generating'
: 'ready';
return {
runtimeKind: 'jump-hop',
workId: `jump-hop-work-${sessionId}`,
profileId: `jump-hop-profile-${sessionId}`,
ownerUserId: '',
sourceSessionId: sessionId,
workTitle: '跳一跳草稿',
workDescription:
state.status === 'failed'
? '跳一跳玩法草稿生成失败,可重新打开处理。'
: '正在生成跳一跳玩法草稿。',
themeTags: [],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
publishReady: false,
generationStatus,
};
});
}
export function buildPendingWoodenFishWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly WoodenFishWorkSummaryResponse[],
): WoodenFishWorkSummaryResponse[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => {
const generationStatus =
state.status === 'failed'
? 'failed'
: state.status === 'generating'
? 'generating'
: 'ready';
return {
runtimeKind: 'wooden-fish',
workId: `wooden-fish-work-${sessionId}`,
profileId: sessionId,
ownerUserId: '',
sourceSessionId: sessionId,
workTitle: '敲木鱼草稿',
workDescription:
state.status === 'failed'
? '敲木鱼草稿生成失败,可重新打开处理。'
: '正在生成敲木鱼草稿。',
themeTags: ['敲木鱼'],
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
publishReady: false,
generationStatus,
};
});
}
export function buildPendingMatch3DWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly Match3DWorkSummary[],
): Match3DWorkSummary[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => {
const themeText = state.summary?.trim() || state.title?.trim() || '';
const fallbackSummary =
state.status === 'failed'
? '玩法素材生成失败,可重新打开处理。'
: '正在生成玩法素材。';
return {
workId: `match3d-work-${sessionId}`,
profileId: sessionId,
ownerUserId: '',
sourceSessionId: sessionId,
gameName: '抓大鹅草稿',
themeText,
summary: themeText || fallbackSummary,
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 0,
difficulty: 0,
publicationStatus: 'draft',
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
publishReady: false,
generationStatus:
state.status === 'failed'
? 'failed'
: state.status === 'generating'
? 'generating'
: 'ready',
generatedItemAssets: [],
};
});
}
export function buildPendingSquareHoleWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly SquareHoleWorkSummary[],
): SquareHoleWorkSummary[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => ({
workId: `square-hole-work-${sessionId}`,
profileId: sessionId,
ownerUserId: '',
sourceSessionId: sessionId,
gameName: '方洞挑战草稿',
themeText: '',
twistRule: '',
summary:
state.status === 'failed'
? '挑战素材生成失败,可重新打开处理。'
: '正在生成挑战素材。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 0,
difficulty: 0,
publicationStatus: 'draft',
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
publishReady: false,
}));
}
export function buildPendingPuzzleWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly PuzzleWorkSummary[],
): PuzzleWorkSummary[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => {
const profileId =
buildPuzzleResultProfileId(sessionId) ?? `puzzle-profile-${sessionId}`;
const title = state.title?.trim() || '拼图草稿';
const summary =
state.summary?.trim() ||
(state.status === 'failed'
? '拼图草稿生成失败,可重新打开处理。'
: '正在生成拼图草稿。');
return {
workId:
buildPuzzleResultWorkId(sessionId) ?? `puzzle-work-${sessionId}`,
profileId,
ownerUserId: '',
sourceSessionId: sessionId,
authorDisplayName: '',
workTitle: title,
workDescription: summary,
levelName: title,
summary,
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: state.updatedAt,
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus:
state.status === 'generating'
? 'generating'
: state.status === 'failed'
? 'failed'
: 'ready',
levels: [],
};
});
}
export function buildPendingVisualNovelWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly VisualNovelWorkSummary[],
): VisualNovelWorkSummary[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([profileId]) =>
existingItems.every((item) => item.profileId !== profileId),
)
.map(([profileId, state]) => ({
runtimeKind: 'visual-novel',
profileId,
ownerUserId: '',
title: '视觉小说草稿',
description:
state.status === 'failed'
? '视觉小说草稿生成失败,可重新打开处理。'
: '正在生成视觉小说草稿。',
coverImageSrc: null,
tags: [],
publishStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
}));
}
export function buildPendingBarkBattleWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([id]) =>
existingItems.every((item) => item.workId !== id && item.draftId !== id),
)
.map(([id, state]) => ({
workId: id,
draftId: id,
ownerUserId: '',
authorDisplayName: '',
title: '汪汪声浪草稿',
summary:
state.status === 'failed'
? '声浪竞技素材生成失败,可重新打开处理。'
: '正在生成声浪竞技素材。',
themeDescription: '',
playerImageDescription: '',
opponentImageDescription: '',
onomatopoeia: [],
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'draft',
generationStatus:
state.status === 'generating'
? 'pending_assets'
: state.status === 'failed'
? 'partial_failed'
: 'ready',
publishReady: false,
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
}));
}
function pickDraftCompletionDialogSourceId(
ids: Array<string | null | undefined>,
) {
const normalizedIds = ids
.map((id) => id?.trim() ?? '')
.filter((id) => Boolean(id));
return (
normalizedIds.find((id) => /session/i.test(id)) ??
normalizedIds.find((id) => /work/i.test(id)) ??
normalizedIds.find((id) => /draft/i.test(id)) ??
normalizedIds.find((id) => /run/i.test(id)) ??
normalizedIds.find((id) => /profile/i.test(id)) ??
normalizedIds[0] ??
null
);
}
function formatDraftTaskCompletionSource(label: string, id?: string | null) {
const normalizedId = id?.trim();
return normalizedId ? `${label} ${normalizedId}` : label;
}