Merge remote-tracking branch 'origin/master'
# Conflicts: # docs/technical/README.md # server-rs/crates/spacetime-client/src/module_bindings/mod.rs
This commit is contained in:
7
src/services/match3d-creation/index.ts
Normal file
7
src/services/match3d-creation/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createMatch3DCreationSession,
|
||||
executeMatch3DCreationAction,
|
||||
getMatch3DCreationSession,
|
||||
match3dCreationClient,
|
||||
streamMatch3DCreationMessage,
|
||||
} from './match3dCreationClient';
|
||||
361
src/services/match3d-creation/match3dCreationClient.ts
Normal file
361
src/services/match3d-creation/match3dCreationClient.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse,
|
||||
Match3DAgentMessageResponse,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorItemResponse,
|
||||
Match3DCreatorConfig,
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
|
||||
const MOCK_RESPONSE_DELAY_MS = 180;
|
||||
const MATCH3D_SESSION_PREFIX = 'match3d-session';
|
||||
|
||||
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
|
||||
themeText: '缤纷玩具',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
};
|
||||
|
||||
let match3dSessionCounter = 0;
|
||||
const mockSessions = new Map<string, Match3DAgentSessionSnapshot>();
|
||||
|
||||
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
|
||||
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createMessage(
|
||||
sessionId: string,
|
||||
role: Match3DAgentMessageResponse['role'],
|
||||
text: string,
|
||||
kind: Match3DAgentMessageResponse['kind'] = 'chat',
|
||||
): Match3DAgentMessageResponse {
|
||||
return {
|
||||
id: `${sessionId}-message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
role,
|
||||
kind,
|
||||
text,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnchor(
|
||||
key: string,
|
||||
label: string,
|
||||
value: string,
|
||||
): Match3DAnchorItemResponse {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
status: value.trim() ? 'confirmed' : 'missing',
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnchorPack(config: Partial<Match3DCreatorConfig>) {
|
||||
return {
|
||||
theme: buildAnchor('theme', '题材主题', config.themeText ?? ''),
|
||||
clearCount: buildAnchor(
|
||||
'clearCount',
|
||||
'需要消除次数',
|
||||
typeof config.clearCount === 'number' ? String(config.clearCount) : '',
|
||||
),
|
||||
difficulty: buildAnchor(
|
||||
'difficulty',
|
||||
'难度',
|
||||
typeof config.difficulty === 'number' ? String(config.difficulty) : '',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = Math.floor(value);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeDifficulty(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(10, Math.round(value)));
|
||||
}
|
||||
|
||||
function buildConfigFromPartial(
|
||||
partial: Partial<Match3DCreatorConfig>,
|
||||
): Match3DCreatorConfig | null {
|
||||
const themeText = partial.themeText?.trim();
|
||||
const clearCount = normalizePositiveInteger(partial.clearCount);
|
||||
const difficulty = normalizeDifficulty(partial.difficulty);
|
||||
|
||||
if (!themeText || !clearCount || !difficulty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
themeText,
|
||||
referenceImageSrc: partial.referenceImageSrc ?? null,
|
||||
clearCount,
|
||||
difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigFromText(
|
||||
text: string,
|
||||
current: Partial<Match3DCreatorConfig>,
|
||||
): Partial<Match3DCreatorConfig> {
|
||||
const next = { ...current };
|
||||
const trimmedText = text.trim();
|
||||
|
||||
const themeMatch =
|
||||
trimmedText.match(/(?:题材|主题)[::\s]*([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})/u) ??
|
||||
trimmedText.match(/(?:想做|做成|选择|使用)([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})(?:题材|主题)/u);
|
||||
const clearCountMatch =
|
||||
trimmedText.match(/(?:消除|次数)[::\s]*(\d+)/u) ??
|
||||
trimmedText.match(/(\d+)\s*(?:次消除|次)/u);
|
||||
const difficultyMatch =
|
||||
trimmedText.match(/(?:难度)[::\s]*(10|[1-9])/u) ??
|
||||
trimmedText.match(/(?:难一点|困难)/u);
|
||||
|
||||
if (themeMatch?.[1]) {
|
||||
next.themeText = themeMatch[1].trim();
|
||||
}
|
||||
|
||||
if (clearCountMatch?.[1]) {
|
||||
next.clearCount = Number(clearCountMatch[1]);
|
||||
}
|
||||
|
||||
if (difficultyMatch?.[1]) {
|
||||
next.difficulty = Number(difficultyMatch[1]);
|
||||
} else if (difficultyMatch?.[0]) {
|
||||
next.difficulty = 7;
|
||||
}
|
||||
|
||||
if (!next.themeText && trimmedText.length >= 2 && trimmedText.length <= 24) {
|
||||
next.themeText = trimmedText;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveSessionProgress(config: Partial<Match3DCreatorConfig>) {
|
||||
const completed = [
|
||||
Boolean(config.themeText?.trim()),
|
||||
Boolean(normalizePositiveInteger(config.clearCount)),
|
||||
Boolean(normalizeDifficulty(config.difficulty)),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return Math.round((completed / 3) * 100);
|
||||
}
|
||||
|
||||
function buildAssistantReply(config: Partial<Match3DCreatorConfig>) {
|
||||
const missing: string[] = [];
|
||||
if (!config.themeText?.trim()) {
|
||||
missing.push('题材主题');
|
||||
}
|
||||
if (!normalizePositiveInteger(config.clearCount)) {
|
||||
missing.push('需要消除次数');
|
||||
}
|
||||
if (!normalizeDifficulty(config.difficulty)) {
|
||||
missing.push('难度');
|
||||
}
|
||||
|
||||
if (missing.length === 0) {
|
||||
const readyConfig = buildConfigFromPartial(config) ?? DEFAULT_MATCH3D_CONFIG;
|
||||
return `已确认:${readyConfig.themeText}题材,消除 ${readyConfig.clearCount} 次,共 ${readyConfig.clearCount * 3} 件物品,难度 ${readyConfig.difficulty}。可以生成结果页。`;
|
||||
}
|
||||
|
||||
return `还需要确认:${missing.join('、')}。`;
|
||||
}
|
||||
|
||||
function updateSessionConfig(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
partialConfig: Partial<Match3DCreatorConfig>,
|
||||
) {
|
||||
const progressPercent = resolveSessionProgress(partialConfig);
|
||||
const config = buildConfigFromPartial(partialConfig);
|
||||
|
||||
return {
|
||||
...session,
|
||||
progressPercent,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: buildAnchorPack(partialConfig),
|
||||
config,
|
||||
updatedAt: nowIso(),
|
||||
} satisfies Match3DAgentSessionSnapshot;
|
||||
}
|
||||
|
||||
function ensureMockSession(sessionId: string) {
|
||||
const session = mockSessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error('抓大鹅创作会话不存在,请重新开始创作。');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function buildDraft(config: Match3DCreatorConfig) {
|
||||
return {
|
||||
gameName: `${config.themeText}抓大鹅`,
|
||||
themeText: config.themeText,
|
||||
summaryText: `${config.themeText}题材的经典三消收纳关卡。`,
|
||||
tags: [config.themeText, '抓大鹅', '消除'].slice(0, 3),
|
||||
coverImageSrc: config.referenceImageSrc ?? null,
|
||||
clearCount: config.clearCount,
|
||||
difficulty: config.difficulty,
|
||||
totalItemCount: config.clearCount * 3,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMatch3DCreationSession(
|
||||
payload: CreateMatch3DSessionRequest = {},
|
||||
): Promise<Match3DSessionResponse> {
|
||||
await delay();
|
||||
|
||||
match3dSessionCounter += 1;
|
||||
const sessionId = `${MATCH3D_SESSION_PREFIX}-${match3dSessionCounter}`;
|
||||
const partialConfig: Partial<Match3DCreatorConfig> = {
|
||||
themeText: payload.themeText ?? payload.seedText,
|
||||
referenceImageSrc: payload.referenceImageSrc ?? null,
|
||||
clearCount: payload.clearCount,
|
||||
difficulty: payload.difficulty,
|
||||
};
|
||||
const now = nowIso();
|
||||
const session: Match3DAgentSessionSnapshot = updateSessionConfig(
|
||||
{
|
||||
sessionId,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: buildAnchorPack(partialConfig),
|
||||
config: null,
|
||||
draft: null,
|
||||
messages: [
|
||||
createMessage(
|
||||
sessionId,
|
||||
'assistant',
|
||||
'先确认题材、需要消除次数和难度。也可以直接说“自动配置”。',
|
||||
),
|
||||
],
|
||||
lastAssistantReply: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
partialConfig,
|
||||
);
|
||||
|
||||
mockSessions.set(sessionId, session);
|
||||
return { session };
|
||||
}
|
||||
|
||||
export async function getMatch3DCreationSession(sessionId: string) {
|
||||
await delay(80);
|
||||
return { session: ensureMockSession(sessionId) };
|
||||
}
|
||||
|
||||
export async function streamMatch3DCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendMatch3DMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
): Promise<Match3DAgentSessionSnapshot> {
|
||||
await delay(120);
|
||||
const session = ensureMockSession(sessionId);
|
||||
const text = payload.text.trim();
|
||||
const currentConfig: Partial<Match3DCreatorConfig> = session.config ?? {
|
||||
themeText: session.anchorPack.theme.value,
|
||||
clearCount: Number(session.anchorPack.clearCount.value) || undefined,
|
||||
difficulty: Number(session.anchorPack.difficulty.value) || undefined,
|
||||
};
|
||||
const nextConfig =
|
||||
payload.quickFillRequested || /自动配置/u.test(text)
|
||||
? {
|
||||
...DEFAULT_MATCH3D_CONFIG,
|
||||
themeText: currentConfig.themeText || DEFAULT_MATCH3D_CONFIG.themeText,
|
||||
}
|
||||
: parseConfigFromText(text, currentConfig);
|
||||
const userMessage = {
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text,
|
||||
createdAt: nowIso(),
|
||||
} satisfies Match3DAgentMessageResponse;
|
||||
const assistantReply = buildAssistantReply(nextConfig);
|
||||
|
||||
options.onUpdate?.(assistantReply.slice(0, Math.ceil(assistantReply.length / 2)));
|
||||
await delay(80);
|
||||
options.onUpdate?.(assistantReply);
|
||||
await delay(80);
|
||||
|
||||
const nextSession = updateSessionConfig(
|
||||
{
|
||||
...session,
|
||||
currentTurn: session.currentTurn + 1,
|
||||
messages: [
|
||||
...session.messages,
|
||||
userMessage,
|
||||
createMessage(sessionId, 'assistant', assistantReply),
|
||||
],
|
||||
lastAssistantReply: assistantReply,
|
||||
},
|
||||
{
|
||||
...nextConfig,
|
||||
referenceImageSrc:
|
||||
payload.referenceImageSrc ?? currentConfig.referenceImageSrc ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
mockSessions.set(sessionId, nextSession);
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
export async function executeMatch3DCreationAction(
|
||||
sessionId: string,
|
||||
payload: ExecuteMatch3DActionRequest,
|
||||
): Promise<Match3DActionResponse> {
|
||||
await delay(220);
|
||||
const session = ensureMockSession(sessionId);
|
||||
|
||||
if (payload.action !== 'match3d_compile_draft') {
|
||||
throw new Error('未知抓大鹅创作操作。');
|
||||
}
|
||||
|
||||
const config = session.config ?? buildConfigFromPartial(DEFAULT_MATCH3D_CONFIG);
|
||||
if (!config) {
|
||||
throw new Error('请先确认题材、需要消除次数和难度。');
|
||||
}
|
||||
|
||||
const nextSession = {
|
||||
...session,
|
||||
stage: 'draft_ready',
|
||||
progressPercent: 100,
|
||||
config,
|
||||
draft: buildDraft(config),
|
||||
lastAssistantReply: '抓大鹅草稿已准备完成。',
|
||||
messages: [
|
||||
...session.messages,
|
||||
createMessage(sessionId, 'assistant', '抓大鹅草稿已准备完成。', 'summary'),
|
||||
],
|
||||
updatedAt: nowIso(),
|
||||
} satisfies Match3DAgentSessionSnapshot;
|
||||
|
||||
mockSessions.set(sessionId, nextSession);
|
||||
return { session: nextSession };
|
||||
}
|
||||
|
||||
export const match3dCreationClient = {
|
||||
createSession: createMatch3DCreationSession,
|
||||
getSession: getMatch3DCreationSession,
|
||||
streamMessage: streamMatch3DCreationMessage,
|
||||
executeAction: executeMatch3DCreationAction,
|
||||
};
|
||||
8
src/services/match3d-runtime/index.ts
Normal file
8
src/services/match3d-runtime/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
buildLocalMatch3DOptimisticRun,
|
||||
confirmLocalMatch3DClick,
|
||||
MATCH3D_VISUAL_SEEDS,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
409
src/services/match3d-runtime/match3dLocalRuntime.ts
Normal file
409
src/services/match3d-runtime/match3dLocalRuntime.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
|
||||
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
||||
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
||||
|
||||
type Match3DVisualSeed = {
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
colorClassName: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
{
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'apple-red',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '苹',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'banana',
|
||||
visualKey: 'banana-yellow',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '蕉',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'grape',
|
||||
visualKey: 'grape-purple',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '萄',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'melon',
|
||||
visualKey: 'melon-green',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '瓜',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'berry',
|
||||
visualKey: 'berry-blue',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '莓',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'peach',
|
||||
visualKey: 'peach-pink',
|
||||
colorClassName: 'from-pink-300 to-orange-400',
|
||||
label: '桃',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'plum',
|
||||
visualKey: 'plum-indigo',
|
||||
colorClassName: 'from-indigo-300 to-indigo-700',
|
||||
label: '李',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime',
|
||||
visualKey: 'lime-lime',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '柠',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange',
|
||||
visualKey: 'orange-orange',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '橙',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'candy',
|
||||
visualKey: 'candy-cyan',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '糖',
|
||||
},
|
||||
];
|
||||
|
||||
function createEmptyTray(): Match3DTraySlot[] {
|
||||
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
|
||||
slotIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
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: Match3DVisualSeed,
|
||||
index: number,
|
||||
copyIndex: number,
|
||||
): Match3DItemSnapshot {
|
||||
const ring = Math.floor(index / 6);
|
||||
const angle = index * 0.86 + copyIndex * 0.22;
|
||||
const spread = 0.16 + (ring % 4) * 0.085;
|
||||
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
|
||||
const y = 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
|
||||
const radius = 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
|
||||
return {
|
||||
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
||||
itemTypeId: seed.itemTypeId,
|
||||
visualKey: seed.visualKey,
|
||||
x: Math.max(0.18, Math.min(0.82, x)),
|
||||
y: Math.max(0.18, Math.min(0.82, 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 findNextTrayIndex(traySlots: Match3DTraySlot[]) {
|
||||
return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1;
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
if (!matchedSlots) {
|
||||
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 nextRun = {
|
||||
...run,
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.itemInstanceId && clearedSet.has(slot.itemInstanceId)
|
||||
? { slotIndex: slot.slotIndex }
|
||||
: slot,
|
||||
),
|
||||
items: run.items.map((item) =>
|
||||
clearedSet.has(item.itemInstanceId)
|
||||
? {
|
||||
...item,
|
||||
state: 'Cleared' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
run: nextRun,
|
||||
clearedItemInstanceIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const typeCount = Math.min(MATCH3D_VISUAL_SEEDS.length, normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
const seed = MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? MATCH3D_VISUAL_SEEDS[0]!;
|
||||
return buildItem(seed, clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset);
|
||||
}),
|
||||
).flat();
|
||||
const nowMs = Date.now();
|
||||
return {
|
||||
runId: `local-match3d-run-${nowMs}`,
|
||||
profileId: 'local-match3d-profile',
|
||||
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);
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
|
||||
return run;
|
||||
}
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmLocalMatch3DClick(
|
||||
run: Match3DRunSnapshot,
|
||||
request: Match3DClickItemRequest,
|
||||
): Promise<Match3DClickItemResult> {
|
||||
// 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 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 nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (nextTrayIndex < 0) {
|
||||
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: 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,
|
||||
),
|
||||
};
|
||||
const settled = settleMatchedTrayItems(movedRun);
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user