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:
@@ -45,6 +45,7 @@ test('creation agent action requests are not auto-retried by default', async ()
|
||||
'执行失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 0 }),
|
||||
timeoutMs: 1_000_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
|
||||
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 0,
|
||||
};
|
||||
const DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS = 1_000_000;
|
||||
|
||||
function buildJsonPostInit(payload: unknown): RequestInit {
|
||||
return {
|
||||
@@ -182,7 +183,8 @@ export function createCreationAgentClient<
|
||||
messages.executeAction,
|
||||
{
|
||||
retry: executeActionRetry,
|
||||
timeoutMs: executeActionTimeoutMs,
|
||||
timeoutMs:
|
||||
executeActionTimeoutMs ?? DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -5,10 +5,16 @@ import type {
|
||||
Match3DRunSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
buildMatch3DTrayInsertionPlan,
|
||||
compactMatch3DTraySlots,
|
||||
syncMatch3DItemTraySlotIndexes,
|
||||
} from './match3dTrayLayout';
|
||||
|
||||
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
||||
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
||||
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
|
||||
const MATCH3D_ITEMS_PER_CLEAR = 3;
|
||||
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
|
||||
const MATCH3D_LOCAL_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_LOCAL_BOARD_RADIUS = 0.5;
|
||||
@@ -373,10 +379,6 @@ function recomputeClickable(items: Match3DItemSnapshot[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function findNextTrayIndex(traySlots: Match3DTraySlot[]) {
|
||||
return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1;
|
||||
}
|
||||
|
||||
function countClearedItems(items: Match3DItemSnapshot[]) {
|
||||
return items.filter((item) => item.state === 'Cleared').length;
|
||||
}
|
||||
@@ -410,27 +412,28 @@ function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
const slotsByType = new Map<string, Match3DTraySlot[]>();
|
||||
for (const slot of run.traySlots) {
|
||||
if (!slot.itemTypeId || !slot.itemInstanceId) {
|
||||
continue;
|
||||
}
|
||||
slotsByType.set(slot.itemTypeId, [
|
||||
...(slotsByType.get(slot.itemTypeId) ?? []),
|
||||
slot,
|
||||
]);
|
||||
}
|
||||
|
||||
const matchedSlots = [...slotsByType.values()].find(
|
||||
(slots) => slots.length >= 3,
|
||||
);
|
||||
function settleMatchedTrayItems(
|
||||
run: Match3DRunSnapshot,
|
||||
itemTypeId: string,
|
||||
) {
|
||||
const matchedSlots = run.traySlots
|
||||
.filter(
|
||||
(slot) =>
|
||||
slot.itemInstanceId && slot.itemTypeId && slot.itemTypeId === itemTypeId,
|
||||
)
|
||||
.slice(0, MATCH3D_ITEMS_PER_CLEAR);
|
||||
if (!matchedSlots) {
|
||||
return {
|
||||
run,
|
||||
clearedItemInstanceIds: [] as string[],
|
||||
};
|
||||
}
|
||||
if (matchedSlots.length < MATCH3D_ITEMS_PER_CLEAR) {
|
||||
return {
|
||||
run,
|
||||
clearedItemInstanceIds: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
const clearedItemInstanceIds = matchedSlots
|
||||
.slice(0, 3)
|
||||
@@ -439,26 +442,36 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
Boolean(itemInstanceId),
|
||||
);
|
||||
const clearedSet = new Set(clearedItemInstanceIds);
|
||||
const nextRun = {
|
||||
...run,
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
const compactedTraySlots = compactMatch3DTraySlots(
|
||||
run.traySlots.map((slot) =>
|
||||
slot.itemInstanceId && clearedSet.has(slot.itemInstanceId)
|
||||
? { slotIndex: slot.slotIndex }
|
||||
: slot,
|
||||
),
|
||||
);
|
||||
const nextRun = {
|
||||
...run,
|
||||
traySlots: compactedTraySlots,
|
||||
items: run.items.map((item) =>
|
||||
clearedSet.has(item.itemInstanceId)
|
||||
? {
|
||||
...item,
|
||||
state: 'Cleared' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: null,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
run: nextRun,
|
||||
run: {
|
||||
...nextRun,
|
||||
items: syncMatch3DItemTraySlotIndexes(
|
||||
nextRun.items,
|
||||
nextRun.traySlots,
|
||||
),
|
||||
},
|
||||
clearedItemInstanceIds,
|
||||
};
|
||||
}
|
||||
@@ -509,31 +522,27 @@ export function buildLocalMatch3DOptimisticRun(
|
||||
const targetItem = run.items.find(
|
||||
(item) => item.itemInstanceId === itemInstanceId,
|
||||
);
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
|
||||
if (!targetItem || targetItem.state !== 'InBoard') {
|
||||
return run;
|
||||
}
|
||||
const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, targetItem);
|
||||
if (!insertion) {
|
||||
return run;
|
||||
}
|
||||
const nextItems = run.items.map((item) =>
|
||||
item.itemInstanceId === itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: insertion.slotIndex,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
return {
|
||||
...run,
|
||||
items: run.items.map((item) =>
|
||||
item.itemInstanceId === itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextTrayIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: targetItem.itemInstanceId,
|
||||
itemTypeId: targetItem.itemTypeId,
|
||||
visualKey: targetItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots),
|
||||
traySlots: insertion.traySlots,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -582,8 +591,8 @@ export async function confirmLocalMatch3DClick(
|
||||
clearedItemInstanceIds: [],
|
||||
};
|
||||
}
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (nextTrayIndex < 0) {
|
||||
const insertion = buildMatch3DTrayInsertionPlan(timedRun.traySlots, targetItem);
|
||||
if (!insertion) {
|
||||
const failedRun = {
|
||||
...timedRun,
|
||||
status: 'Failed' as const,
|
||||
@@ -601,27 +610,22 @@ export async function confirmLocalMatch3DClick(
|
||||
const movedRun: Match3DRunSnapshot = {
|
||||
...timedRun,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
items: timedRun.items.map((item) =>
|
||||
item.itemInstanceId === targetItem.itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: timedRun.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextTrayIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: targetItem.itemInstanceId,
|
||||
itemTypeId: targetItem.itemTypeId,
|
||||
visualKey: targetItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
items: syncMatch3DItemTraySlotIndexes(
|
||||
timedRun.items.map((item) =>
|
||||
item.itemInstanceId === targetItem.itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: insertion.slotIndex,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
insertion.traySlots,
|
||||
),
|
||||
traySlots: insertion.traySlots,
|
||||
};
|
||||
const settled = settleMatchedTrayItems(movedRun);
|
||||
const settled = settleMatchedTrayItems(movedRun, targetItem.itemTypeId);
|
||||
const nextRun = resolveRunStatus({
|
||||
...settled.run,
|
||||
items: recomputeClickable(settled.run.items),
|
||||
|
||||
167
src/services/match3d-runtime/match3dTrayLayout.test.ts
Normal file
167
src/services/match3d-runtime/match3dTrayLayout.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DItemSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { confirmLocalMatch3DClick } from './match3dLocalRuntime';
|
||||
import {
|
||||
buildMatch3DTrayInsertionPlan,
|
||||
compactMatch3DTraySlots,
|
||||
syncMatch3DItemTraySlotIndexes,
|
||||
} from './match3dTrayLayout';
|
||||
|
||||
function slot(
|
||||
slotIndex: number,
|
||||
itemInstanceId?: string,
|
||||
itemTypeId?: string,
|
||||
): Match3DTraySlot {
|
||||
return itemInstanceId && itemTypeId
|
||||
? {
|
||||
slotIndex,
|
||||
itemInstanceId,
|
||||
itemTypeId,
|
||||
visualKey: itemTypeId,
|
||||
}
|
||||
: { slotIndex };
|
||||
}
|
||||
|
||||
function item(
|
||||
itemInstanceId: string,
|
||||
itemTypeId: string,
|
||||
traySlotIndex: number | null = null,
|
||||
): Match3DItemSnapshot {
|
||||
return {
|
||||
itemInstanceId,
|
||||
itemTypeId,
|
||||
visualKey: itemTypeId,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
radius: 0.08,
|
||||
layer: 1,
|
||||
state: traySlotIndex === null ? 'InBoard' : 'InTray',
|
||||
clickable: traySlotIndex === null,
|
||||
traySlotIndex,
|
||||
};
|
||||
}
|
||||
|
||||
test('抓大鹅托盘点击新物品会插入到同类后面并后移其它物品', () => {
|
||||
const plan = buildMatch3DTrayInsertionPlan(
|
||||
[
|
||||
slot(0, 'apple-1', 'apple'),
|
||||
slot(1, 'pear-1', 'pear'),
|
||||
slot(2, 'apple-2', 'apple'),
|
||||
slot(3, 'melon-1', 'melon'),
|
||||
slot(4),
|
||||
slot(5),
|
||||
slot(6),
|
||||
],
|
||||
item('apple-3', 'apple'),
|
||||
);
|
||||
|
||||
expect(plan?.slotIndex).toBe(3);
|
||||
expect(plan?.traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([
|
||||
'apple-1',
|
||||
'pear-1',
|
||||
'apple-2',
|
||||
'apple-3',
|
||||
'melon-1',
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('抓大鹅三消后托盘会向前补位并同步物品槽位索引', () => {
|
||||
const traySlots = compactMatch3DTraySlots([
|
||||
slot(0),
|
||||
slot(1),
|
||||
slot(2),
|
||||
slot(3, 'melon-1', 'melon'),
|
||||
slot(4, 'pear-1', 'pear'),
|
||||
slot(5),
|
||||
slot(6),
|
||||
]);
|
||||
const items = syncMatch3DItemTraySlotIndexes(
|
||||
[
|
||||
item('melon-1', 'melon', 3),
|
||||
item('pear-1', 'pear', 4),
|
||||
{ ...item('apple-1', 'apple', 0), state: 'Cleared' as const },
|
||||
],
|
||||
traySlots,
|
||||
);
|
||||
|
||||
expect(traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([
|
||||
'melon-1',
|
||||
'pear-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(items.find((entry) => entry.itemInstanceId === 'melon-1')?.traySlotIndex).toBe(
|
||||
0,
|
||||
);
|
||||
expect(items.find((entry) => entry.itemInstanceId === 'pear-1')?.traySlotIndex).toBe(
|
||||
1,
|
||||
);
|
||||
expect(items.find((entry) => entry.itemInstanceId === 'apple-1')?.traySlotIndex).toBeNull();
|
||||
});
|
||||
|
||||
test('本地抓大鹅确认只清除本次点击类型的三连', async () => {
|
||||
const run = {
|
||||
runId: 'local-triple-run',
|
||||
profileId: 'local-triple-profile',
|
||||
status: 'Running' as const,
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: Date.now(),
|
||||
durationLimitMs: 600_000,
|
||||
serverNowMs: Date.now(),
|
||||
remainingMs: 600_000,
|
||||
clearCount: 3,
|
||||
totalItemCount: 7,
|
||||
clearedItemCount: 0,
|
||||
items: [
|
||||
item('apple-1', 'apple', 0),
|
||||
item('apple-2', 'apple', 1),
|
||||
item('apple-3', 'apple', 2),
|
||||
item('pear-1', 'pear', 3),
|
||||
item('pear-2', 'pear', 4),
|
||||
item('pear-3', 'pear', null),
|
||||
item('melon-1', 'melon', 5),
|
||||
],
|
||||
traySlots: [
|
||||
slot(0, 'apple-1', 'apple'),
|
||||
slot(1, 'apple-2', 'apple'),
|
||||
slot(2, 'apple-3', 'apple'),
|
||||
slot(3, 'pear-1', 'pear'),
|
||||
slot(4, 'pear-2', 'pear'),
|
||||
slot(5, 'melon-1', 'melon'),
|
||||
slot(6),
|
||||
],
|
||||
};
|
||||
run.items[5]!.clickable = true;
|
||||
run.items[5]!.state = 'InBoard';
|
||||
|
||||
const result = await confirmLocalMatch3DClick(run, {
|
||||
runId: run.runId,
|
||||
itemInstanceId: 'pear-3',
|
||||
clientSnapshotVersion: run.snapshotVersion,
|
||||
clientEventId: 'click-pear-3',
|
||||
clickedAtMs: Date.now(),
|
||||
});
|
||||
|
||||
expect(result.clearedItemInstanceIds).toEqual(['pear-1', 'pear-2', 'pear-3']);
|
||||
expect(result.run.items.find((entry) => entry.itemInstanceId === 'apple-1')?.state).toBe(
|
||||
'InTray',
|
||||
);
|
||||
expect(result.run.traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([
|
||||
'apple-1',
|
||||
'apple-2',
|
||||
'apple-3',
|
||||
'melon-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
134
src/services/match3d-runtime/match3dTrayLayout.ts
Normal file
134
src/services/match3d-runtime/match3dTrayLayout.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
Match3DItemSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
|
||||
type Match3DTrayOccupant = {
|
||||
itemInstanceId: string;
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
};
|
||||
|
||||
export type Match3DTrayInsertionPlan = {
|
||||
slotIndex: number;
|
||||
traySlots: Match3DTraySlot[];
|
||||
};
|
||||
|
||||
function resolveMatch3DTraySlotOrder(traySlots: Match3DTraySlot[]) {
|
||||
return [...traySlots].sort((left, right) => left.slotIndex - right.slotIndex);
|
||||
}
|
||||
|
||||
function resolveMatch3DTrayOccupants(traySlots: Match3DTraySlot[]) {
|
||||
return resolveMatch3DTraySlotOrder(traySlots).flatMap((slot) =>
|
||||
slot.itemInstanceId && slot.itemTypeId && slot.visualKey
|
||||
? [
|
||||
{
|
||||
itemInstanceId: slot.itemInstanceId,
|
||||
itemTypeId: slot.itemTypeId,
|
||||
visualKey: slot.visualKey,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function rebuildMatch3DTraySlots(
|
||||
traySlots: Match3DTraySlot[],
|
||||
occupants: Match3DTrayOccupant[],
|
||||
) {
|
||||
return resolveMatch3DTraySlotOrder(traySlots).map((slot, index) => {
|
||||
const occupant = occupants[index];
|
||||
return occupant
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: occupant.itemInstanceId,
|
||||
itemTypeId: occupant.itemTypeId,
|
||||
visualKey: occupant.visualKey,
|
||||
}
|
||||
: { slotIndex: slot.slotIndex };
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMatch3DTrayInsertionPlan(
|
||||
traySlots: Match3DTraySlot[],
|
||||
item: Pick<
|
||||
Match3DItemSnapshot,
|
||||
'itemInstanceId' | 'itemTypeId' | 'visualKey'
|
||||
>,
|
||||
): Match3DTrayInsertionPlan | null {
|
||||
const orderedSlots = resolveMatch3DTraySlotOrder(traySlots);
|
||||
const occupants = resolveMatch3DTrayOccupants(orderedSlots);
|
||||
if (occupants.length >= orderedSlots.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let lastSameTypeIndex = -1;
|
||||
for (let index = occupants.length - 1; index >= 0; index -= 1) {
|
||||
if (occupants[index]?.itemTypeId === item.itemTypeId) {
|
||||
lastSameTypeIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const insertionIndex =
|
||||
lastSameTypeIndex >= 0 ? lastSameTypeIndex + 1 : occupants.length;
|
||||
occupants.splice(insertionIndex, 0, {
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
itemTypeId: item.itemTypeId,
|
||||
visualKey: item.visualKey,
|
||||
});
|
||||
|
||||
return {
|
||||
slotIndex: orderedSlots[insertionIndex]?.slotIndex ?? insertionIndex,
|
||||
traySlots: rebuildMatch3DTraySlots(orderedSlots, occupants),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncMatch3DItemTraySlotIndexes(
|
||||
items: Match3DItemSnapshot[],
|
||||
traySlots: Match3DTraySlot[],
|
||||
) {
|
||||
const slotByItemId = new Map(
|
||||
traySlots.flatMap((slot) =>
|
||||
slot.itemInstanceId
|
||||
? [[slot.itemInstanceId, slot.slotIndex] as const]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
return items.map((item) =>
|
||||
item.state === 'InTray' && slotByItemId.has(item.itemInstanceId)
|
||||
? {
|
||||
...item,
|
||||
traySlotIndex: slotByItemId.get(item.itemInstanceId),
|
||||
}
|
||||
: item.state === 'Cleared'
|
||||
? {
|
||||
...item,
|
||||
traySlotIndex: null,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function compactMatch3DTraySlots(traySlots: Match3DTraySlot[]) {
|
||||
return rebuildMatch3DTraySlots(
|
||||
resolveMatch3DTraySlotOrder(traySlots),
|
||||
resolveMatch3DTrayOccupants(traySlots),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatch3DTrayItemIdToSlotIndexMap(
|
||||
traySlots: Match3DTraySlot[],
|
||||
) {
|
||||
return new Map(
|
||||
resolveMatch3DTraySlotOrder(traySlots).flatMap((slot) =>
|
||||
slot.itemInstanceId ? [[slot.itemInstanceId, slot.slotIndex] as const] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatch3DTraySlotRectIndexOrder(
|
||||
traySlots: Match3DTraySlot[],
|
||||
) {
|
||||
return resolveMatch3DTraySlotOrder(traySlots).map((slot) => slot.slotIndex);
|
||||
}
|
||||
@@ -99,6 +99,22 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('finished draft generation keeps elapsed time pinned to completion time', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'failed',
|
||||
startedAtMs: 1_000,
|
||||
finishedAtMs: 151_000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: 'VectorEngine 图片编辑请求超时',
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 500_000);
|
||||
|
||||
expect(progress?.elapsedMs).toBe(150_000);
|
||||
});
|
||||
|
||||
test('big fish draft generation exposes multiple draft steps', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'big-fish',
|
||||
|
||||
@@ -59,6 +59,7 @@ export type MiniGameDraftGenerationState = {
|
||||
kind: MiniGameDraftGenerationKind;
|
||||
phase: MiniGameDraftGenerationPhase;
|
||||
startedAtMs: number;
|
||||
finishedAtMs?: number;
|
||||
completedAssetCount: number;
|
||||
totalAssetCount: number;
|
||||
error: string | null;
|
||||
@@ -445,7 +446,11 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(0, nowMs - state.startedAtMs);
|
||||
const effectiveNowMs =
|
||||
typeof state.finishedAtMs === 'number' && Number.isFinite(state.finishedAtMs)
|
||||
? state.finishedAtMs
|
||||
: nowMs;
|
||||
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
|
||||
const puzzleTimeline =
|
||||
state.kind === 'puzzle' &&
|
||||
state.phase !== 'failed' &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const DEFAULT_RUNTIME_CLICK_SOUND_SRC = '/audio/ui-click-soft.wav';
|
||||
export const DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC =
|
||||
'/audio/ui-level-clear.wav';
|
||||
export const DEFAULT_RUNTIME_MERGE_SOUND_SRC =
|
||||
DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC;
|
||||
export const DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC =
|
||||
'/audio/ui-countdown-warning.wav';
|
||||
export const DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS = 5_000;
|
||||
@@ -53,6 +55,10 @@ export function playRuntimeLevelClearSound(volume = 0.6) {
|
||||
playRuntimeClickSound(DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC, volume);
|
||||
}
|
||||
|
||||
export function playRuntimeMergeSound(volume = 0.6) {
|
||||
playRuntimeClickSound(DEFAULT_RUNTIME_MERGE_SOUND_SRC, volume);
|
||||
}
|
||||
|
||||
export function playRuntimeCountdownSound(volume = 0.6) {
|
||||
playRuntimeClickSound(DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC, volume);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user