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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user