629 lines
18 KiB
TypeScript
629 lines
18 KiB
TypeScript
import type {
|
||
Match3DClickItemRequest,
|
||
Match3DClickItemResult,
|
||
Match3DItemSnapshot,
|
||
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 = 20;
|
||
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;
|
||
const MATCH3D_LOCAL_BOARD_SAFE_MARGIN = 0.035;
|
||
const MATCH3D_LOCAL_CONTAINER_MOUTH_RATIO = 0.78;
|
||
|
||
type Match3DSizeTier = 'XL' | 'L' | 'M' | 'XS' | 'S';
|
||
|
||
type Match3DVisualSeed = {
|
||
itemTypeId: string;
|
||
visualKey: string;
|
||
colorClassName: string;
|
||
label: string;
|
||
};
|
||
|
||
type Match3DSelectedVisualSeed = Match3DVisualSeed & {
|
||
radiusScale: number;
|
||
relativeVolume: number;
|
||
sizeTier: Match3DSizeTier;
|
||
};
|
||
|
||
const MATCH3D_SIZE_TIER_RULES: Array<{
|
||
radiusScale: number;
|
||
ratio: number;
|
||
relativeVolume: number;
|
||
sizeTier: Match3DSizeTier;
|
||
}> = [
|
||
{ sizeTier: 'XL', ratio: 0.2, relativeVolume: 1.86, radiusScale: 1.23 },
|
||
{ sizeTier: 'L', ratio: 0.3, relativeVolume: 1.4, radiusScale: 1.12 },
|
||
{ sizeTier: 'M', ratio: 0.3, relativeVolume: 1, radiusScale: 1 },
|
||
{ sizeTier: 'XS', ratio: 0.15, relativeVolume: 0.73, radiusScale: 0.9 },
|
||
{ sizeTier: 'S', ratio: 0.05, relativeVolume: 0.44, radiusScale: 0.76 },
|
||
];
|
||
|
||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||
// 中文注释:默认 20 类对齐 10*10 物品 Sprite,可由生成素材替换显示。
|
||
{
|
||
itemTypeId: 'block-red-2x4',
|
||
visualKey: 'block-red-2x4',
|
||
colorClassName: 'from-rose-400 to-red-700',
|
||
label: '红色二乘四',
|
||
},
|
||
{
|
||
itemTypeId: 'block-blue-1x2',
|
||
visualKey: 'block-blue-1x2',
|
||
colorClassName: 'from-blue-300 to-blue-700',
|
||
label: '蓝色一乘二',
|
||
},
|
||
{
|
||
itemTypeId: 'block-yellow-2x2',
|
||
visualKey: 'block-yellow-2x2',
|
||
colorClassName: 'from-yellow-300 to-yellow-600',
|
||
label: '黄色二乘二',
|
||
},
|
||
{
|
||
itemTypeId: 'block-green-1x4',
|
||
visualKey: 'block-green-1x4',
|
||
colorClassName: 'from-emerald-300 to-green-700',
|
||
label: '绿色一乘四',
|
||
},
|
||
{
|
||
itemTypeId: 'block-orange-1x6',
|
||
visualKey: 'block-orange-1x6',
|
||
colorClassName: 'from-orange-300 to-orange-700',
|
||
label: '橙色一乘六',
|
||
},
|
||
{
|
||
itemTypeId: 'block-white-1x1',
|
||
visualKey: 'block-white-1x1',
|
||
colorClassName: 'from-slate-50 to-slate-300',
|
||
label: '白色一乘一',
|
||
},
|
||
{
|
||
itemTypeId: 'block-black-1x8',
|
||
visualKey: 'block-black-1x8',
|
||
colorClassName: 'from-zinc-700 to-black',
|
||
label: '黑色一乘八',
|
||
},
|
||
{
|
||
itemTypeId: 'block-tan-2x3',
|
||
visualKey: 'block-tan-2x3',
|
||
colorClassName: 'from-amber-100 to-yellow-600',
|
||
label: '米色二乘三',
|
||
},
|
||
{
|
||
itemTypeId: 'block-darkred-2x2',
|
||
visualKey: 'block-darkred-2x2',
|
||
colorClassName: 'from-red-700 to-red-950',
|
||
label: '深红二乘二',
|
||
},
|
||
{
|
||
itemTypeId: 'block-blue-1x4',
|
||
visualKey: 'block-blue-1x4',
|
||
colorClassName: 'from-sky-300 to-blue-700',
|
||
label: '蓝色一乘四',
|
||
},
|
||
{
|
||
itemTypeId: 'block-pink-2x4',
|
||
visualKey: 'block-pink-2x4',
|
||
colorClassName: 'from-pink-300 to-pink-600',
|
||
label: '粉色二乘四',
|
||
},
|
||
{
|
||
itemTypeId: 'block-gray-1x6',
|
||
visualKey: 'block-gray-1x6',
|
||
colorClassName: 'from-zinc-400 to-zinc-700',
|
||
label: '灰色一乘六',
|
||
},
|
||
{
|
||
itemTypeId: 'block-lavender-tile-2x2',
|
||
visualKey: 'block-lavender-tile-2x2',
|
||
colorClassName: 'from-purple-200 to-purple-500',
|
||
label: '薰衣草光板',
|
||
},
|
||
{
|
||
itemTypeId: 'block-teal-tile-1x3',
|
||
visualKey: 'block-teal-tile-1x3',
|
||
colorClassName: 'from-teal-300 to-teal-700',
|
||
label: '青色长光板',
|
||
},
|
||
{
|
||
itemTypeId: 'block-orange-tile-2x2-stud',
|
||
visualKey: 'block-orange-tile-2x2-stud',
|
||
colorClassName: 'from-orange-300 to-amber-700',
|
||
label: '橙色单钉板',
|
||
},
|
||
{
|
||
itemTypeId: 'block-purple-slope-1x2',
|
||
visualKey: 'block-purple-slope-1x2',
|
||
colorClassName: 'from-violet-400 to-violet-900',
|
||
label: '紫色斜坡',
|
||
},
|
||
{
|
||
itemTypeId: 'block-green-cylinder',
|
||
visualKey: 'block-green-cylinder',
|
||
colorClassName: 'from-green-400 to-green-800',
|
||
label: '绿色圆柱',
|
||
},
|
||
{
|
||
itemTypeId: 'block-clear-ring',
|
||
visualKey: 'block-clear-ring',
|
||
colorClassName: 'from-slate-50 to-slate-300',
|
||
label: '透明圆环',
|
||
},
|
||
{
|
||
itemTypeId: 'block-mint-arch',
|
||
visualKey: 'block-mint-arch',
|
||
colorClassName: 'from-emerald-100 to-emerald-300',
|
||
label: '薄荷拱门',
|
||
},
|
||
{
|
||
itemTypeId: 'block-gold-cone',
|
||
visualKey: 'block-gold-cone',
|
||
colorClassName: 'from-yellow-300 to-amber-700',
|
||
label: '金色锥形件',
|
||
},
|
||
];
|
||
|
||
function hashNumber(value: number) {
|
||
let state = Math.max(1, value >>> 0);
|
||
state ^= state << 13;
|
||
state ^= state >>> 7;
|
||
state ^= state << 17;
|
||
return state >>> 0;
|
||
}
|
||
|
||
function resolveSizeTierPlan(typeCount: number) {
|
||
const baseCounts = MATCH3D_SIZE_TIER_RULES.map((rule) => ({
|
||
...rule,
|
||
count: Math.floor(typeCount * rule.ratio),
|
||
remainder: typeCount * rule.ratio - Math.floor(typeCount * rule.ratio),
|
||
}));
|
||
let assignedCount = baseCounts.reduce((sum, rule) => sum + rule.count, 0);
|
||
const remainderOrder = [...baseCounts].sort(
|
||
(left, right) => right.remainder - left.remainder,
|
||
);
|
||
let cursor = 0;
|
||
while (assignedCount < typeCount) {
|
||
remainderOrder[cursor % remainderOrder.length]!.count += 1;
|
||
assignedCount += 1;
|
||
cursor += 1;
|
||
}
|
||
|
||
return baseCounts.flatMap((rule) => Array(rule.count).fill(rule));
|
||
}
|
||
|
||
export function resolveLocalMatch3DItemTypeCount(clearCount: number) {
|
||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||
if (normalizedClearCount === 8) return 3;
|
||
if (normalizedClearCount === 12) return 9;
|
||
if (normalizedClearCount === 16) return 15;
|
||
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 20;
|
||
return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount);
|
||
}
|
||
|
||
export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) {
|
||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩保留硬核 21 组三消节奏,
|
||
// 但物品类型池最多加载 20 种,避免超过 10*10 Sprite 解析素材上限。
|
||
return normalizedClearCount === 20 ? 21 : normalizedClearCount;
|
||
}
|
||
|
||
function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] {
|
||
const typeCount = resolveLocalMatch3DItemTypeCount(clearCount);
|
||
const seeds = [...MATCH3D_VISUAL_SEEDS];
|
||
let state = hashNumber(clearCount * 2_654_435_761);
|
||
for (let index = seeds.length - 1; index > 0; index -= 1) {
|
||
state = hashNumber(state + index);
|
||
const swapIndex = state % (index + 1);
|
||
[seeds[index], seeds[swapIndex]] = [seeds[swapIndex]!, seeds[index]!];
|
||
}
|
||
const sizeTierPlan = resolveSizeTierPlan(typeCount);
|
||
return seeds.slice(0, typeCount).map((seed, index) => ({
|
||
...seed,
|
||
radiusScale: sizeTierPlan[index]!.radiusScale,
|
||
relativeVolume: sizeTierPlan[index]!.relativeVolume,
|
||
sizeTier: sizeTierPlan[index]!.sizeTier,
|
||
}));
|
||
}
|
||
|
||
function createEmptyTray(): Match3DTraySlot[] {
|
||
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
|
||
slotIndex,
|
||
}));
|
||
}
|
||
|
||
function resolveLocalMatch3DSpawnPoint(
|
||
index: number,
|
||
totalItemCount: number,
|
||
radius: number,
|
||
) {
|
||
const safeRadius = Math.max(
|
||
0,
|
||
MATCH3D_LOCAL_BOARD_RADIUS - MATCH3D_LOCAL_BOARD_SAFE_MARGIN - radius,
|
||
);
|
||
const mouthRadius = safeRadius * MATCH3D_LOCAL_CONTAINER_MOUTH_RATIO;
|
||
const normalizedIndex = Math.max(0, index);
|
||
const normalizedTotal = Math.max(1, totalItemCount);
|
||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||
const distance =
|
||
Math.sqrt((normalizedIndex + 0.5) / normalizedTotal) * mouthRadius;
|
||
const angle = normalizedIndex * goldenAngle;
|
||
const jitterRadius = Math.min(0.012, mouthRadius * 0.035);
|
||
const jitterAngle = angle * 1.7 + 0.9;
|
||
const x =
|
||
MATCH3D_LOCAL_BOARD_CENTER +
|
||
Math.cos(angle) * distance +
|
||
Math.cos(jitterAngle) * jitterRadius;
|
||
const y =
|
||
MATCH3D_LOCAL_BOARD_CENTER +
|
||
Math.sin(angle) * distance +
|
||
Math.sin(jitterAngle) * jitterRadius;
|
||
const dx = x - MATCH3D_LOCAL_BOARD_CENTER;
|
||
const dy = y - MATCH3D_LOCAL_BOARD_CENTER;
|
||
const currentDistance = Math.hypot(dx, dy);
|
||
if (currentDistance <= safeRadius || currentDistance <= 0) {
|
||
return { x, y };
|
||
}
|
||
const ratio = safeRadius / currentDistance;
|
||
return {
|
||
x: MATCH3D_LOCAL_BOARD_CENTER + dx * ratio,
|
||
y: MATCH3D_LOCAL_BOARD_CENTER + dy * ratio,
|
||
};
|
||
}
|
||
|
||
function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
|
||
if (run.status !== 'Running') {
|
||
return run;
|
||
}
|
||
const elapsedMs = Math.max(0, nowMs - run.startedAtMs);
|
||
const remainingMs = Math.max(0, run.durationLimitMs - elapsedMs);
|
||
if (remainingMs > 0) {
|
||
return {
|
||
...run,
|
||
serverNowMs: nowMs,
|
||
remainingMs,
|
||
};
|
||
}
|
||
return {
|
||
...run,
|
||
status: 'Failed' as const,
|
||
serverNowMs: nowMs,
|
||
remainingMs: 0,
|
||
failureReason: 'TimeUp' as const,
|
||
snapshotVersion: run.snapshotVersion + 1,
|
||
};
|
||
}
|
||
|
||
function buildItem(
|
||
seed: Match3DSelectedVisualSeed,
|
||
index: number,
|
||
copyIndex: number,
|
||
totalItemCount: number,
|
||
): Match3DItemSnapshot {
|
||
const radius = MATCH3D_LOCAL_BASE_RADIUS * seed.radiusScale;
|
||
const point = resolveLocalMatch3DSpawnPoint(index, totalItemCount, radius);
|
||
return {
|
||
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
||
itemTypeId: seed.itemTypeId,
|
||
visualKey: seed.visualKey,
|
||
x: point.x,
|
||
y: point.y,
|
||
radius,
|
||
layer: index + 1,
|
||
state: 'InBoard',
|
||
clickable: true,
|
||
};
|
||
}
|
||
|
||
function recomputeClickable(items: Match3DItemSnapshot[]) {
|
||
const boardItems = items.filter((item) => item.state === 'InBoard');
|
||
return items.map((item) => {
|
||
if (item.state !== 'InBoard') {
|
||
return {
|
||
...item,
|
||
clickable: false,
|
||
};
|
||
}
|
||
const coveredByHigherLayer = boardItems.some((other) => {
|
||
if (
|
||
other.itemInstanceId === item.itemInstanceId ||
|
||
other.layer <= item.layer
|
||
) {
|
||
return false;
|
||
}
|
||
const distance = Math.hypot(other.x - item.x, other.y - item.y);
|
||
return distance < Math.min(item.radius, other.radius) * 0.78;
|
||
});
|
||
return {
|
||
...item,
|
||
clickable: !coveredByHigherLayer,
|
||
};
|
||
});
|
||
}
|
||
|
||
function countClearedItems(items: Match3DItemSnapshot[]) {
|
||
return items.filter((item) => item.state === 'Cleared').length;
|
||
}
|
||
|
||
function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
||
const clearedItemCount = countClearedItems(run.items);
|
||
if (clearedItemCount >= run.totalItemCount) {
|
||
return {
|
||
...run,
|
||
status: 'Won',
|
||
clearedItemCount,
|
||
remainingMs: Math.max(0, run.remainingMs),
|
||
};
|
||
}
|
||
const trayIsFull = run.traySlots.every((slot) =>
|
||
Boolean(slot.itemInstanceId),
|
||
);
|
||
if (trayIsFull) {
|
||
return {
|
||
...run,
|
||
status: 'Failed',
|
||
clearedItemCount,
|
||
failureReason: 'TrayFull',
|
||
};
|
||
}
|
||
return {
|
||
...run,
|
||
status: 'Running',
|
||
failureReason: undefined,
|
||
clearedItemCount,
|
||
};
|
||
}
|
||
|
||
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)
|
||
.map((slot) => slot.itemInstanceId)
|
||
.filter((itemInstanceId): itemInstanceId is string =>
|
||
Boolean(itemInstanceId),
|
||
);
|
||
const clearedSet = new Set(clearedItemInstanceIds);
|
||
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,
|
||
items: syncMatch3DItemTraySlotIndexes(
|
||
nextRun.items,
|
||
nextRun.traySlots,
|
||
),
|
||
},
|
||
clearedItemInstanceIds,
|
||
};
|
||
}
|
||
|
||
export function startLocalMatch3DRun(
|
||
clearCount = 12,
|
||
profileId = 'local-match3d-profile',
|
||
): Match3DRunSnapshot {
|
||
const normalizedClearCount =
|
||
normalizeLocalMatch3DRuntimeClearCount(clearCount);
|
||
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
|
||
const totalItemCount = normalizedClearCount * 3;
|
||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||
const seed =
|
||
selectedSeeds[clearIndex % selectedSeeds.length] ?? selectedSeeds[0]!;
|
||
return buildItem(
|
||
seed,
|
||
clearIndex * 3 + copyOffset,
|
||
clearIndex * 3 + copyOffset,
|
||
totalItemCount,
|
||
);
|
||
}),
|
||
).flat();
|
||
const nowMs = Date.now();
|
||
return {
|
||
runId: `local-match3d-run-${nowMs}`,
|
||
profileId,
|
||
status: 'Running',
|
||
snapshotVersion: 1,
|
||
startedAtMs: nowMs,
|
||
durationLimitMs: MATCH3D_LOCAL_DURATION_MS,
|
||
serverNowMs: nowMs,
|
||
remainingMs: MATCH3D_LOCAL_DURATION_MS,
|
||
clearCount: normalizedClearCount,
|
||
totalItemCount: items.length,
|
||
clearedItemCount: 0,
|
||
traySlots: createEmptyTray(),
|
||
items: recomputeClickable(items),
|
||
};
|
||
}
|
||
|
||
export function resolveLocalMatch3DTimer(run: Match3DRunSnapshot) {
|
||
return normalizeRemainingMs(run);
|
||
}
|
||
|
||
export function buildLocalMatch3DOptimisticRun(
|
||
run: Match3DRunSnapshot,
|
||
itemInstanceId: string,
|
||
): Match3DRunSnapshot {
|
||
const targetItem = run.items.find(
|
||
(item) => item.itemInstanceId === itemInstanceId,
|
||
);
|
||
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: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots),
|
||
traySlots: insertion.traySlots,
|
||
};
|
||
}
|
||
|
||
function waitForLocalConfirmation(delayMs: number) {
|
||
const scheduler = globalThis.setTimeout;
|
||
return new Promise((resolve) => scheduler(resolve, delayMs));
|
||
}
|
||
|
||
export async function confirmLocalMatch3DClick(
|
||
run: Match3DRunSnapshot,
|
||
request: Match3DClickItemRequest,
|
||
): Promise<Match3DClickItemResult> {
|
||
// 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
|
||
await waitForLocalConfirmation(180);
|
||
const timedRun = normalizeRemainingMs(run);
|
||
if (timedRun.status !== 'Running') {
|
||
return {
|
||
status: 'RunFinished',
|
||
run: timedRun,
|
||
clearedItemInstanceIds: [],
|
||
failureReason: timedRun.failureReason,
|
||
};
|
||
}
|
||
if (request.clientSnapshotVersion !== run.snapshotVersion) {
|
||
return {
|
||
status: 'VersionConflict',
|
||
run: timedRun,
|
||
clearedItemInstanceIds: [],
|
||
};
|
||
}
|
||
|
||
const targetItem = run.items.find(
|
||
(item) => item.itemInstanceId === request.itemInstanceId,
|
||
);
|
||
if (!targetItem || targetItem.state !== 'InBoard') {
|
||
return {
|
||
status: 'RejectedAlreadyMoved',
|
||
run: timedRun,
|
||
clearedItemInstanceIds: [],
|
||
};
|
||
}
|
||
if (!targetItem.clickable) {
|
||
return {
|
||
status: 'RejectedNotClickable',
|
||
run: timedRun,
|
||
clearedItemInstanceIds: [],
|
||
};
|
||
}
|
||
const insertion = buildMatch3DTrayInsertionPlan(timedRun.traySlots, targetItem);
|
||
if (!insertion) {
|
||
const failedRun = {
|
||
...timedRun,
|
||
status: 'Failed' as const,
|
||
failureReason: 'TrayFull' as const,
|
||
snapshotVersion: run.snapshotVersion + 1,
|
||
};
|
||
return {
|
||
status: 'RejectedTrayFull',
|
||
run: failedRun,
|
||
clearedItemInstanceIds: [],
|
||
failureReason: 'TrayFull',
|
||
};
|
||
}
|
||
|
||
const movedRun: Match3DRunSnapshot = {
|
||
...timedRun,
|
||
snapshotVersion: run.snapshotVersion + 1,
|
||
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, targetItem.itemTypeId);
|
||
const nextRun = resolveRunStatus({
|
||
...settled.run,
|
||
items: recomputeClickable(settled.run.items),
|
||
});
|
||
|
||
return {
|
||
status: 'Accepted',
|
||
run: nextRun,
|
||
acceptedItemInstanceId: targetItem.itemInstanceId,
|
||
clearedItemInstanceIds: settled.clearedItemInstanceIds,
|
||
failureReason: nextRun.failureReason,
|
||
};
|
||
}
|
||
|
||
export function stopLocalMatch3DRun(
|
||
run: Match3DRunSnapshot,
|
||
): Match3DRunSnapshot {
|
||
if (run.status !== 'Running') {
|
||
return run;
|
||
}
|
||
return {
|
||
...run,
|
||
status: 'Stopped',
|
||
snapshotVersion: run.snapshotVersion + 1,
|
||
};
|
||
}
|