This commit is contained in:
2026-05-13 03:10:55 +08:00
154 changed files with 16812 additions and 708 deletions

View File

@@ -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),
},
'绑定手机号失败',
);

View File

@@ -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);
});
});

View 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,
};

View File

@@ -0,0 +1 @@
export * from './babyObjectMatchClient';

View File

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

View File

@@ -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 {

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

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

View File

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

View File

@@ -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[] {

View File

@@ -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)
);
}