Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -21,6 +21,7 @@ import type {
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatBindPhoneRequest,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
@@ -193,15 +194,16 @@ export async function redeemRegistrationInviteCode(inviteCode: string) {
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const payload: AuthWechatBindPhoneRequest = {
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
};
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
hasBabyObjectMatchRequiredTag,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
createBabyObjectMatchDraft,
|
||||
deleteLocalBabyObjectMatchDraft,
|
||||
listLocalBabyObjectMatchDrafts,
|
||||
publishBabyObjectMatchWork,
|
||||
} from './babyObjectMatchClient';
|
||||
|
||||
describe('babyObjectMatchClient', () => {
|
||||
beforeEach(() => {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('creates local demo draft with exact edutainment tag', async () => {
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: ' 苹果 ',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(response.draft.templateName).toBe('宝贝识物');
|
||||
expect(response.draft.itemNames).toEqual(['苹果', '香蕉']);
|
||||
expect(response.draft.itemAssets).toHaveLength(2);
|
||||
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
|
||||
'placeholder',
|
||||
);
|
||||
expect(response.draft.themeTags).toContain(
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
);
|
||||
expect(hasBabyObjectMatchRequiredTag(response.draft.themeTags)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects draft creation when any item name is empty', async () => {
|
||||
await expect(
|
||||
createBabyObjectMatchDraft({
|
||||
itemAName: '苹果',
|
||||
itemBName: ' ',
|
||||
}),
|
||||
).rejects.toThrow('请填写两个物品名称。');
|
||||
});
|
||||
|
||||
test('publish normalizes exact edutainment tag into payload', async () => {
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '杯子',
|
||||
itemBName: '勺子',
|
||||
});
|
||||
const published = await publishBabyObjectMatchWork({
|
||||
draft: {
|
||||
...response.draft,
|
||||
themeTags: ['儿童教育', '寓教于乐 '],
|
||||
},
|
||||
});
|
||||
|
||||
expect(published.publicWorkCode).toMatch(/^BO-/u);
|
||||
expect(published.draft.publicationStatus).toBe('published');
|
||||
expect(published.draft.themeTags[0]).toBe(
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
);
|
||||
expect(hasBabyObjectMatchRequiredTag(published.draft.themeTags)).toBe(true);
|
||||
});
|
||||
|
||||
test('deletes local baby object match draft by profile id', async () => {
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(1);
|
||||
|
||||
const nextItems = deleteLocalBabyObjectMatchDraft(response.draft.profileId);
|
||||
|
||||
expect(nextItems).toHaveLength(0);
|
||||
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
231
src/services/edutainment-baby-object/babyObjectMatchClient.ts
Normal file
231
src/services/edutainment-baby-object/babyObjectMatchClient.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
BabyObjectMatchItemAsset,
|
||||
BabyObjectMatchPublishRequest,
|
||||
BabyObjectMatchPublishResponse,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
SaveBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
normalizeBabyObjectMatchTags,
|
||||
validateBabyObjectMatchItemNames,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { buildBabyObjectMatchPublicWorkCode } from '../publicWorkCode';
|
||||
|
||||
const STORAGE_KEY = 'genarrative.edutainmentBabyObject.localDrafts.v1';
|
||||
|
||||
type LocalDraftStore = Record<string, BabyObjectMatchDraft>;
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return (
|
||||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function readLocalDraftStore(): LocalDraftStore {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(rawValue) as LocalDraftStore;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalDraftStore(store: LocalDraftStore) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
}
|
||||
|
||||
function createLocalId(prefix: string) {
|
||||
const randomPart =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID().replace(/-/gu, '')
|
||||
: Math.random().toString(36).slice(2);
|
||||
|
||||
return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function encodeSvgDataUri(svg: string) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function buildPlaceholderItemImage(itemName: string, index: number) {
|
||||
const palettes = [
|
||||
{
|
||||
bg: '#fef3c7',
|
||||
accent: '#fb7185',
|
||||
shadow: '#f59e0b',
|
||||
text: '#7c2d12',
|
||||
},
|
||||
{
|
||||
bg: '#dbeafe',
|
||||
accent: '#34d399',
|
||||
shadow: '#60a5fa',
|
||||
text: '#064e3b',
|
||||
},
|
||||
] as const;
|
||||
const palette = palettes[index % palettes.length]!;
|
||||
const displayText = itemName.slice(0, 6);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="96" fill="${palette.bg}"/><circle cx="256" cy="238" r="132" fill="${palette.accent}" opacity=".92"/><ellipse cx="256" cy="356" rx="132" ry="34" fill="${palette.shadow}" opacity=".22"/><circle cx="210" cy="202" r="24" fill="#fff" opacity=".82"/><circle cx="302" cy="202" r="24" fill="#fff" opacity=".82"/><path d="M204 276c30 30 74 30 104 0" fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round"/><text x="256" y="438" text-anchor="middle" font-family="Arial,'Microsoft YaHei',sans-serif" font-size="42" font-weight="700" fill="${palette.text}">${displayText}</text></svg>`;
|
||||
|
||||
return encodeSvgDataUri(svg);
|
||||
}
|
||||
|
||||
function buildItemAsset(
|
||||
itemName: string,
|
||||
index: number,
|
||||
): BabyObjectMatchItemAsset {
|
||||
return {
|
||||
itemId: `baby-object-item-${index + 1}`,
|
||||
itemName,
|
||||
imageSrc: buildPlaceholderItemImage(itemName, index),
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: `生成适合 4-8 岁儿童识物分类游戏的${itemName}物品图,绘本草地舞台风格,单个物体,透明或干净背景,无文字、无水印、无按钮。`,
|
||||
};
|
||||
}
|
||||
|
||||
function saveDraftToLocalStore(draft: BabyObjectMatchDraft) {
|
||||
const store = readLocalDraftStore();
|
||||
store[draft.profileId] = draft;
|
||||
writeLocalDraftStore(store);
|
||||
}
|
||||
|
||||
export function normalizeBabyObjectMatchDraft(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): BabyObjectMatchDraft {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
...draft,
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: draft.workTitle.trim() || '宝贝识物',
|
||||
workDescription: draft.workDescription.trim(),
|
||||
itemNames: [
|
||||
draft.itemNames[0].trim(),
|
||||
draft.itemNames[1].trim(),
|
||||
],
|
||||
itemAssets: [
|
||||
{
|
||||
...draft.itemAssets[0],
|
||||
itemName: draft.itemNames[0].trim(),
|
||||
},
|
||||
{
|
||||
...draft.itemAssets[1],
|
||||
itemName: draft.itemNames[1].trim(),
|
||||
},
|
||||
],
|
||||
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
|
||||
updatedAt: draft.updatedAt || now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前为本地 Demo 创作链路。真实 image-2 接入后替换为后端接口,
|
||||
* 但返回契约保持 BabyObjectMatchDraftResponse。
|
||||
*/
|
||||
export async function createBabyObjectMatchDraft(
|
||||
payload: CreateBabyObjectMatchDraftRequest,
|
||||
) {
|
||||
const validated = validateBabyObjectMatchItemNames(payload);
|
||||
if (!validated.valid) {
|
||||
throw new Error('请填写两个物品名称。');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const draftId = createLocalId('baby-object-draft');
|
||||
const profileId = createLocalId('baby-object-profile');
|
||||
const itemNames: [string, string] = [
|
||||
validated.itemAName,
|
||||
validated.itemBName,
|
||||
];
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
draftId,
|
||||
profileId,
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: `${itemNames[0]}和${itemNames[1]}识物分类`,
|
||||
itemNames,
|
||||
itemAssets: [buildItemAsset(itemNames[0], 0), buildItemAsset(itemNames[1], 1)],
|
||||
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
saveDraftToLocalStore(draft);
|
||||
return { draft };
|
||||
}
|
||||
|
||||
export async function saveBabyObjectMatchDraft(
|
||||
payload: SaveBabyObjectMatchDraftRequest,
|
||||
) {
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
...payload.draft,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
saveDraftToLocalStore(draft);
|
||||
|
||||
return { draft };
|
||||
}
|
||||
|
||||
export async function publishBabyObjectMatchWork(
|
||||
payload: BabyObjectMatchPublishRequest,
|
||||
): Promise<BabyObjectMatchPublishResponse> {
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
...payload.draft,
|
||||
publicationStatus: 'published',
|
||||
publishedAt: payload.draft.publishedAt ?? new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
saveDraftToLocalStore(draft);
|
||||
|
||||
return {
|
||||
draft,
|
||||
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function listLocalBabyObjectMatchDrafts() {
|
||||
return Object.values(readLocalDraftStore()).sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteLocalBabyObjectMatchDraft(profileId: string) {
|
||||
const normalizedProfileId = profileId.trim();
|
||||
if (!normalizedProfileId) {
|
||||
return listLocalBabyObjectMatchDrafts();
|
||||
}
|
||||
|
||||
const store = readLocalDraftStore();
|
||||
delete store[normalizedProfileId];
|
||||
writeLocalDraftStore(store);
|
||||
|
||||
return listLocalBabyObjectMatchDrafts();
|
||||
}
|
||||
|
||||
export const babyObjectMatchClient = {
|
||||
createDraft: createBabyObjectMatchDraft,
|
||||
deleteDraft: deleteLocalBabyObjectMatchDraft,
|
||||
saveDraft: saveBabyObjectMatchDraft,
|
||||
publish: publishBabyObjectMatchWork,
|
||||
listLocalDrafts: listLocalBabyObjectMatchDrafts,
|
||||
};
|
||||
1
src/services/edutainment-baby-object/index.ts
Normal file
1
src/services/edutainment-baby-object/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './babyObjectMatchClient';
|
||||
@@ -8,6 +8,11 @@ export {
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
export {
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
type Match3DRuntimeAdapter,
|
||||
} from './match3dRuntimeAdapter';
|
||||
export {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
|
||||
@@ -496,12 +496,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 {
|
||||
|
||||
144
src/services/match3d-runtime/match3dRuntimeAdapter.test.ts
Normal file
144
src/services/match3d-runtime/match3dRuntimeAdapter.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
106
src/services/match3d-runtime/match3dRuntimeAdapter.ts
Normal file
106
src/services/match3d-runtime/match3dRuntimeAdapter.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type Match3DRuntimeRequestOptions = Pick<
|
||||
export type Match3DRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBabyObjectMatchGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
@@ -227,6 +228,37 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('baby object match generation exposes two item names', () => {
|
||||
const state = createMiniGameDraftGenerationState('baby-object-match');
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 9_000,
|
||||
);
|
||||
const entries = buildBabyObjectMatchGenerationAnchorEntries({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'baby-object-draft',
|
||||
'baby-object-images',
|
||||
'baby-object-ready',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('baby-object-images');
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'baby-object-item-1',
|
||||
label: '物品 1',
|
||||
value: '苹果',
|
||||
},
|
||||
{
|
||||
id: 'baby-object-item-2',
|
||||
label: '物品 2',
|
||||
value: '香蕉',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
@@ -18,7 +22,8 @@ export type MiniGameDraftGenerationKind =
|
||||
| 'puzzle'
|
||||
| 'big-fish'
|
||||
| 'square-hole'
|
||||
| 'match3d';
|
||||
| 'match3d'
|
||||
| 'baby-object-match';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
@@ -37,6 +42,9 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-generate-views'
|
||||
| 'match3d-ready'
|
||||
| 'baby-object-draft'
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-select-image'
|
||||
| 'ready'
|
||||
@@ -191,6 +199,27 @@ const MATCH3D_PHASE_ORDER: Partial<
|
||||
'match3d-generate-views': 5,
|
||||
};
|
||||
|
||||
const BABY_OBJECT_MATCH_STEPS = [
|
||||
{
|
||||
id: 'baby-object-draft',
|
||||
label: '整理识物草稿',
|
||||
detail: '写入两个物品名称与寓教于乐标签。',
|
||||
weight: 22,
|
||||
},
|
||||
{
|
||||
id: 'baby-object-images',
|
||||
label: '生成物品图',
|
||||
detail: '为两个物品准备绘本风格图片资产。',
|
||||
weight: 68,
|
||||
},
|
||||
{
|
||||
id: 'baby-object-ready',
|
||||
label: '准备结果页',
|
||||
detail: '校验草稿字段并进入结果页。',
|
||||
weight: 10,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -205,6 +234,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'match3d') {
|
||||
return MATCH3D_STEPS;
|
||||
}
|
||||
if (kind === 'baby-object-match') {
|
||||
return BABY_OBJECT_MATCH_STEPS;
|
||||
}
|
||||
return BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
@@ -260,7 +292,9 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'square-hole-draft'
|
||||
: kind === 'match3d'
|
||||
? 'match3d-work-title'
|
||||
: 'compile',
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
@@ -313,6 +347,18 @@ function resolveMatch3DPhaseByElapsedMs(
|
||||
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
|
||||
}
|
||||
|
||||
function resolveBabyObjectMatchPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 52_000) {
|
||||
return 'baby-object-ready';
|
||||
}
|
||||
if (elapsedMs >= 8_000) {
|
||||
return 'baby-object-images';
|
||||
}
|
||||
return 'baby-object-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
@@ -360,27 +406,34 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
phase: puzzleTimeline.phase,
|
||||
}
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'square-hole' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'match3d' &&
|
||||
: state.kind === 'square-hole' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
: state.kind === 'match3d' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
}
|
||||
: state.kind === 'baby-object-match' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
@@ -401,13 +454,15 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 1
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: 0;
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
@@ -436,7 +491,9 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||
: normalizedState.kind === 'match3d'
|
||||
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(cappedOverallProgress),
|
||||
@@ -448,13 +505,15 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 10 * 60_000 - elapsedMs)
|
||||
: null,
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 10 * 60_000 - elapsedMs)
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? Math.max(0, 60_000 - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
steps,
|
||||
@@ -600,6 +659,22 @@ function resolveMatch3DGeneratedItemCount(
|
||||
return 21;
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchGenerationAnchorEntries(
|
||||
formPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
|
||||
draft: BabyObjectMatchDraft | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
const itemNames =
|
||||
formPayload?.itemAName?.trim() || formPayload?.itemBName?.trim()
|
||||
? [formPayload.itemAName.trim(), formPayload.itemBName.trim()]
|
||||
: (draft?.itemNames ?? []);
|
||||
|
||||
return itemNames.filter(Boolean).map((value, index) => ({
|
||||
id: `baby-object-item-${index + 1}`,
|
||||
label: `物品 ${index + 1}`,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSquareHoleGenerationAnchorEntries(
|
||||
session: SquareHoleSessionSnapshot | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
|
||||
@@ -45,6 +45,14 @@ export function buildVisualNovelPublicWorkCode(profileId: string) {
|
||||
return `VN-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `BO-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -103,3 +111,16 @@ export function isSameVisualNovelPublicWorkCode(
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameBabyObjectMatchPublicWorkCode(
|
||||
keyword: string,
|
||||
profileId: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildBabyObjectMatchPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user