hermes/hermes-1e775b03 #13

Merged
kdletters merged 9 commits from hermes/hermes-1e775b03 into master 2026-05-12 15:11:51 +08:00
8 changed files with 358 additions and 43 deletions
Showing only changes of commit 22810245f5 - Show all commits

View File

@@ -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<Match3DRunSnapshot>(buildInitialRun);
const authorityRunRef = useRef(run);
const runtimeSessionRef = useRef(buildInitialRuntimeSession());
const [run, setRun] = useState<Match3DRunSnapshot>(
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 (
<Match3DRuntimeShell

View File

@@ -104,10 +104,6 @@ import {
ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
} from '../../services/apiClient';
import {
fetchCreationEntryConfig,
type CreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
getPublicAuthUserByCode,
getPublicAuthUserById,
@@ -132,6 +128,10 @@ import {
deleteBigFishWork,
listBigFishWorks,
} from '../../services/big-fish-works';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
@@ -144,13 +144,7 @@ import {
shouldRestoreCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
clickMatch3DItem,
finishMatch3DTimeUp,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from '../../services/match3d-runtime';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
@@ -2599,6 +2593,10 @@ export function PlatformEntryFlowShellImpl({
},
});
const match3dRuntimeAdapter = useMemo(
() => 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);
})

View File

@@ -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'),

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'