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:
@@ -567,6 +567,96 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
const generatingSecondLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '第二关',
|
||||
pictureDescription: '第二关画面正在生成。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating' as const,
|
||||
};
|
||||
const localThirdLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-3',
|
||||
levelName: '第三关',
|
||||
pictureDescription: '第三关初稿。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle' as const,
|
||||
};
|
||||
const completedSecondLevel = {
|
||||
...generatingSecondLevel,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-level-2',
|
||||
imageSrc: '/puzzle/level-2.png',
|
||||
assetId: 'asset-level-2',
|
||||
prompt: '第二关画面',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated' as const,
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-level-2',
|
||||
coverImageSrc: '/puzzle/level-2.png',
|
||||
coverAssetId: 'asset-level-2',
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, generatingSecondLevel, localThirdLevel],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('第三关'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
target: { value: '正在编辑第三关的信息。' },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, completedSecondLevel],
|
||||
},
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const currentDialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(within(currentDialog).getByLabelText('关卡名称')).toHaveProperty(
|
||||
'value',
|
||||
'第三关',
|
||||
);
|
||||
expect(within(currentDialog).getByLabelText('画面描述')).toHaveProperty(
|
||||
'value',
|
||||
'正在编辑第三关的信息。',
|
||||
);
|
||||
expect(within(currentDialog).queryByDisplayValue('第二关')).toBeNull();
|
||||
});
|
||||
|
||||
test('publishes with work info and serialized levels', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
@@ -1833,13 +1833,21 @@ export function PuzzleResultView({
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
|
||||
const latestEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
const savedEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestEditStateRef.current = editState;
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
latestEditStateRef.current = null;
|
||||
setActiveLevelId(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
@@ -1847,17 +1855,16 @@ export function PuzzleResultView({
|
||||
return;
|
||||
}
|
||||
const nextState = createDraftEditState(draft);
|
||||
setEditState((currentState) => {
|
||||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||||
currentState,
|
||||
nextState,
|
||||
);
|
||||
savedEditStateRef.current = nextState;
|
||||
return mergedState;
|
||||
});
|
||||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||||
latestEditStateRef.current,
|
||||
nextState,
|
||||
);
|
||||
latestEditStateRef.current = mergedState;
|
||||
savedEditStateRef.current = nextState;
|
||||
setEditState(mergedState);
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
|
||||
nextState.levels.forEach((level) => {
|
||||
mergedState.levels.forEach((level) => {
|
||||
if (level.generationStatus === 'generating') {
|
||||
nextRuntimes[level.levelId] =
|
||||
current[level.levelId] ?? {
|
||||
@@ -1870,7 +1877,7 @@ export function PuzzleResultView({
|
||||
});
|
||||
setActiveLevelId((currentLevelId) =>
|
||||
currentLevelId &&
|
||||
nextState.levels.some((level) => level.levelId === currentLevelId)
|
||||
mergedState.levels.some((level) => level.levelId === currentLevelId)
|
||||
? currentLevelId
|
||||
: null,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user