refactor match3d runtime adapters

This commit is contained in:
2026-05-12 14:02:42 +08:00
parent 5cb5329f4e
commit 22810245f5
8 changed files with 358 additions and 43 deletions

View File

@@ -6,6 +6,11 @@ export {
startLocalMatch3DRun,
stopLocalMatch3DRun,
} from './match3dLocalRuntime';
export {
createLocalMatch3DRuntimeAdapter,
createServerMatch3DRuntimeAdapter,
type Match3DRuntimeAdapter,
} from './match3dRuntimeAdapter';
export {
clickMatch3DItem,
finishMatch3DTimeUp,

View File

@@ -481,12 +481,17 @@ export function buildLocalMatch3DOptimisticRun(
};
}
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 new Promise((resolve) => window.setTimeout(resolve, 180));
await waitForLocalConfirmation(180);
const timedRun = normalizeRemainingMs(run);
if (timedRun.status !== 'Running') {
return {

View File

@@ -0,0 +1,144 @@
import { expect, test, vi } from 'vitest';
import type {
Match3DClickItemRequest,
Match3DRunResponse,
Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
createLocalMatch3DRuntimeAdapter,
createServerMatch3DRuntimeAdapter,
type Match3DRuntimeAdapter,
startLocalMatch3DRun,
} from './index';
function buildMockRun(runId: string): Match3DRunSnapshot {
return {
runId,
profileId: 'server-profile-1',
ownerUserId: 'server-owner-1',
status: 'Running',
snapshotVersion: 1,
startedAtMs: 1_700_000_000_000,
durationLimitMs: 30_000,
serverNowMs: 1_700_000_000_000,
remainingMs: 30_000,
clearCount: 3,
totalItemCount: 0,
clearedItemCount: 0,
boardVersion: 1,
items: [],
traySlots: [],
failureReason: null,
lastConfirmedActionId: null,
};
}
test('server Match3D runtime adapter forwards the full runtime seam lazily', async () => {
const startResponse: Match3DRunResponse = { run: buildMockRun('server-run-start') };
const getResponse: Match3DRunResponse = { run: buildMockRun('server-run-get') };
const restartResponse: Match3DRunResponse = { run: buildMockRun('server-run-restart') };
const stopResponse: Match3DRunResponse = {
run: { ...buildMockRun('server-run-stop'), status: 'Stopped' },
};
const finishResponse: Match3DRunResponse = {
run: { ...buildMockRun('server-run-finish'), status: 'Timeout' },
};
const clickPayload: Match3DClickItemRequest = {
runId: 'server-run-start',
itemInstanceId: 'item-1',
clientActionId: 'action-1',
clientEventId: 'event-1',
clickedAtMs: 1_700_000_000_001,
clientSnapshotVersion: 1,
};
const dependencies = {
clickItem: vi.fn().mockResolvedValue({
status: 'Accepted' as const,
run: buildMockRun('server-run-click'),
}),
finishTimeUp: vi.fn().mockResolvedValue(finishResponse),
getRun: vi.fn().mockResolvedValue(getResponse),
restartRun: vi.fn().mockResolvedValue(restartResponse),
startRun: vi.fn().mockResolvedValue(startResponse),
stopRun: vi.fn().mockResolvedValue(stopResponse),
};
const adapter = createServerMatch3DRuntimeAdapter(dependencies);
expect(await adapter.startRun('server-profile-1', { skipRefresh: true })).toBe(
startResponse,
);
expect(await adapter.getRun('server-run-start')).toBe(getResponse);
expect(await adapter.clickItem('server-run-start', clickPayload)).toEqual({
status: 'Accepted',
run: buildMockRun('server-run-click'),
});
expect(await adapter.restartRun('server-run-start')).toBe(restartResponse);
expect(await adapter.stopRun('server-run-restart')).toBe(stopResponse);
expect(await adapter.finishTimeUp('server-run-start')).toBe(finishResponse);
expect(dependencies.startRun).toHaveBeenCalledWith('server-profile-1', {
skipRefresh: true,
});
expect(dependencies.getRun).toHaveBeenCalledWith('server-run-start');
expect(dependencies.clickItem).toHaveBeenCalledWith(
'server-run-start',
clickPayload,
);
expect(dependencies.restartRun).toHaveBeenCalledWith('server-run-start');
expect(dependencies.stopRun).toHaveBeenCalledWith('server-run-restart');
expect(dependencies.finishTimeUp).toHaveBeenCalledWith('server-run-start');
});
test('local Match3D runtime adapter exposes the same runtime seam as the server client', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
const started = await adapter.startRun('ignored-local-profile');
const clickableItem = started.run.items.find((item) => item.clickable);
expect(started.run.profileId).toBe('local-match3d-profile');
expect(clickableItem).toBeTruthy();
const clickResult = await adapter.clickItem(started.run.runId, {
runId: started.run.runId,
itemInstanceId: clickableItem!.itemInstanceId,
clientActionId: 'local-click-1',
clientEventId: 'local-event-1',
clickedAtMs: started.run.serverNowMs ?? Date.now(),
clientSnapshotVersion: started.run.snapshotVersion,
});
expect(clickResult.status).toBe('Accepted');
expect(clickResult.run.snapshotVersion).toBe(started.run.snapshotVersion + 1);
const restarted = await adapter.restartRun(started.run.runId);
expect(restarted.run.runId).not.toBe(started.run.runId);
const stopped = await adapter.stopRun(restarted.run.runId);
expect(stopped.run.status).toBe('Stopped');
});
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
const first = await adapter.getRun('unused-run-id');
const timedOut = await adapter.finishTimeUp(first.run.runId);
expect(timedOut.run.status).toBe('Running');
expect(timedOut.run.runId).toBe(first.run.runId);
});
test('server and local Match3D runtime adapters share the same runtime seam', () => {
const adapters: Match3DRuntimeAdapter[] = [
createLocalMatch3DRuntimeAdapter({ clearCount: 1 }),
createServerMatch3DRuntimeAdapter(),
];
expect(adapters).toHaveLength(2);
for (const adapter of adapters) {
expect(typeof adapter.startRun).toBe('function');
expect(typeof adapter.getRun).toBe('function');
expect(typeof adapter.clickItem).toBe('function');
expect(typeof adapter.restartRun).toBe('function');
expect(typeof adapter.stopRun).toBe('function');
expect(typeof adapter.finishTimeUp).toBe('function');
}
});

