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

@@ -52,6 +52,19 @@ import {
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
import { resolveGeometryAsset } from './match3dVisualAssets';
const runtimeAudioFeedback = vi.hoisted(() => ({
playRuntimeMergeSound: vi.fn(),
}));
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/runtimeAudioFeedback')>();
return {
...actual,
playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
};
});
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
return {
@@ -82,6 +95,7 @@ afterEach(() => {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
vi.restoreAllMocks();
});
@@ -519,6 +533,318 @@ test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () =>
).toBe('scale(0.58)');
});
test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => {
const baseRun = startLocalMatch3DRun(3);
const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3);
expect(appleBoard && pearTray && appleTray).toBeTruthy();
const run: Match3DRunSnapshot = {
...baseRun,
items: [
{
...appleBoard!,
itemInstanceId: 'apple-3',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: true,
state: 'InBoard',
x: 0.5,
y: 0.5,
layer: 10,
traySlotIndex: null,
},
{
...pearTray!,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: false,
state: 'InTray',
traySlotIndex: 0,
},
{
...appleTray!,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: false,
state: 'InTray',
traySlotIndex: 1,
},
{
...baseRun.items[3]!,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
clickable: false,
state: 'InTray',
traySlotIndex: 2,
},
...baseRun.items.slice(4).map((item) => ({
...item,
clickable: false,
state: 'InBoard' as const,
})),
],
traySlots: baseRun.traySlots.map((slot) => {
if (slot.slotIndex === 0) {
return {
slotIndex: 0,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 1) {
return {
slotIndex: 1,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 2) {
return {
slotIndex: 2,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
};
}
return { slotIndex: slot.slotIndex };
}),
};
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
status: 'Accepted' as const,
acceptedItemInstanceId: payload.itemInstanceId,
clearedItemInstanceIds: [],
run: {
...run,
snapshotVersion: run.snapshotVersion + 1,
},
}));
const onOptimisticRunChange = vi.fn();
render(
<Match3DRuntimeShell
run={run}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
const board = screen.getByTestId('match3d-board');
mockMatch3DBoardRect();
mockMatch3DPointerCapture(board);
Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 530,
height: 56,
left: 220,
right: 276,
top: 474,
width: 56,
x: 220,
y: 474,
toJSON: () => ({}),
}),
});
const point = toMatch3DBoardClientPoint(run.items[0]!);
fireMatch3DBoardPointer(board, 'pointerdown', point, 14);
fireMatch3DBoardPointer(board, 'pointerup', point, 14);
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled());
const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as
| Match3DRunSnapshot
| undefined;
expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
'apple-1',
'apple-2',
'apple-3',
'pear-1',
null,
null,
null,
]);
expect(
optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3')
?.traySlotIndex,
).toBe(2);
});
test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => {
const baseRun = startLocalMatch3DRun(1);
const [first, second, third, fourth] = baseRun.items.slice(0, 4);
expect(first && second && third).toBeTruthy();
const run: Match3DRunSnapshot = {
...baseRun,
totalItemCount: 4,
items: [
{
...first!,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InTray',
clickable: false,
traySlotIndex: 0,
},
{
...second!,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InTray',
clickable: false,
traySlotIndex: 1,
},
{
...third!,
itemInstanceId: 'apple-3',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InBoard',
clickable: true,
x: 0.5,
y: 0.5,
layer: 10,
traySlotIndex: null,
},
{
...(fourth ?? third!),
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
state: 'InTray',
clickable: false,
traySlotIndex: 2,
},
],
traySlots: baseRun.traySlots.map((slot) => {
if (slot.slotIndex === 0) {
return {
slotIndex: 0,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 1) {
return {
slotIndex: 1,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 2) {
return {
slotIndex: 2,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
};
}
return { slotIndex: slot.slotIndex };
}),
};
const acceptedRun: Match3DRunSnapshot = {
...run,
snapshotVersion: run.snapshotVersion + 1,
clearedItemCount: 3,
items: run.items.map((item) =>
item.itemTypeId === 'apple'
? {
...item,
state: 'Cleared' as const,
clickable: false,
traySlotIndex: null,
}
: { ...item, traySlotIndex: 0 },
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === 0
? {
slotIndex: 0,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
}
: { slotIndex: slot.slotIndex },
),
};
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
status: 'Accepted' as const,
acceptedItemInstanceId: payload.itemInstanceId,
clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'],
run: acceptedRun,
}));
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
rerender(
<Match3DRuntimeShell
run={nextRun}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
});
const { rerender } = render(
<Match3DRuntimeShell
run={run}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
const board = screen.getByTestId('match3d-board');
mockMatch3DBoardRect();
mockMatch3DPointerCapture(board);
screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => {
Object.defineProperty(slot, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 530,
height: 56,
left: 52 + index * 58,
right: 108 + index * 58,
top: 474,
width: 56,
x: 52 + index * 58,
y: 474,
toJSON: () => ({}),
}),
});
});
const point = toMatch3DBoardClientPoint(run.items[2]!);
fireMatch3DBoardPointer(board, 'pointerdown', point, 15);
fireMatch3DBoardPointer(board, 'pointerup', point, 15);
await waitFor(() =>
expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(),
);
expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3);
expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy();
expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull();
expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1);
const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as
| Match3DRunSnapshot
| undefined;
expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
'pear-1',
null,
null,
null,
null,
null,
null,
]);
});
test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
const run = startLocalMatch3DRun(1);
const clickableItem = run.items.find((item) => item.clickable)!;
@@ -1025,9 +1351,10 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const containerImage = screen.getByTestId(
'match3d-container-image',
) as HTMLImageElement;
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
expect(containerImage.className).toContain('w-[min(116vw,42rem)]');
expect(containerImage.className).toContain('h-auto');
expect(containerImage.className).toContain('left-1/2');
expect(containerImage.className).toContain('top-[54%]');
expect(containerImage.className).toContain('-translate-x-1/2');
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',