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.
This commit is contained in:
2026-05-16 22:59:02 +08:00
parent bb60ca91ef
commit a45e358e83
42 changed files with 3872 additions and 443 deletions

View File

@@ -603,6 +603,53 @@ describe('puzzleLocalRuntime', () => {
);
});
test('本地试玩直达后续关卡时继承作品 UI 背景', () => {
const workWithLevels: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background.png',
backgroundMusic: null,
generationStatus: 'ready',
},
{
levelId: 'puzzle-level-2',
levelName: '第二关',
pictureDescription: '第二关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-2.png',
coverAssetId: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
backgroundMusic: null,
generationStatus: 'ready',
},
],
};
const run = startLocalPuzzleRun(workWithLevels, 'puzzle-level-2');
expect(run.currentLevel?.levelId).toBe('puzzle-level-2');
expect(run.currentLevel?.coverImageSrc).toBe('/level-2.png');
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background.png',
);
expect(run.currentLevel?.uiBackgroundImageObjectKey).toBe(
'generated-puzzle-assets/session/ui/background.png',
);
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(

View File

@@ -12,7 +12,10 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { resolvePuzzleUiBackgroundSource } from './puzzleUiBackgroundSource';
import {
resolvePuzzleUiBackgroundFields,
resolvePuzzleUiBackgroundSource,
} from './puzzleUiBackgroundSource';
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
@@ -761,6 +764,15 @@ function resolveNextSameWorkLevel(
return levels[nextLevelIndex] ?? null;
}
function resolvePuzzleWorkUiBackgroundCarrier(
work: PuzzleWorkSummary | null | undefined,
) {
return (
work?.levels?.find((level) => resolvePuzzleUiBackgroundSource(level)) ??
null
);
}
function applyLocalNextLevelHandoff(
run: PuzzleRunSnapshot,
work: PuzzleWorkSummary | null | undefined,
@@ -803,11 +815,11 @@ function buildFallbackLocalLevel(
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
const nextCoverImageSrc =
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
const nextUiBackgroundImageSrc =
resolvePuzzleUiBackgroundSource(nextLevel) ?? currentLevel.uiBackgroundImageSrc;
const nextUiBackgroundImageObjectKey = resolvePuzzleUiBackgroundSource(nextLevel)
? nextLevel?.uiBackgroundImageObjectKey?.trim() || null
: currentLevel.uiBackgroundImageObjectKey ?? null;
const nextUiBackground = resolvePuzzleUiBackgroundFields(
nextLevel,
resolvePuzzleWorkUiBackgroundCarrier(work),
currentLevel,
);
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
@@ -838,8 +850,8 @@ function buildFallbackLocalLevel(
clearedAtMs: null,
elapsedMs: null,
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackgroundImageObjectKey,
uiBackgroundImageSrc: nextUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackground.uiBackgroundImageObjectKey,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
@@ -865,9 +877,10 @@ export function startLocalPuzzleRun(
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
const firstUiBackgroundImageSrc = resolvePuzzleUiBackgroundSource(firstLevel);
const firstUiBackgroundImageObjectKey =
firstLevel?.uiBackgroundImageObjectKey?.trim() || null;
const firstUiBackground = resolvePuzzleUiBackgroundFields(
firstLevel,
resolvePuzzleWorkUiBackgroundCarrier(item),
);
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
@@ -888,8 +901,8 @@ export function startLocalPuzzleRun(
authorDisplayName: item.authorDisplayName,
themeTags: item.themeTags,
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackgroundImageObjectKey,
uiBackgroundImageSrc: firstUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackground.uiBackgroundImageObjectKey,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',

View File

@@ -6,15 +6,27 @@ type PuzzleUiBackgroundFields = {
export function resolvePuzzleUiBackgroundSource(
level: PuzzleUiBackgroundFields | null | undefined,
) {
const imageSrc = level?.uiBackgroundImageSrc?.trim();
if (imageSrc) {
return imageSrc;
}
const objectKey = level?.uiBackgroundImageObjectKey?.trim().replace(/^\/+/u, '');
if (!objectKey) {
return null;
}
return `/${objectKey}`;
return resolvePuzzleUiBackgroundFields(level).uiBackgroundImageSrc;
}
export function resolvePuzzleUiBackgroundFields(
...sources: Array<PuzzleUiBackgroundFields | null | undefined>
) {
for (const source of sources) {
const imageSrc = source?.uiBackgroundImageSrc?.trim();
const objectKey = source?.uiBackgroundImageObjectKey
?.trim()
.replace(/^\/+/u, '');
if (imageSrc || objectKey) {
return {
uiBackgroundImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
uiBackgroundImageObjectKey: objectKey || null,
};
}
}
return {
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
};
}