View File

@@ -0,0 +1,106 @@
import type {
Match3DClickItemRequest,
Match3DClickItemResult,
Match3DRunResponse,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
confirmLocalMatch3DClick,
resolveLocalMatch3DTimer,
startLocalMatch3DRun,
stopLocalMatch3DRun,
} from './match3dLocalRuntime';
import {
clickMatch3DItem,
finishMatch3DTimeUp,
getMatch3DRun,
type Match3DRuntimeRequestOptions,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from './match3dRuntimeClient';
export type Match3DRuntimeAdapter = {
startRun: (
profileId: string,
options?: Match3DRuntimeRequestOptions,
) => Promise<Match3DRunResponse>;
getRun: (runId: string) => Promise<Match3DRunResponse>;
clickItem: (
runId: string,
payload: Match3DClickItemRequest,
) => Promise<Match3DClickItemResult>;
restartRun: (runId: string) => Promise<Match3DRunResponse>;
stopRun: (runId: string) => Promise<Match3DRunResponse>;
finishTimeUp: (runId: string) => Promise<Match3DRunResponse>;
};
export type LocalMatch3DRuntimeAdapterOptions = {
clearCount?: number;
initialRun?: Match3DRunResponse['run'];
};
type ServerMatch3DRuntimeAdapterDependencies = {
clickItem: typeof clickMatch3DItem;
finishTimeUp: typeof finishMatch3DTimeUp;
getRun: typeof getMatch3DRun;
restartRun: typeof restartMatch3DRun;
startRun: typeof startMatch3DRun;
stopRun: typeof stopMatch3DRun;
};
const defaultServerMatch3DRuntimeAdapterDependencies: ServerMatch3DRuntimeAdapterDependencies = {
clickItem: clickMatch3DItem,
finishTimeUp: finishMatch3DTimeUp,
getRun: getMatch3DRun,
restartRun: restartMatch3DRun,
startRun: startMatch3DRun,
stopRun: stopMatch3DRun,
};
export function createServerMatch3DRuntimeAdapter(
dependencies: ServerMatch3DRuntimeAdapterDependencies =
defaultServerMatch3DRuntimeAdapterDependencies,
): Match3DRuntimeAdapter {
return {
clickItem: (runId, payload) => dependencies.clickItem(runId, payload),
finishTimeUp: (runId) => dependencies.finishTimeUp(runId),
getRun: (runId) => dependencies.getRun(runId),
restartRun: (runId) => dependencies.restartRun(runId),
startRun: (profileId, options) => dependencies.startRun(profileId, options),
stopRun: (runId) => dependencies.stopRun(runId),
};
}
export function createLocalMatch3DRuntimeAdapter(
options: LocalMatch3DRuntimeAdapterOptions = {},
): Match3DRuntimeAdapter {
let authorityRun = options.initialRun ?? startLocalMatch3DRun(options.clearCount);
return {
async startRun() {
authorityRun = startLocalMatch3DRun(options.clearCount);
return { run: authorityRun };
},
async getRun() {
authorityRun = resolveLocalMatch3DTimer(authorityRun);
return { run: authorityRun };
},
async clickItem(_runId, payload) {
const result = await confirmLocalMatch3DClick(authorityRun, payload);
authorityRun = result.run;
return result;
},
async restartRun() {
authorityRun = startLocalMatch3DRun(options.clearCount);
return { run: authorityRun };
},
async stopRun() {
authorityRun = stopLocalMatch3DRun(authorityRun);
return { run: authorityRun };
},
async finishTimeUp() {
authorityRun = resolveLocalMatch3DTimer(authorityRun);
return { run: authorityRun };
},
};
}

View File

@@ -24,7 +24,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360,
retryUnsafeMethods: true,
};
type Match3DRuntimeRequestOptions = Pick<
export type Match3DRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'