feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-12 16:08:59 +08:00
parent cf074837a4
commit d41f260a2a
58 changed files with 5628 additions and 466 deletions

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

@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
buildBabyObjectMatchGenerationAnchorEntries,
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
@@ -226,6 +227,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-models'
| '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-models': 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,
@@ -580,6 +639,22 @@ export function buildMatch3DGenerationAnchorEntries(
.filter((entry) => entry.value.trim());
}
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)
);
}