Files
Genarrative/src/services/miniGameDraftGenerationProgress.ts
五香丸子 dfa59aaf31 Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao
# Conflicts:
#	server-rs/crates/api-server/src/jump_hop.rs
#	server-rs/crates/api-server/src/modules/jump_hop.rs
2026-06-06 21:04:46 +08:00

1478 lines
43 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 {
PuzzleClearSessionSnapshotResponse,
PuzzleClearWorkspaceCreateRequest,
} from '../../packages/shared/src/contracts/puzzleClear';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
} from '../../packages/shared/src/contracts/runtime';
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
import type {
CreateJumpHopSessionRequest,
JumpHopSessionSnapshot,
} from './jump-hop/jumpHopClient';
export type MiniGameDraftGenerationKind =
| 'puzzle'
| 'big-fish'
| 'square-hole'
| 'match3d'
| 'baby-object-match'
| 'jump-hop'
| 'puzzle-clear'
| 'wooden-fish';
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-level-scene'
| 'match3d-derived-assets'
| 'match3d-parse-spritesheet'
| 'match3d-write-draft'
| 'match3d-ready'
| 'baby-object-draft'
| 'baby-object-images'
| 'baby-object-ready'
| 'jump-hop-draft'
| 'jump-hop-tile-atlas'
| 'jump-hop-slice-tiles'
| 'jump-hop-write-draft'
| 'puzzle-clear-draft'
| 'puzzle-clear-background'
| 'puzzle-clear-atlas'
| 'puzzle-clear-slices'
| 'puzzle-clear-write-draft'
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
| 'wooden-fish-back-button'
| 'wooden-fish-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| '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;
metadata?: {
puzzleAiRedraw?: boolean;
puzzleActivePhaseId?: MiniGameDraftGenerationPhase;
puzzleActiveStepStartedAtMs?: number;
puzzleProgressPercent?: number;
};
};
type MiniGameStepDefinition = {
id: MiniGameDraftGenerationPhase;
label: string;
detail: string;
weight: number;
};
type TimedMiniGameStepDefinition = Omit<MiniGameStepDefinition, 'weight'> & {
durationMs: number;
};
type MiniGameAnchorSource = {
key: string;
label: string;
value: string;
};
const PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS = 240_000;
const PUZZLE_IMAGE_GENERATION_EXPECTED_MS = 90_000;
const PUZZLE_COMPILE_EXPECTED_MS = 8_000;
const PUZZLE_LEVEL_NAME_EXPECTED_MS = 10_000;
const PUZZLE_WRITE_DRAFT_EXPECTED_MS = 10_000;
const PUZZLE_COMPILE_MILESTONE_PROGRESS = 88;
const PUZZLE_IMAGE_MILESTONE_PROGRESS = 94;
const PUZZLE_UI_MILESTONE_PROGRESS = 96;
function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) {
return state.metadata?.puzzleAiRedraw === false;
}
function buildWeightedPuzzleSteps(
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
) {
const totalDuration = steps.reduce((sum, step) => sum + step.durationMs, 0);
let usedWeight = 0;
return steps.map((step, index) => {
const weight =
index === steps.length - 1
? Math.max(1, 100 - usedWeight)
: Math.max(1, Math.round((step.durationMs / totalDuration) * 100));
usedWeight += weight;
return {
id: step.id,
label: step.label,
detail: step.detail,
weight,
} satisfies MiniGameStepDefinition;
});
}
function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
const steps: TimedMiniGameStepDefinition[] = [
{
id: 'compile',
label: '编译首关草稿',
detail: '建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
durationMs: PUZZLE_COMPILE_EXPECTED_MS,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据描述生成关卡名、作品描述和标签,约 10 秒。',
durationMs: PUZZLE_LEVEL_NAME_EXPECTED_MS,
},
];
if (!shouldSkipPuzzleCoverGeneration(state)) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '生成 1:1 拼图首图,预计 4 分钟。',
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
});
}
steps.push(
{
id: 'puzzle-level-scene',
label: '生成关卡画面',
detail: shouldSkipPuzzleCoverGeneration(state)
? '直接使用上传图作为参考,生成 9:16 完整关卡画面,预计 90 秒。'
: '使用拼图首图作为参考,生成 9:16 完整关卡画面,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-ui-assets',
label: '生成UI与背景',
detail:
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '校验资产并写入正式首关、作品摘要和草稿投影,约 10 秒。',
durationMs: PUZZLE_WRITE_DRAFT_EXPECTED_MS,
},
);
return steps;
}
function buildPuzzleSteps(state: MiniGameDraftGenerationState) {
return buildWeightedPuzzleSteps(buildPuzzleTimedSteps(state));
}
function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
return buildPuzzleTimedSteps(state).reduce(
(sum, step) => sum + step.durationMs,
0,
);
}
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
function resolvePuzzleBackendProgressPercent(
state: MiniGameDraftGenerationState,
) {
const progressPercent = state.metadata?.puzzleProgressPercent;
if (
typeof progressPercent !== 'number' ||
!Number.isFinite(progressPercent)
) {
return null;
}
return Math.max(0, Math.min(100, Math.round(progressPercent)));
}
function resolvePuzzlePhaseByBackendProgress(
state: MiniGameDraftGenerationState,
): MiniGameDraftGenerationPhase | null {
const progressPercent = resolvePuzzleBackendProgressPercent(state);
if (progressPercent == null) {
return null;
}
// 中文注释:拼图生成页的跨步骤只跟随后端会话真实里程碑;
// 每步内部的等待反馈仍由本地假进度补足。
if (progressPercent >= 96) {
return 'puzzle-select-image';
}
if (progressPercent >= 94) {
return 'puzzle-ui-assets';
}
if (progressPercent >= 88) {
return shouldSkipPuzzleCoverGeneration(state)
? 'puzzle-level-scene'
: 'puzzle-cover-image';
}
return null;
}
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-level-scene',
label: '生成关卡整图',
detail: '生成 9:16 完整抓大鹅关卡画面。',
weight: 28,
},
{
id: 'match3d-derived-assets',
label: '生成三张派生图',
detail: '以关卡整图为参考,并发生成 UI、背景和 10x10 物品 Sprite。',
weight: 34,
},
{
id: 'match3d-parse-spritesheet',
label: '解析物品Sprite',
detail: '解析 20 个物品和每个物品的 5 个形态,并上传透明 PNG。',
weight: 18,
},
{
id: 'match3d-write-draft',
label: '写入草稿页',
detail: '保存关卡整图、派生图集、20 种物品素材和作品草稿。',
weight: 2,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_ESTIMATED_WAIT_MS = 460_000;
const MATCH3D_PHASE_ORDER: Partial<
Record<MiniGameDraftGenerationPhase, number>
> = {
'match3d-work-title': 0,
'match3d-item-names': 1,
'match3d-level-scene': 2,
'match3d-derived-assets': 3,
'match3d-parse-spritesheet': 4,
'match3d-write-draft': 5,
// 中文注释:旧生成页阶段在恢复生成中草稿时归并到新流程对应阶段。
'match3d-background-prompt': 1,
'match3d-material-sheet': 3,
'match3d-slice-images': 4,
'match3d-upload-images': 4,
'match3d-generate-views': 4,
'match3d-background-image': 3,
};
function normalizeMatch3DGenerationPhase(
phase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
switch (phase) {
case 'match3d-background-prompt':
return 'match3d-item-names';
case 'match3d-material-sheet':
case 'match3d-background-image':
return 'match3d-derived-assets';
case 'match3d-slice-images':
case 'match3d-upload-images':
case 'match3d-generate-views':
return 'match3d-parse-spritesheet';
default:
return phase;
}
}
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>;
const JUMP_HOP_STEPS = [
{
id: 'jump-hop-draft',
label: '整理玩法草稿',
detail: '保存主题并派生作品信息和默认角色配置。',
weight: 12,
},
{
id: 'jump-hop-tile-atlas',
label: '生成 5x5 地块图集',
detail: '调用 image2 生成 25 个主题地块素材。',
weight: 54,
},
{
id: 'jump-hop-slice-tiles',
label: '切分 25 个地块',
detail: '按 5 行 5 列切分透明地块 PNG。',
weight: 24,
},
{
id: 'jump-hop-write-draft',
label: '写入正式草稿',
detail: '保存地块池、无限路径缓冲和运行态配置。',
weight: 10,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000;
const PUZZLE_CLEAR_STEPS = [
{
id: 'puzzle-clear-draft',
label: '整理玩法草稿',
detail: '保存作品信息、主题词与底图策略。',
weight: 8,
},
{
id: 'puzzle-clear-background',
label: '准备场地底图',
detail: '处理上传底图或生成中央场地底图。',
weight: 22,
},
{
id: 'puzzle-clear-atlas',
label: '生成复合图集',
detail: '生成 135 组复合图案 atlas。',
weight: 42,
},
{
id: 'puzzle-clear-slices',
label: '切分卡牌碎片',
detail: '按预排坐标切成 1x1 卡牌碎片并校验。',
weight: 20,
},
{
id: 'puzzle-clear-write-draft',
label: '写入正式草稿',
detail: '保存底图、图集、碎片和作品摘要。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_CLEAR_ESTIMATED_WAIT_MS = 620_000;
const WOODEN_FISH_STEPS = [
{
id: 'wooden-fish-draft',
label: '整理玩法草稿',
detail: '保存作品信息、敲击物、音效和飘字配置。',
weight: 8,
},
{
id: 'wooden-fish-hit-object',
label: '生成敲击物图案',
detail: '调用 image2 生成绿幕敲击物并去绿透明化,预计约 3 分钟。',
weight: 32,
},
{
id: 'wooden-fish-background',
label: '生成背景环境图',
detail: '使用透明敲击物作参考生成 9:16 背景环境图,预计约 3 分钟。',
weight: 32,
},
{
id: 'wooden-fish-back-button',
label: '生成返回按钮图',
detail: '使用敲击物和背景作参考生成主题圆形返回按钮,预计约 3 分钟。',
weight: 20,
},
{
id: 'wooden-fish-write-draft',
label: '写入正式草稿',
detail: '保存图案、背景、返回按钮、音效、飘字和封面摘要。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const WOODEN_FISH_COMPILE_EXPECTED_MS = 8_000;
const WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS = 180_000;
const WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS = 10_000;
const WOODEN_FISH_ESTIMATED_WAIT_MS =
WOODEN_FISH_COMPILE_EXPECTED_MS +
WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS * 3 +
WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
export function resolveMiniGameDraftGenerationStartedAtMs(
startedAt: string | number | null | undefined,
fallbackMs = Date.now(),
) {
if (typeof startedAt === 'number' && Number.isFinite(startedAt)) {
return startedAt;
}
if (typeof startedAt === 'string') {
const parsed = Date.parse(startedAt);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallbackMs;
}
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'puzzle') {
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
}
if (kind === 'square-hole') {
return SQUARE_HOLE_STEPS;
}
if (kind === 'match3d') {
return MATCH3D_STEPS;
}
if (kind === 'baby-object-match') {
return BABY_OBJECT_MATCH_STEPS;
}
if (kind === 'jump-hop') {
return JUMP_HOP_STEPS;
}
if (kind === 'puzzle-clear') {
return PUZZLE_CLEAR_STEPS;
}
if (kind === 'wooden-fish') {
return WOODEN_FISH_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) => {
// 中文注释:拼图草稿编译的 action 回包才代表可进入结果页;
// 但预计写入时长已耗尽时,最后一步自身应呈现已完成,避免出现“进行中 100%”。
const isTimedWriteStepCompleted =
(state.kind === 'puzzle' || state.kind === 'wooden-fish') &&
state.phase !== 'failed' &&
(step.id === 'puzzle-select-image' ||
step.id === 'wooden-fish-write-draft') &&
clampProgress(activeStepProgressRatio * 100) >= 100;
const isCompleted =
state.phase === 'ready' ||
index < activeStepIndex ||
isTimedWriteStepCompleted;
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,
startedAtMs = Date.now(),
): 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'
: kind === 'jump-hop'
? 'jump-hop-draft'
: kind === 'puzzle-clear'
? 'puzzle-clear-draft'
: kind === 'wooden-fish'
? 'wooden-fish-draft'
: 'compile',
startedAtMs,
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 >= 450_000
? 'match3d-write-draft'
: elapsedMs >= 360_000
? 'match3d-parse-spritesheet'
: elapsedMs >= 118_000
? 'match3d-derived-assets'
: elapsedMs >= 28_000
? 'match3d-level-scene'
: elapsedMs >= 8_000
? 'match3d-item-names'
: 'match3d-work-title';
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
const normalizedCurrentPhase = normalizeMatch3DGenerationPhase(currentPhase);
const currentOrder = MATCH3D_PHASE_ORDER[normalizedCurrentPhase] ?? -1;
return currentOrder > elapsedOrder ? normalizedCurrentPhase : 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 resolveJumpHopPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 270_000) {
return 'jump-hop-write-draft';
}
if (elapsedMs >= 220_000) {
return 'jump-hop-slice-tiles';
}
if (elapsedMs >= 115_000) {
return 'jump-hop-tile-atlas';
}
if (elapsedMs >= 12_000) {
return 'jump-hop-tile-atlas';
}
return 'jump-hop-draft';
}
function resolvePuzzleClearPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 590_000) {
return 'puzzle-clear-write-draft';
}
if (elapsedMs >= 470_000) {
return 'puzzle-clear-slices';
}
if (elapsedMs >= 120_000) {
return 'puzzle-clear-atlas';
}
if (elapsedMs >= 8_000) {
return 'puzzle-clear-background';
}
return 'puzzle-clear-draft';
}
function buildWoodenFishPhaseTimeline(): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
| 'wooden-fish-back-button'
| 'wooden-fish-write-draft'
>;
durationMs: number;
}> {
return [
{
phase: 'wooden-fish-draft',
durationMs: WOODEN_FISH_COMPILE_EXPECTED_MS,
},
{
phase: 'wooden-fish-hit-object',
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
},
{
phase: 'wooden-fish-background',
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
},
{
phase: 'wooden-fish-back-button',
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
},
{
phase: 'wooden-fish-write-draft',
durationMs: WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS,
},
];
}
function resolveWoodenFishTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0;
for (const item of buildWoodenFishPhaseTimeline()) {
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: 'wooden-fish-write-draft' as const,
activeStepProgressRatio: 1,
};
}
function resolvePuzzleActiveStepProgressRatio(
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
activeStepIndex: number,
elapsedMs: number,
) {
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
const elapsedBeforeActiveStep = steps
.slice(0, activeStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const elapsedInActiveStep = Math.max(0, elapsedMs - elapsedBeforeActiveStep);
return Math.max(
0,
Math.min(0.98, elapsedInActiveStep / Math.max(1, activeStep.durationMs)),
);
}
function resolvePuzzleActiveStepElapsedProgressRatio(
state: MiniGameDraftGenerationState,
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
activeStepIndex: number,
elapsedMs: number,
effectiveNowMs: number,
) {
if (resolvePuzzleBackendProgressPercent(state) != null) {
const stepStartedAtMs = state.metadata?.puzzleActiveStepStartedAtMs;
if (
state.metadata?.puzzleActivePhaseId === state.phase &&
typeof stepStartedAtMs === 'number' &&
Number.isFinite(stepStartedAtMs)
) {
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
return Math.max(
0,
Math.min(
0.98,
(effectiveNowMs - stepStartedAtMs) /
Math.max(1, activeStep.durationMs),
),
);
}
return resolvePuzzleActiveStepProgressRatio(
steps,
activeStepIndex,
elapsedMs,
);
}
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
// 中文注释:未收到后端真实里程碑时,跨步骤必须卡住;
// 但当前步骤内的假进度要按整段等待时间继续向前走,避免短步骤几秒后停死。
const fallbackDurationMs = Math.max(1, resolvePuzzleEstimatedWaitMs(state));
return Math.max(0, Math.min(0.98, elapsedMs / fallbackDurationMs));
}
function resolveElapsedActiveStepProgressRatio(
kind: MiniGameDraftGenerationKind,
elapsedMs: number,
) {
const estimatedWaitMs =
kind === 'big-fish'
? 7_000
: kind === 'square-hole'
? 12_000
: kind === 'match3d'
? MATCH3D_ESTIMATED_WAIT_MS
: kind === 'baby-object-match'
? BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS
: kind === 'jump-hop'
? JUMP_HOP_ESTIMATED_WAIT_MS
: kind === 'puzzle-clear'
? PUZZLE_CLEAR_ESTIMATED_WAIT_MS
: kind === 'wooden-fish'
? WOODEN_FISH_ESTIMATED_WAIT_MS
: 1;
return Math.max(
0,
Math.min(0.98, elapsedMs / Math.max(1, estimatedWaitMs)),
);
}
/** 计算拼图生成总进度,后端里程碑决定跨步骤,当前步骤内使用平滑假进度。 */
function resolvePuzzleOverallProgress(
state: MiniGameDraftGenerationState,
activeStepProgressRatio: number,
) {
const backendProgressPercent = resolvePuzzleBackendProgressPercent(state);
// 中文注释88 以下的后端进度只保留为会话事实,不参与首帧总进度抬升。
// 生成页恢复时必须先从 0% 起步,再由当前步骤内的假进度平滑推进。
const backendProgressFloor =
backendProgressPercent != null &&
backendProgressPercent >= PUZZLE_COMPILE_MILESTONE_PROGRESS
? backendProgressPercent
: 0;
const range =
state.phase === 'puzzle-select-image'
? {
start: PUZZLE_UI_MILESTONE_PROGRESS,
end: PUZZLE_NON_READY_MAX_PROGRESS,
}
: state.phase === 'puzzle-ui-assets'
? {
start: PUZZLE_IMAGE_MILESTONE_PROGRESS,
end: PUZZLE_UI_MILESTONE_PROGRESS,
}
: state.phase === 'puzzle-cover-image' ||
state.phase === 'puzzle-level-scene'
? {
start: PUZZLE_COMPILE_MILESTONE_PROGRESS,
end: PUZZLE_IMAGE_MILESTONE_PROGRESS,
}
: {
start: 0,
end: PUZZLE_COMPILE_MILESTONE_PROGRESS,
};
const fakeProgress =
range.start + (range.end - range.start) * activeStepProgressRatio;
const nextProgress = Math.min(
PUZZLE_NON_READY_MAX_PROGRESS,
Math.max(range.start, backendProgressFloor, fakeProgress),
);
return nextProgress;
}
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 puzzleBackendPhase =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzlePhaseByBackendProgress(state)
: null;
const woodenFishTimeline =
state.kind === 'wooden-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolveWoodenFishTimelineByElapsedMs(elapsedMs)
: null;
const normalizedState =
puzzleBackendPhase != null
? {
...state,
phase: puzzleBackendPhase,
}
: woodenFishTimeline != null
? {
...state,
phase: woodenFishTimeline.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.kind === 'jump-hop' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
}
: state.kind === 'puzzle-clear' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolvePuzzleClearPhaseByElapsedMs(elapsedMs),
}
: state;
const puzzleTimedSteps =
normalizedState.kind === 'puzzle'
? buildPuzzleTimedSteps(normalizedState)
: null;
const steps =
normalizedState.kind === 'puzzle'
? buildWeightedPuzzleSteps(
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
)
: getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const activeStep = steps[activeStepIndex] ?? steps[0];
const activeStepProgressRatio =
normalizedState.phase === 'failed'
? 0
: normalizedState.kind === 'puzzle'
? normalizedState.phase === 'ready'
? 1
: resolvePuzzleActiveStepElapsedProgressRatio(
normalizedState,
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
activeStepIndex,
elapsedMs,
effectiveNowMs,
)
: normalizedState.totalAssetCount > 0
? Math.min(
1,
normalizedState.completedAssetCount /
normalizedState.totalAssetCount,
)
: normalizedState.phase === 'ready'
? 1
: normalizedState.kind === 'big-fish'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'square-hole'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'match3d'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'baby-object-match'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'jump-hop'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'puzzle-clear'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'wooden-fish'
? (woodenFishTimeline?.activeStepProgressRatio ?? 0)
: 0;
const completedWeight = steps
.slice(
0,
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
normalizedState.phase === 'failed'
? Math.max(1, completedWeight)
: normalizedState.phase === 'ready'
? 100
: completedWeight + activeStep.weight * activeStepProgressRatio;
const cappedOverallProgress =
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
? overallProgress
: Math.min(PUZZLE_NON_READY_MAX_PROGRESS, 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'
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
: normalizedState.kind === 'jump-hop'
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
: normalizedState.kind === 'puzzle-clear'
? '拼消消草稿已准备完成,可进入结果页试玩或发布。'
: normalizedState.kind === 'wooden-fish'
? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
overallProgress: clampProgress(cappedOverallProgress),
completedWeight: clampProgress(cappedOverallProgress),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
? 0
: normalizedState.kind === 'puzzle'
? Math.max(
0,
resolvePuzzleEstimatedWaitMs(normalizedState) - 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)
: normalizedState.kind === 'jump-hop'
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'puzzle-clear'
? Math.max(
0,
PUZZLE_CLEAR_ESTIMATED_WAIT_MS - elapsedMs,
)
: normalizedState.kind === 'wooden-fish'
? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(
steps,
activeStepIndex,
normalizedState,
activeStepProgressRatio,
),
};
}
export function buildJumpHopGenerationAnchorEntries(
session: JumpHopSessionSnapshot | null | undefined,
formPayload: CreateJumpHopSessionRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const sessionRecord = session as
| {
config?: Partial<CreateJumpHopSessionRequest>;
draft?: {
workTitle?: string;
themeText?: string;
characterPrompt?: string;
tilePrompt?: string;
} | null;
}
| null
| undefined;
const config = sessionRecord?.config;
const draft = sessionRecord?.draft;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'jump-hop-theme',
label: '主题',
value:
formPayload?.themeText?.trim() ||
config?.themeText?.trim() ||
draft?.themeText?.trim() ||
draft?.workTitle?.trim() ||
'',
},
{
key: 'jump-hop-tile-style',
label: '地块图集',
value:
formPayload?.tilePrompt?.trim() ||
config?.tilePrompt?.trim() ||
draft?.tilePrompt?.trim() ||
'',
},
];
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 buildWoodenFishGenerationAnchorEntries(
session: WoodenFishSessionSnapshotResponse | null | undefined,
formPayload: WoodenFishWorkspaceCreateRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const draft = session?.draft;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'wooden-fish-hit-object',
label: '敲击物',
value:
formPayload?.hitObjectPrompt?.trim() ||
draft?.hitObjectPrompt?.trim() ||
'',
},
{
key: 'wooden-fish-hit-sound',
label: '音效',
value:
formPayload?.hitSoundAsset?.prompt?.trim() ||
draft?.hitSoundAsset?.prompt?.trim() ||
'',
},
{
key: 'wooden-fish-words',
label: '飘字',
value:
formPayload?.floatingWords
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 8)
.join('、') ||
draft?.floatingWords
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 8)
.join('、') ||
'',
},
];
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 buildPuzzleClearGenerationAnchorEntries(
session: PuzzleClearSessionSnapshotResponse | null | undefined,
formPayload: PuzzleClearWorkspaceCreateRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const draft = session?.draft;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'puzzle-clear-title',
label: '作品',
value:
formPayload?.workTitle?.trim() || draft?.workTitle?.trim() || '拼消消',
},
{
key: 'puzzle-clear-theme',
label: '主题',
value:
formPayload?.themePrompt?.trim() || draft?.themePrompt?.trim() || '',
},
{
key: 'puzzle-clear-background',
label: '底图',
value:
formPayload?.boardBackgroundPrompt?.trim() ||
draft?.boardBackgroundPrompt?.trim() ||
(formPayload?.boardBackgroundAsset ?? draft?.boardBackgroundAsset)
?.prompt?.trim() ||
(formPayload?.generateBoardBackground ??
draft?.generateBoardBackground
? 'AI生成'
: '上传底图'),
},
];
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 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 entries: Array<MiniGameAnchorSource | null> = [
{
key: 'match3d-theme',
label: '题材',
value:
formPayload?.themeText?.trim() ||
config?.themeText?.trim() ||
session?.anchorPack.theme.value ||
'',
},
{
key: 'match3d-items',
label: '素材数量',
value: `${resolveMatch3DGeneratedItemCount()} 种素材`,
},
];
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 = null,
_difficulty: number | null | undefined = null,
) {
return 20;
}
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());
}