Files
Genarrative/src/services/miniGameDraftGenerationProgress.ts
高物 a45e358e83 Add generationStatus and match3d/runtime fixes
Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
2026-05-16 22:59:02 +08:00

783 lines
22 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 { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
CreateMatch3DSessionRequest,
Match3DAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/match3dAgent';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/puzzleAgentSession';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
} from '../../packages/shared/src/contracts/runtime';
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
export type MiniGameDraftGenerationKind =
| 'puzzle'
| 'big-fish'
| 'square-hole'
| 'match3d'
| 'baby-object-match';
export type MiniGameDraftGenerationPhase =
| 'idle'
| 'compile'
| 'puzzle-level-name'
| 'big-fish-draft'
| 'big-fish-levels'
| 'big-fish-runtime'
| 'square-hole-draft'
| 'square-hole-cover'
| 'square-hole-shapes'
| 'square-hole-ready'
| 'match3d-work-title'
| 'match3d-item-names'
| 'match3d-background-prompt'
| 'match3d-material-sheet'
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-generate-views'
| 'match3d-background-image'
| 'match3d-write-draft'
| 'match3d-ready'
| 'baby-object-draft'
| 'baby-object-images'
| 'baby-object-ready'
| 'puzzle-images'
| 'puzzle-ui-background'
| 'puzzle-select-image'
| 'ready'
| 'failed';
export type MiniGameDraftGenerationState = {
kind: MiniGameDraftGenerationKind;
phase: MiniGameDraftGenerationPhase;
startedAtMs: number;
finishedAtMs?: number;
completedAssetCount: number;
totalAssetCount: number;
error: string | null;
};
type MiniGameStepDefinition = {
id: MiniGameDraftGenerationPhase;
label: string;
detail: string;
weight: number;
};
type MiniGameAnchorSource = {
key: string;
label: string;
value: string;
};
const PUZZLE_STEPS = [
{
id: 'compile',
label: '编译首关草稿',
detail: '读取画面描述,建立可编辑草稿与首关结构。',
weight: 10,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据画面描述和图像语义整理首关题目。',
weight: 8,
},
{
id: 'puzzle-images',
label: '生成首关画面',
detail: '调用图片模型生成适合切块的正方形首图。',
weight: 42,
},
{
id: 'puzzle-ui-background',
label: '生成UI背景',
detail: '生成不含槽位和控件的 9:16 纯背景。',
weight: 32,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '写入首图、UI背景和首关数据。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_ESTIMATED_WAIT_MS = 132_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
const PUZZLE_PHASE_TIMELINE: Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-images'
| 'puzzle-ui-background'
| 'puzzle-select-image'
>;
durationMs: number;
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 70_000 },
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];
const BIG_FISH_STEPS = [
{
id: 'big-fish-draft',
label: '整理玩法骨架',
detail: '收拢玩法承诺、成长阶梯与风险节奏。',
weight: 30,
},
{
id: 'big-fish-levels',
label: '编译等级蓝图',
detail: '生成每级角色描述、形象描述与动作描述。',
weight: 45,
},
{
id: 'big-fish-runtime',
label: '校准场地与参数',
detail: '整理背景蓝图与运行参数,准备结果页。',
weight: 25,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const SQUARE_HOLE_STEPS = [
{
id: 'square-hole-draft',
label: '整理玩法草稿',
detail: '收拢题材、展示选项与洞口选项。',
weight: 28,
},
{
id: 'square-hole-cover',
label: '生成封面与背景',
detail: '生成作品封面和运行背景。',
weight: 32,
},
{
id: 'square-hole-shapes',
label: '生成选项贴图',
detail: '为展示选项与洞口选项生成贴图。',
weight: 40,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_STEPS = [
{
id: 'match3d-work-title',
label: '建立草稿存档',
detail: '创建可恢复作品草稿,锁定本次题材和难度。',
weight: 8,
},
{
id: 'match3d-item-names',
label: '生成作品计划',
detail: '生成游戏名称、物品名称与标签。',
weight: 10,
},
{
id: 'match3d-background-prompt',
label: '生成背景提示词',
detail: '整理纯背景图与容器 UI 图提示词。',
weight: 6,
},
{
id: 'match3d-material-sheet',
label: '分批生成素材图',
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
weight: 24,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成每个物品的五个视角。',
weight: 12,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '上传每个物品的 2D 五视角素材。',
weight: 14,
},
{
id: 'match3d-generate-views',
label: '校验素材结构',
detail: '确认物品顺序和五视角图片。',
weight: 8,
},
{
id: 'match3d-background-image',
label: '生成UI背景',
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
weight: 16,
},
{
id: 'match3d-write-draft',
label: '写入草稿页',
detail: '保存素材、背景、容器和作品草稿。',
weight: 2,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_ESTIMATED_WAIT_MS = 510_000;
const MATCH3D_PHASE_ORDER: Partial<
Record<MiniGameDraftGenerationPhase, number>
> = {
'match3d-work-title': 0,
'match3d-item-names': 1,
'match3d-background-prompt': 2,
'match3d-material-sheet': 3,
'match3d-slice-images': 4,
'match3d-upload-images': 5,
'match3d-generate-views': 6,
'match3d-background-image': 7,
'match3d-write-draft': 8,
};
const BABY_OBJECT_MATCH_STEPS = [
{
id: 'baby-object-draft',
label: '整理识物草稿',
detail: '写入两个物品名称与寓教于乐标签。',
weight: 22,
},
{
id: 'baby-object-images',
label: '生成游戏素材',
detail: '生成物品图、背景、礼物盒、篮子和界面包装。',
weight: 68,
},
{
id: 'baby-object-ready',
label: '准备结果页',
detail: '校验草稿字段并进入结果页。',
weight: 10,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'puzzle') {
return PUZZLE_STEPS;
}
if (kind === 'square-hole') {
return SQUARE_HOLE_STEPS;
}
if (kind === 'match3d') {
return MATCH3D_STEPS;
}
if (kind === 'baby-object-match') {
return BABY_OBJECT_MATCH_STEPS;
}
return BIG_FISH_STEPS;
}
function getActiveStepIndex(
steps: ReadonlyArray<MiniGameStepDefinition>,
phase: MiniGameDraftGenerationPhase,
) {
if (phase === 'ready') {
return steps.length - 1;
}
const index = steps.findIndex((step) => step.id === phase);
return index >= 0 ? index : 0;
}
function buildMiniGameProgressSteps(
steps: ReadonlyArray<MiniGameStepDefinition>,
activeStepIndex: number,
state: MiniGameDraftGenerationState,
activeStepProgressRatio: number,
) {
return steps.map((step, index) => {
const isCompleted = state.phase === 'ready' || index < activeStepIndex;
const isActive =
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted
? 1
: isAssetStep
? state.completedAssetCount
: isActive
? activeStepProgressRatio
: 0,
total: isAssetStep ? state.totalAssetCount : 1,
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
} satisfies CustomWorldGenerationStep;
});
}
export function createMiniGameDraftGenerationState(
kind: MiniGameDraftGenerationKind,
): MiniGameDraftGenerationState {
return {
kind,
phase:
kind === 'big-fish'
? 'big-fish-draft'
: kind === 'square-hole'
? 'square-hole-draft'
: kind === 'match3d'
? 'match3d-work-title'
: kind === 'baby-object-match'
? 'baby-object-draft'
: 'compile',
startedAtMs: Date.now(),
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
}
function resolveBigFishPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 4_500) {
return 'big-fish-runtime';
}
if (elapsedMs >= 1_800) {
return 'big-fish-levels';
}
return 'big-fish-draft';
}
function resolveSquareHolePhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 6_500) {
return 'square-hole-shapes';
}
if (elapsedMs >= 2_400) {
return 'square-hole-cover';
}
return 'square-hole-draft';
}
function resolveMatch3DPhaseByElapsedMs(
elapsedMs: number,
currentPhase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
const elapsedPhase =
elapsedMs >= 492_000
? 'match3d-write-draft'
: elapsedMs >= 370_000
? 'match3d-background-image'
: elapsedMs >= 340_000
? 'match3d-generate-views'
: elapsedMs >= 260_000
? 'match3d-upload-images'
: elapsedMs >= 210_000
? 'match3d-slice-images'
: elapsedMs >= 28_000
? 'match3d-material-sheet'
: elapsedMs >= 12_000
? 'match3d-background-prompt'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
}
function resolveBabyObjectMatchPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 330_000) {
return 'baby-object-ready';
}
if (elapsedMs >= 8_000) {
return 'baby-object-images';
}
return 'baby-object-draft';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0;
for (const item of PUZZLE_PHASE_TIMELINE) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
return {
phase: item.phase,
activeStepProgressRatio: Math.max(
0,
Math.min(1, elapsedInPhase / item.durationMs),
),
};
}
elapsedBeforePhase += item.durationMs;
}
return {
phase: 'puzzle-select-image' as const,
activeStepProgressRatio: 1,
};
}
export function buildMiniGameDraftGenerationProgress(
state: MiniGameDraftGenerationState | null,
nowMs = Date.now(),
): CustomWorldGenerationProgress | null {
if (!state) {
return null;
}
const effectiveNowMs =
typeof state.finishedAtMs === 'number' && Number.isFinite(state.finishedAtMs)
? state.finishedAtMs
: nowMs;
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
const puzzleTimeline =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
: null;
const normalizedState =
puzzleTimeline != null
? {
...state,
phase: puzzleTimeline.phase,
}
: state.kind === 'big-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
}
: state.kind === 'square-hole' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
}
: state.kind === 'match3d' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
}
: state.kind === 'baby-object-match' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
}
: state;
const steps = getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const completedWeight = steps
.slice(
0,
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
)
.reduce((sum, step) => sum + step.weight, 0);
const activeStep = steps[activeStepIndex] ?? steps[0];
const assetRatio =
normalizedState.totalAssetCount > 0
? Math.min(
1,
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
)
: normalizedState.phase === 'ready'
? 1
: normalizedState.kind === 'puzzle'
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
: normalizedState.kind === 'big-fish'
? 0.55
: normalizedState.kind === 'square-hole'
? 0.42
: normalizedState.kind === 'match3d'
? 0.5
: normalizedState.kind === 'baby-object-match'
? 0.52
: 0;
const overallProgress =
normalizedState.phase === 'failed'
? Math.max(1, completedWeight)
: normalizedState.phase === 'ready'
? 100
: completedWeight + activeStep.weight * assetRatio;
const cappedOverallProgress =
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
? overallProgress
: normalizedState.kind === 'puzzle'
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
: overallProgress;
return {
phaseId: normalizedState.phase,
phaseLabel:
normalizedState.phase === 'failed'
? '生成失败'
: normalizedState.phase === 'ready'
? '生成完成'
: activeStep.label,
phaseDetail:
normalizedState.error ??
(normalizedState.phase === 'ready'
? normalizedState.kind === 'big-fish'
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
: normalizedState.kind === 'match3d'
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
: normalizedState.kind === 'baby-object-match'
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
overallProgress: clampProgress(cappedOverallProgress),
completedWeight: clampProgress(cappedOverallProgress),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
normalizedState.phase === 'ready'
? 0
: normalizedState.kind === 'puzzle'
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'big-fish'
? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: normalizedState.kind === 'match3d'
? Math.max(0, MATCH3D_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'baby-object-match'
? Math.max(
0,
BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs,
)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(
steps,
activeStepIndex,
normalizedState,
assetRatio,
),
};
}
export function buildPuzzleGenerationAnchorEntries(
session: PuzzleAgentSessionSnapshot | null | undefined,
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'picture-description',
label: '画面描述',
value:
formPayload?.pictureDescription?.trim() ||
formPayload?.seedText?.trim() ||
session.draft?.levels?.[0]?.pictureDescription ||
session.anchorPack.visualSubject.value,
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
export function buildBigFishGenerationAnchorEntries(
session: BigFishSessionSnapshotResponse | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const draft = session.draft;
const assetReadyCount = session.assetSlots.filter(
(slot) => slot.status === 'ready',
).length;
const entries: Array<MiniGameAnchorSource | null> = [
session.anchorPack.gameplayPromise,
session.anchorPack.ecologyVisualTheme,
session.anchorPack.growthLadder,
session.anchorPack.riskTempo,
draft
? {
key: 'level-characters',
label: '角色描述',
value: draft.levels
.map(
(level) =>
`Lv.${level.level} ${level.name}${level.oneLineFantasy}`,
)
.join('\n'),
}
: null,
draft
? {
key: 'asset-coverage',
label: '图片与动作',
value: `已生成 ${assetReadyCount}/${session.assetSlots.length} 个资产`,
}
: null,
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
export function buildMatch3DGenerationAnchorEntries(
session: Match3DAgentSessionSnapshot | null | undefined,
formPayload: CreateMatch3DSessionRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
if (!session && !formPayload) {
return [];
}
const config = session?.config;
const clearCount = formPayload?.clearCount ?? config?.clearCount ?? null;
const difficulty = formPayload?.difficulty ?? config?.difficulty ?? null;
const itemCount = resolveMatch3DGeneratedItemCount(clearCount, difficulty);
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'match3d-theme',
label: '题材',
value:
formPayload?.themeText?.trim() ||
config?.themeText?.trim() ||
session?.anchorPack.theme.value ||
'',
},
{
key: 'match3d-items',
label: '物品数量',
value: `${itemCount}`,
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
function resolveMatch3DGeneratedItemCount(
clearCount: number | null | undefined,
difficulty: number | null | undefined,
) {
const roundToSheet = (count: number) => Math.ceil(count / 5) * 5;
if (clearCount === 8) return roundToSheet(3);
if (clearCount === 12) return roundToSheet(9);
if (clearCount === 16) return roundToSheet(15);
if (clearCount === 20 || clearCount === 21) return roundToSheet(21);
const normalizedDifficulty =
typeof difficulty === 'number' && Number.isFinite(difficulty)
? Math.max(1, Math.min(10, Math.round(difficulty)))
: 4;
if (normalizedDifficulty <= 2) return roundToSheet(3);
if (normalizedDifficulty <= 4) return roundToSheet(9);
if (normalizedDifficulty <= 6) return roundToSheet(15);
return roundToSheet(21);
}
export function buildBabyObjectMatchGenerationAnchorEntries(
formPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
draft: BabyObjectMatchDraft | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const itemNames =
formPayload?.itemAName?.trim() || formPayload?.itemBName?.trim()
? [formPayload.itemAName.trim(), formPayload.itemBName.trim()]
: (draft?.itemNames ?? []);
return itemNames.filter(Boolean).map((value, index) => ({
id: `baby-object-item-${index + 1}`,
label: `物品 ${index + 1}`,
value,
}));
}
export function buildSquareHoleGenerationAnchorEntries(
session: SquareHoleSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const draft = session.draft;
const shapeCount =
draft?.shapeOptions.filter((option) => option.imageSrc?.trim()).length ??
session.config.shapeOptions.filter((option) => option.imageSrc?.trim())
.length;
const totalShapeCount =
draft?.shapeOptions.length || session.config.shapeOptions.length;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'square-hole-title',
label: '作品名称',
value: draft?.gameName || `${session.config.themeText}方洞挑战`,
},
{
key: 'square-hole-theme',
label: '题材与规则',
value: `${session.config.themeText}${session.config.twistRule}`,
},
{
key: 'square-hole-options',
label: '选项资产',
value:
totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '',
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}