From 22810245f5b91f3cde0b70ead2997c7467da446f Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 12 May 2026 14:02:42 +0800 Subject: [PATCH] refactor match3d runtime adapters --- src/Match3DPlaygroundApp.tsx | 49 +++--- .../PlatformEntryFlowShellImpl.tsx | 40 ++--- ...gEntryFlowShell.agent.interaction.test.tsx | 48 +++++- src/services/match3d-runtime/index.ts | 5 + .../match3d-runtime/match3dLocalRuntime.ts | 7 +- .../match3dRuntimeAdapter.test.ts | 144 ++++++++++++++++++ .../match3d-runtime/match3dRuntimeAdapter.ts | 106 +++++++++++++ .../match3d-runtime/match3dRuntimeClient.ts | 2 +- 8 files changed, 358 insertions(+), 43 deletions(-) create mode 100644 src/services/match3d-runtime/match3dRuntimeAdapter.test.ts create mode 100644 src/services/match3d-runtime/match3dRuntimeAdapter.ts diff --git a/src/Match3DPlaygroundApp.tsx b/src/Match3DPlaygroundApp.tsx index ba37e6a8..f446f865 100644 --- a/src/Match3DPlaygroundApp.tsx +++ b/src/Match3DPlaygroundApp.tsx @@ -6,51 +6,64 @@ import type { } from '../packages/shared/src/contracts/match3dRuntime'; import { Match3DRuntimeShell } from './components/match3d-runtime'; import { - confirmLocalMatch3DClick, - resolveLocalMatch3DTimer, + createLocalMatch3DRuntimeAdapter, + type Match3DRuntimeAdapter, startLocalMatch3DRun, } from './services/match3d-runtime'; -function buildInitialRun() { +type LocalMatch3DRuntimeSession = { + adapter: Match3DRuntimeAdapter; + initialRun: Match3DRunSnapshot; +}; + +function resolveClearCountParam() { const params = new URLSearchParams(window.location.search); const clearCountParam = params.get('clearCount') ?? params.get('count'); const clearCount = clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10); - return startLocalMatch3DRun( - Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12, - ); + return Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12; +} + +function buildInitialRuntimeSession(): LocalMatch3DRuntimeSession { + const initialRun = startLocalMatch3DRun(resolveClearCountParam()); + return { + adapter: createLocalMatch3DRuntimeAdapter({ initialRun }), + initialRun, + }; } export default function Match3DPlaygroundApp() { - const [run, setRun] = useState(buildInitialRun); - const authorityRunRef = useRef(run); + const runtimeSessionRef = useRef(buildInitialRuntimeSession()); + const [run, setRun] = useState( + runtimeSessionRef.current.initialRun, + ); const syncRun = useCallback((nextRun: Match3DRunSnapshot) => { setRun(nextRun); }, []); const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => { - const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload); - authorityRunRef.current = result.run; + const runId = payload.runId ?? runtimeSessionRef.current.initialRun.runId; + const result = await runtimeSessionRef.current.adapter.clickItem(runId, payload); setRun(result.run); return result; }, []); const handleRestart = useCallback(() => { - const nextRun = buildInitialRun(); - authorityRunRef.current = nextRun; - setRun(nextRun); - }, []); + void runtimeSessionRef.current.adapter.restartRun(run.runId).then(({ run }) => { + setRun(run); + }); + }, [run.runId]); const handleExit = useCallback(() => { window.location.assign('/'); }, []); const handleTimeExpired = useCallback(() => { - const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current); - authorityRunRef.current = nextRun; - setRun(nextRun); - }, []); + void runtimeSessionRef.current.adapter.finishTimeUp(run.runId).then(({ run }) => { + setRun(run); + }); + }, [run.runId]); return ( createServerMatch3DRuntimeAdapter(), + [], + ); const match3dFlow = usePlatformCreationAgentFlowController< Match3DAgentSessionSnapshot, CreateMatch3DSessionRequest, @@ -4376,11 +4374,11 @@ export function PlatformEntryFlowShellImpl({ try { const { run } = options.embedded - ? await startMatch3DRun( + ? await match3dRuntimeAdapter.startRun( profile.profileId, RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, ) - : await startMatch3DRun(profile.profileId); + : await match3dRuntimeAdapter.startRun(profile.profileId); setMatch3DRun(run); setMatch3DRuntimeReturnStage(returnStage); if (!options.embedded) { @@ -4412,6 +4410,7 @@ export function PlatformEntryFlowShellImpl({ [ isMatch3DBusy, match3dFlow, + match3dRuntimeAdapter, resolveMatch3DErrorMessage, setMatch3DError, setSelectionStage, @@ -6671,7 +6670,7 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void restartMatch3DRun(match3dRun.runId) + void match3dRuntimeAdapter.restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); }) @@ -6693,14 +6692,14 @@ export function PlatformEntryFlowShellImpl({ if (!runId) { return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); } - return clickMatch3DItem(runId, payload); + return match3dRuntimeAdapter.clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void finishMatch3DTimeUp(match3dRun.runId) + void match3dRuntimeAdapter.finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); }) @@ -6868,6 +6867,7 @@ export function PlatformEntryFlowShellImpl({ match3dError, match3dFlow, match3dRun, + match3dRuntimeAdapter, platformBootstrap.platformTab, platformThemeClass, puzzleError, @@ -8560,7 +8560,7 @@ export function PlatformEntryFlowShellImpl({ error={match3dError} onBack={() => { if (match3dRun?.runId && match3dRun.status === 'running') { - void stopMatch3DRun(match3dRun.runId).catch( + void match3dRuntimeAdapter.stopRun(match3dRun.runId).catch( () => undefined, ); } @@ -8573,7 +8573,7 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void restartMatch3DRun(match3dRun.runId) + void match3dRuntimeAdapter.restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); }) @@ -8597,14 +8597,14 @@ export function PlatformEntryFlowShellImpl({ new Error('抓大鹅运行态缺少 runId。'), ); } - return clickMatch3DItem(runId, payload); + return match3dRuntimeAdapter.clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void finishMatch3DTimeUp(match3dRun.runId) + void match3dRuntimeAdapter.finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); }) diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 7f2b8bf6..de68c02f 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -54,6 +54,7 @@ import { import { match3dCreationClient } from '../../services/match3d-creation'; import { clickMatch3DItem, + createServerMatch3DRuntimeAdapter, finishMatch3DTimeUp, restartMatch3DRun, startMatch3DRun, @@ -348,14 +349,35 @@ vi.mock('../../services/match3d-works', () => ({ listMatch3DWorks: vi.fn(), })); -vi.mock('../../services/match3d-runtime', () => ({ +const match3dRuntimeServiceMocks = vi.hoisted(() => ({ clickMatch3DItem: vi.fn(), + createServerMatch3DRuntimeAdapter: vi.fn(), finishMatch3DTimeUp: vi.fn(), restartMatch3DRun: vi.fn(), startMatch3DRun: vi.fn(), stopMatch3DRun: vi.fn(), })); +const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({ + clickItem: vi.fn(), + finishTimeUp: vi.fn(), + getRun: vi.fn(), + restartRun: vi.fn(), + startRun: vi.fn(), + stopRun: vi.fn(), +})); + +vi.mock('../../services/match3d-runtime', async () => { + const actual = await vi.importActual< + typeof import('../../services/match3d-runtime') + >('../../services/match3d-runtime'); + + return { + ...actual, + ...match3dRuntimeServiceMocks, + }; +}); + vi.mock('../../services/square-hole-creation', () => ({ squareHoleCreationClient: { createSession: vi.fn(), @@ -1453,6 +1475,24 @@ function TestWrapper({ beforeEach(() => { vi.resetAllMocks(); + vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue( + match3dServerRuntimeAdapterMock, + ); + match3dServerRuntimeAdapterMock.startRun.mockRejectedValue( + new Error('未启动抓大鹅运行态'), + ); + match3dServerRuntimeAdapterMock.clickItem.mockRejectedValue( + new Error('未执行抓大鹅点击'), + ); + match3dServerRuntimeAdapterMock.restartRun.mockRejectedValue( + new Error('未重新开始抓大鹅运行态'), + ); + match3dServerRuntimeAdapterMock.finishTimeUp.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-profile-time-up'), + }); + match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-profile-stopped'), + }); window.history.replaceState(null, '', '/'); window.sessionStorage.clear(); window.localStorage.clear(); @@ -4465,7 +4505,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [match3dWork], }); - vi.mocked(startMatch3DRun).mockResolvedValue({ + match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({ run: buildMockMatch3DRun(match3dWork.profileId), }); @@ -4483,7 +4523,9 @@ test('public code search opens a published Match3D work by M3 code and starts ru await user.click(screen.getByRole('button', { name: '启动' })); await waitFor(() => { - expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1'); + expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith( + 'match3d-profile-public-1', + ); }); expect( await screen.findByText('抓大鹅运行态:match3d-run-match3d-profile-public-1'), diff --git a/src/services/match3d-runtime/index.ts b/src/services/match3d-runtime/index.ts index b3549600..8cf5c538 100644 --- a/src/services/match3d-runtime/index.ts +++ b/src/services/match3d-runtime/index.ts @@ -6,6 +6,11 @@ export { startLocalMatch3DRun, stopLocalMatch3DRun, } from './match3dLocalRuntime'; +export { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, + type Match3DRuntimeAdapter, +} from './match3dRuntimeAdapter'; export { clickMatch3DItem, finishMatch3DTimeUp, diff --git a/src/services/match3d-runtime/match3dLocalRuntime.ts b/src/services/match3d-runtime/match3dLocalRuntime.ts index 18d2e088..7624b109 100644 --- a/src/services/match3d-runtime/match3dLocalRuntime.ts +++ b/src/services/match3d-runtime/match3dLocalRuntime.ts @@ -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 { // 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。 - await new Promise((resolve) => window.setTimeout(resolve, 180)); + await waitForLocalConfirmation(180); const timedRun = normalizeRemainingMs(run); if (timedRun.status !== 'Running') { return { diff --git a/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts new file mode 100644 index 00000000..351d6df6 --- /dev/null +++ b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts @@ -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'); + } +}); diff --git a/src/services/match3d-runtime/match3dRuntimeAdapter.ts b/src/services/match3d-runtime/match3dRuntimeAdapter.ts new file mode 100644 index 00000000..6402ebf3 --- /dev/null +++ b/src/services/match3d-runtime/match3dRuntimeAdapter.ts @@ -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; + getRun: (runId: string) => Promise; + clickItem: ( + runId: string, + payload: Match3DClickItemRequest, + ) => Promise; + restartRun: (runId: string) => Promise; + stopRun: (runId: string) => Promise; + finishTimeUp: (runId: string) => Promise; +}; + +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 }; + }, + }; +} diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index 44a7f0b2..9c4c51bc 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -24,7 +24,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxDelayMs: 360, retryUnsafeMethods: true, }; -type Match3DRuntimeRequestOptions = Pick< +export type Match3DRuntimeRequestOptions = Pick< ApiRequestOptions, | 'authImpact' | 'skipRefresh'