Merge remote-tracking branch 'origin/master' into feat/recommend-runtime-guest

# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
This commit is contained in:
kdletters
2026-05-25 14:12:39 +08:00
470 changed files with 8570 additions and 3058 deletions

View File

@@ -9,6 +9,9 @@ export type CreationEntryTypeConfig = {
visible: boolean;
open: boolean;
sortOrder: number;
categoryId: string;
categoryLabel: string;
categorySortOrder: number;
updatedAtMicros: number;
};
@@ -23,6 +26,14 @@ export type CreationEntryConfig = {
title: string;
description: string;
};
eventBanner: {
title: string;
description: string;
coverImageSrc: string;
prizePoolMudPoints: number;
startsAtText: string;
endsAtText: string;
};
creationTypes: CreationEntryTypeConfig[];
};

View File

@@ -36,12 +36,15 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps[0]?.detail).toBe(
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.estimatedRemainingMs).toBe(296_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[2]?.detail).toBe(
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
);
expect(progress?.estimatedRemainingMs).toBe(446_500);
expect(progress?.overallProgress).toBe(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
test('puzzle draft generation advances steps across the current asset pipeline', () => {
test('puzzle draft generation starts total progress from zero', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -51,42 +54,81 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
const progress = buildMiniGameDraftGenerationProgress(state, 1000);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.estimatedRemainingMs).toBe(273_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.estimatedRemainingMs).toBe(448_000);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('puzzle write-back step turns completed once rounded progress reaches 100%', () => {
test('puzzle draft generation total progress advances after startup', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 7000);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.phaseId).toBe('compile');
});
test('puzzle draft generation keeps current step until real progress advances it', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const longRunningProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
const progressedState: MiniGameDraftGenerationState = {
...state,
phase: 'puzzle-cover-image',
};
const realProgress = buildMiniGameDraftGenerationProgress(
progressedState,
296_000,
);
expect(longRunningProgress?.phaseId).toBe('compile');
expect(longRunningProgress?.steps[0]?.status).toBe('active');
expect(longRunningProgress?.steps[1]?.status).toBe('pending');
expect(longRunningProgress?.overallProgress).toBeLessThanOrEqual(98);
expect(longRunningProgress?.overallProgress).toBeGreaterThan(40);
expect(realProgress?.phaseId).toBe('puzzle-cover-image');
expect(realProgress?.steps[1]?.status).toBe('completed');
expect(realProgress?.steps[2]?.status).toBe('active');
});
test('puzzle write-back step stays active until the generation action finishes', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'puzzle-select-image',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 298_950);
const progress = buildMiniGameDraftGenerationProgress(state, 448_950);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(50);
expect(progress?.steps[5]?.completed).toBe(1);
expect(progress?.steps[5]?.status).toBe('completed');
expect(progress?.steps[5]?.completed).toBe(0.98);
expect(progress?.steps[5]?.status).toBe('active');
});
test('puzzle direct upload generation skips the first image generation step', () => {
@@ -112,14 +154,14 @@ describe('miniGameDraftGenerationProgress', () => {
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseId).toBe('puzzle-level-scene');
expect(progress?.phaseId).toBe('compile');
expect(progress?.steps[2]?.detail).toContain('直接使用上传图作为参考');
expect(progress?.estimatedRemainingMs).toBe(189_000);
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.phaseId).toBe('compile');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
test('puzzle draft generation does not advance or claim completion before response', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -129,18 +171,88 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 480_000);
const progress = buildMiniGameDraftGenerationProgress(state, 630_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.phaseId).toBe('compile');
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.overallProgress).toBeGreaterThan(80);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[5]?.completed).toBe(1);
expect(progress?.steps[5]?.status).toBe('completed');
expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
expect(progress?.steps[0]?.status).toBe('active');
expect(progress?.steps[0]?.completed).toBe(0.98);
expect(progress?.steps.slice(1).every((step) => step.status === 'pending')).toBe(
true,
);
});
test('puzzle draft generation advances steps from backend progress percent only', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
} as MiniGameDraftGenerationState['metadata'],
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 120_000);
const uiProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 94,
} as MiniGameDraftGenerationState['metadata'],
},
230_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 96,
} as MiniGameDraftGenerationState['metadata'],
},
260_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[3]?.status).toBe('pending');
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(uiProgress?.steps[4]?.status).toBe('active');
expect(uiProgress?.steps[5]?.status).toBe('pending');
expect(writeProgress?.phaseId).toBe('puzzle-select-image');
expect(writeProgress?.steps[5]?.status).toBe('active');
});
test('puzzle backend milestone starts fake progress from the current step entry time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
puzzleActivePhaseId: 'puzzle-cover-image',
puzzleActiveStepStartedAtMs: 120_000,
} as MiniGameDraftGenerationState['metadata'],
};
const progress = buildMiniGameDraftGenerationProgress(state, 121_000);
expect(progress?.phaseId).toBe('puzzle-cover-image');
expect(progress?.steps[2]?.status).toBe('active');
expect(progress?.steps[2]?.completed).toBeLessThan(0.02);
});
test('puzzle ready copy points to result page work info completion', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
@@ -268,6 +380,19 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('match3d draft generation starts total progress from zero', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs,
);
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('match3d draft generation keeps backend observed asset phase', () => {
const state = {
...createMiniGameDraftGenerationState('match3d'),
@@ -407,7 +532,7 @@ describe('miniGameDraftGenerationProgress', () => {
]);
});
test('wooden fish draft generation exposes hit object, background and sound pipeline', () => {
test('wooden fish draft generation exposes hit object, background and back button pipeline', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');
const progress = buildMiniGameDraftGenerationProgress(
@@ -419,12 +544,40 @@ describe('miniGameDraftGenerationProgress', () => {
'wooden-fish-draft',
'wooden-fish-hit-object',
'wooden-fish-background',
'wooden-fish-hit-sound',
'wooden-fish-back-button',
'wooden-fish-write-draft',
]);
expect(progress?.phaseId).toBe('wooden-fish-hit-object');
expect(progress?.phaseLabel).toBe('生成敲击物图案');
expect(progress?.estimatedRemainingMs).toBe(272_000);
expect(progress?.estimatedRemainingMs).toBe(530_000);
});
test('wooden fish draft generation follows hit object, background, back button and writeback', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');
const hitObjectProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 20_000,
);
const backgroundProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 200_000,
);
const backButtonProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 390_000,
);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 575_000,
);
expect(hitObjectProgress?.phaseId).toBe('wooden-fish-hit-object');
expect(backgroundProgress?.phaseId).toBe('wooden-fish-background');
expect(backButtonProgress?.phaseId).toBe('wooden-fish-back-button');
expect(writeBackProgress?.phaseId).toBe('wooden-fish-write-draft');
expect(writeBackProgress?.estimatedRemainingMs).toBe(0);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
});
test('wooden fish generation anchors expose hit object, sound and words', () => {
@@ -434,7 +587,15 @@ describe('miniGameDraftGenerationProgress', () => {
workDescription: '敲一下,好事发生。',
themeTags: ['解压'],
hitObjectPrompt: '金色小木鱼',
hitSoundPrompt: '清脆木鱼声',
hitSoundAsset: {
assetId: 'wooden-fish-default-hit-sound',
audioSrc: '/wooden-fish/default-hit-sound.mp3',
audioObjectKey: 'public/wooden-fish/default-hit-sound.mp3',
assetObjectId: 'wooden-fish-default-hit-sound',
source: 'bundled-default',
prompt: '默认木鱼音',
durationMs: 3000,
},
floatingWords: ['幸运+1', '功德+1'],
});
@@ -447,7 +608,7 @@ describe('miniGameDraftGenerationProgress', () => {
{
id: 'wooden-fish-hit-sound',
label: '音效',
value: '清脆木鱼',
value: '默认木鱼',
},
{
id: 'wooden-fish-words',

View File

@@ -70,7 +70,7 @@ export type MiniGameDraftGenerationPhase =
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
| 'wooden-fish-hit-sound'
| 'wooden-fish-back-button'
| 'wooden-fish-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
@@ -91,6 +91,9 @@ export type MiniGameDraftGenerationState = {
error: string | null;
metadata?: {
puzzleAiRedraw?: boolean;
puzzleActivePhaseId?: MiniGameDraftGenerationPhase;
puzzleActiveStepStartedAtMs?: number;
puzzleProgressPercent?: number;
};
};
@@ -111,10 +114,14 @@ type MiniGameAnchorSource = {
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;
@@ -160,8 +167,8 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
});
}
@@ -204,30 +211,40 @@ function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
function buildPuzzlePhaseTimeline(state: MiniGameDraftGenerationState): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>;
durationMs: number;
}> {
return buildPuzzleTimedSteps(state).map((step) => ({
phase: step.id as Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>,
durationMs: step.durationMs,
}));
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 = [
@@ -415,30 +432,36 @@ const WOODEN_FISH_STEPS = [
{
id: 'wooden-fish-hit-object',
label: '生成敲击物图案',
detail: '使用 image2 生成最终运行态敲击物图案。',
weight: 34,
detail: '用 image2 生成绿幕敲击物并去绿透明化,预计约 3 分钟。',
weight: 32,
},
{
id: 'wooden-fish-background',
label: '生成背景环境图',
detail: '使用 image2 生成敲击背景环境图。',
weight: 34,
detail: '使用透明敲击物作参考生成 9:16 背景环境图,预计约 3 分钟。',
weight: 32,
},
{
id: 'wooden-fish-hit-sound',
label: '准备敲击音效',
detail: '生成或写回短促敲击音效资产。',
weight: 16,
id: 'wooden-fish-back-button',
label: '生成返回按钮图',
detail: '使用敲击物和背景作参考生成主题圆形返回按钮,预计约 3 分钟。',
weight: 20,
},
{
id: 'wooden-fish-write-draft',
label: '写入正式草稿',
detail: '保存图案、背景、音效、飘字和封面摘要。',
detail: '保存图案、背景、返回按钮、音效、飘字和封面摘要。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const WOODEN_FISH_ESTIMATED_WAIT_MS = 5 * 60_000;
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)));
@@ -486,15 +509,16 @@ function buildMiniGameProgressSteps(
return steps.map((step, index) => {
// 中文注释:拼图草稿编译的 action 回包才代表可进入结果页;
// 但预计写入时长已耗尽时,最后一步自身应呈现已完成,避免出现“进行中 100%”。
const isPuzzleWriteStepCompleted =
state.kind === 'puzzle' &&
const isTimedWriteStepCompleted =
(state.kind === 'puzzle' || state.kind === 'wooden-fish') &&
state.phase !== 'failed' &&
step.id === 'puzzle-select-image' &&
(step.id === 'puzzle-select-image' ||
step.id === 'wooden-fish-write-draft') &&
clampProgress(activeStepProgressRatio * 100) >= 100;
const isCompleted =
state.phase === 'ready' ||
index < activeStepIndex ||
isPuzzleWriteStepCompleted;
isTimedWriteStepCompleted;
const isActive =
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
@@ -618,31 +642,45 @@ function resolveJumpHopPhaseByElapsedMs(
return 'jump-hop-draft';
}
function resolveWoodenFishPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 270_000) {
return 'wooden-fish-write-draft';
}
if (elapsedMs >= 240_000) {
return 'wooden-fish-hit-sound';
}
if (elapsedMs >= 120_000) {
return 'wooden-fish-background';
}
if (elapsedMs >= 12_000) {
return 'wooden-fish-hit-object';
}
return 'wooden-fish-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 resolvePuzzleTimelineByElapsedMs(
elapsedMs: number,
state: MiniGameDraftGenerationState,
) {
function resolveWoodenFishTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0;
for (const item of buildPuzzlePhaseTimeline(state)) {
for (const item of buildWoodenFishPhaseTimeline()) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
@@ -659,11 +697,148 @@ function resolvePuzzleTimelineByElapsedMs(
}
return {
phase: 'puzzle-select-image' as const,
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 === '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(),
@@ -677,18 +852,29 @@ export function buildMiniGameDraftGenerationProgress(
? state.finishedAtMs
: nowMs;
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
const puzzleTimeline =
const puzzleBackendPhase =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
? resolvePuzzlePhaseByBackendProgress(state)
: null;
const woodenFishTimeline =
state.kind === 'wooden-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolveWoodenFishTimelineByElapsedMs(elapsedMs)
: null;
const normalizedState =
puzzleTimeline != null
puzzleBackendPhase != null
? {
...state,
phase: puzzleTimeline.phase,
phase: puzzleBackendPhase,
}
: woodenFishTimeline != null
? {
...state,
phase: woodenFishTimeline.phase,
}
: state.kind === 'big-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
@@ -724,61 +910,92 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
}
: state.kind === 'wooden-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveWoodenFishPhaseByElapsedMs(elapsedMs),
}
: state;
const puzzleTimedSteps =
normalizedState.kind === 'puzzle'
? buildPuzzleTimedSteps(normalizedState)
: null;
const steps =
normalizedState.kind === 'puzzle'
? buildPuzzleSteps(normalizedState)
? buildWeightedPuzzleSteps(
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
)
: getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const activeStep = steps[activeStepIndex] ?? steps[0];
const activeStepProgressRatio =
normalizedState.kind === 'puzzle'
? normalizedState.phase === 'ready'
? 1
: normalizedState.phase === 'failed'
? 0
: 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 === '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 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
: normalizedState.kind === 'jump-hop'
? 0.5
: normalizedState.kind === 'wooden-fish'
? 0.5
: 0;
const overallProgress =
normalizedState.phase === 'failed'
? Math.max(1, completedWeight)
: normalizedState.phase === 'ready'
? 100
: completedWeight + activeStep.weight * assetRatio;
: normalizedState.kind === 'puzzle'
? resolvePuzzleOverallProgress(
normalizedState,
activeStepProgressRatio,
)
: completedWeight + activeStep.weight * activeStepProgressRatio;
const cappedOverallProgress =
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
? overallProgress
: normalizedState.kind === 'puzzle'
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
: normalizedState.kind === 'wooden-fish'
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
: overallProgress;
return {
@@ -835,7 +1052,7 @@ export function buildMiniGameDraftGenerationProgress(
steps,
activeStepIndex,
normalizedState,
assetRatio,
activeStepProgressRatio,
),
};
}
@@ -917,8 +1134,7 @@ export function buildWoodenFishGenerationAnchorEntries(
key: 'wooden-fish-hit-sound',
label: '音效',
value:
formPayload?.hitSoundPrompt?.trim() ||
draft?.hitSoundPrompt?.trim() ||
formPayload?.hitSoundAsset?.prompt?.trim() ||
draft?.hitSoundAsset?.prompt?.trim() ||
'',
},

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildWoodenFishPublicWorkCode,
isSameCustomWorldPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from './publicWorkCode';
@@ -32,6 +34,16 @@ describe('publicWorkCode', () => {
);
});
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(
true,
);
expect(
isSameCustomWorldPublicWorkCode('world-public-1', 'world-public-1'),
).toBe(true);
});
it('matches wooden fish public work codes and raw profile ids', () => {
expect(
isSameWoodenFishPublicWorkCode(

View File

@@ -53,6 +53,28 @@ export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
return `BO-${suffix}`;
}
function normalizeCustomWorldPublicWorkCodeSuffix(profileId: string) {
const digits = profileId
.split('')
.filter((character) => character >= '0' && character <= '9')
.join('');
if (digits.length === 0) {
const bytes = new TextEncoder().encode(profileId);
const checksum = bytes.reduce((accumulator, value) => {
return (accumulator * 131 + value) >>> 0;
}, 0);
return String(checksum % 100_000_000).padStart(8, '0');
}
return digits.slice(-8).padStart(8, '0');
}
export function buildCustomWorldPublicWorkCode(profileId: string) {
return `CW-${normalizeCustomWorldPublicWorkCodeSuffix(profileId)}`;
}
function normalizeBarkBattlePublicWorkCodeSuffix(workId: string) {
const normalized = normalizePublicCodeText(workId);
const withoutPrefix = normalized.startsWith('BB')
@@ -155,6 +177,19 @@ export function isSameBabyObjectMatchPublicWorkCode(
);
}
export function isSameCustomWorldPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildCustomWorldPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameBarkBattlePublicWorkCode(keyword: string, workId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);

View File

@@ -0,0 +1,36 @@
import { beforeEach, expect, test, vi } from 'vitest';
const { createCreationAgentClientMock } = vi.hoisted(() => ({
createCreationAgentClientMock: vi.fn(),
}));
vi.mock('../creation-agent', () => ({
createCreationAgentClient: createCreationAgentClientMock,
}));
vi.mock('../apiClient', () => ({
requestJson: vi.fn(),
}));
beforeEach(() => {
vi.resetModules();
createCreationAgentClientMock.mockReset();
createCreationAgentClientMock.mockReturnValue({
createSession: vi.fn(),
getSession: vi.fn(),
sendMessage: vi.fn(),
streamMessage: vi.fn(),
executeAction: vi.fn(),
});
});
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
await import('./woodenFishClient');
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
expect.objectContaining({
createSessionTimeoutMs: 20 * 60 * 1000,
executeActionTimeoutMs: 20 * 60 * 1000,
}),
);
});

View File

@@ -27,6 +27,8 @@ import {
const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions';
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
const WOODEN_FISH_RUNTIME_API_BASE = '/api/runtime/wooden-fish';
// 中文注释:敲木鱼创作会串行等待多次 image2 与 OSS 写入,前端请求窗口需要覆盖完整生成链路。
const WOODEN_FISH_GENERATION_TIMEOUT_MS = 20 * 60 * 1000;
const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
@@ -78,6 +80,8 @@ const woodenFishCreationClient = createCreationAgentClient<
streamIncomplete: '敲木鱼共创消息流式结果不完整',
executeAction: '执行敲木鱼共创操作失败',
},
createSessionTimeoutMs: WOODEN_FISH_GENERATION_TIMEOUT_MS,
executeActionTimeoutMs: WOODEN_FISH_GENERATION_TIMEOUT_MS,
});
type FlattenedWoodenFishWorkProfileResponse = Omit<
@@ -120,6 +124,8 @@ function normalizeWoodenFishWorkProfile(
hitObjectAsset: flattened.hitObjectAsset,
backgroundAsset:
flattened.backgroundAsset ?? flattened.draft?.backgroundAsset ?? null,
backButtonAsset:
flattened.backButtonAsset ?? flattened.draft?.backButtonAsset ?? null,
hitSoundAsset: flattened.hitSoundAsset,
floatingWords: flattened.floatingWords,
};

View File

@@ -1,5 +1,20 @@
import type { WoodenFishAudioAsset } from '../../../packages/shared/src/contracts/woodenFish';
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC =
'/wooden-fish/default-hit-object.png';
export const WOODEN_FISH_DEFAULT_HIT_SOUND_SRC =
'/wooden-fish/default-hit-sound.mp3';
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT =
'默认敲击物图案,圆润木质质感,透明背景';
export const WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET: WoodenFishAudioAsset = {
assetId: 'wooden-fish-default-hit-sound',
audioSrc: WOODEN_FISH_DEFAULT_HIT_SOUND_SRC,
audioObjectKey: 'public/wooden-fish/default-hit-sound.mp3',
assetObjectId: 'wooden-fish-default-hit-sound',
source: 'bundled-default',
prompt: '默认木鱼音',
durationMs: 3000,
};