Merge remote-tracking branch 'origin/codex/wooden-fish-template'

This commit is contained in:
kdletters
2026-05-22 08:09:58 +08:00
617 changed files with 31612 additions and 237 deletions

View File

@@ -6,6 +6,7 @@ import {
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
buildWoodenFishGenerationAnchorEntries,
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState,
} from './miniGameDraftGenerationProgress';
@@ -383,6 +384,56 @@ describe('miniGameDraftGenerationProgress', () => {
]);
});
test('wooden fish draft generation exposes hit object, background and sound pipeline', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 28_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'wooden-fish-draft',
'wooden-fish-hit-object',
'wooden-fish-background',
'wooden-fish-hit-sound',
'wooden-fish-write-draft',
]);
expect(progress?.phaseId).toBe('wooden-fish-hit-object');
expect(progress?.phaseLabel).toBe('生成敲击物图案');
expect(progress?.estimatedRemainingMs).toBe(272_000);
});
test('wooden fish generation anchors expose hit object, sound and words', () => {
const entries = buildWoodenFishGenerationAnchorEntries(null, {
templateId: 'wooden-fish',
workTitle: '每日一敲',
workDescription: '敲一下,好事发生。',
themeTags: ['解压'],
hitObjectPrompt: '金色小木鱼',
hitSoundPrompt: '清脆木鱼声',
floatingWords: ['幸运+1', '功德+1'],
});
expect(entries).toEqual([
{
id: 'wooden-fish-hit-object',
label: '敲击物',
value: '金色小木鱼',
},
{
id: 'wooden-fish-hit-sound',
label: '音效',
value: '清脆木鱼声',
},
{
id: 'wooden-fish-words',
label: '飘字',
value: '幸运+1、功德+1',
},
]);
});
test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries({
sessionId: 'puzzle-session-1',

View File

@@ -16,6 +16,10 @@ import type {
CustomWorldGenerationStep,
} from '../../packages/shared/src/contracts/runtime';
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
import type {
CreateJumpHopSessionRequest,
@@ -28,7 +32,8 @@ export type MiniGameDraftGenerationKind =
| 'square-hole'
| 'match3d'
| 'baby-object-match'
| 'jump-hop';
| 'jump-hop'
| 'wooden-fish';
export type MiniGameDraftGenerationPhase =
| 'idle'
@@ -62,6 +67,11 @@ export type MiniGameDraftGenerationPhase =
| 'jump-hop-tile-atlas'
| 'jump-hop-slice-tiles'
| 'jump-hop-write-draft'
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
| 'wooden-fish-hit-sound'
| 'wooden-fish-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
@@ -395,6 +405,41 @@ const JUMP_HOP_STEPS = [
const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000;
const WOODEN_FISH_STEPS = [
{
id: 'wooden-fish-draft',
label: '整理玩法草稿',
detail: '保存作品信息、敲击物、音效和飘字配置。',
weight: 8,
},
{
id: 'wooden-fish-hit-object',
label: '生成敲击物图案',
detail: '使用 image2 生成最终运行态敲击物图案。',
weight: 34,
},
{
id: 'wooden-fish-background',
label: '生成背景环境图',
detail: '使用 image2 生成敲击背景环境图。',
weight: 34,
},
{
id: 'wooden-fish-hit-sound',
label: '准备敲击音效',
detail: '生成或写回短促敲击音效资产。',
weight: 16,
},
{
id: 'wooden-fish-write-draft',
label: '写入正式草稿',
detail: '保存图案、背景、音效、飘字和封面摘要。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const WOODEN_FISH_ESTIMATED_WAIT_MS = 5 * 60_000;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
@@ -415,6 +460,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'jump-hop') {
return JUMP_HOP_STEPS;
}
if (kind === 'wooden-fish') {
return WOODEN_FISH_STEPS;
}
return BIG_FISH_STEPS;
}
@@ -472,8 +520,10 @@ export function createMiniGameDraftGenerationState(
? 'match3d-work-title'
: kind === 'baby-object-match'
? 'baby-object-draft'
: kind === 'jump-hop'
? 'jump-hop-draft'
: kind === 'jump-hop'
? 'jump-hop-draft'
: kind === 'wooden-fish'
? 'wooden-fish-draft'
: 'compile',
startedAtMs: Date.now(),
completedAssetCount: 0,
@@ -558,6 +608,24 @@ function resolveJumpHopPhaseByElapsedMs(
return 'jump-hop-draft';
}
function resolveWoodenFishPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 270_000) {
return 'wooden-fish-write-draft';
}
if (elapsedMs >= 240_000) {
return 'wooden-fish-hit-sound';
}
if (elapsedMs >= 120_000) {
return 'wooden-fish-background';
}
if (elapsedMs >= 12_000) {
return 'wooden-fish-hit-object';
}
return 'wooden-fish-draft';
}
function resolvePuzzleTimelineByElapsedMs(
elapsedMs: number,
state: MiniGameDraftGenerationState,
@@ -646,6 +714,13 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
}
: state.kind === 'wooden-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveWoodenFishPhaseByElapsedMs(elapsedMs),
}
: state;
const steps =
@@ -680,6 +755,8 @@ export function buildMiniGameDraftGenerationProgress(
? 0.52
: normalizedState.kind === 'jump-hop'
? 0.5
: normalizedState.kind === 'wooden-fish'
? 0.5
: 0;
const overallProgress =
normalizedState.phase === 'failed'
@@ -713,6 +790,8 @@ export function buildMiniGameDraftGenerationProgress(
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
: normalizedState.kind === 'jump-hop'
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
: normalizedState.kind === 'wooden-fish'
? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
@@ -738,6 +817,8 @@ export function buildMiniGameDraftGenerationProgress(
)
: normalizedState.kind === 'jump-hop'
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'wooden-fish'
? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(
@@ -808,6 +889,57 @@ export function buildJumpHopGenerationAnchorEntries(
.filter((entry) => entry.value.trim());
}
export function buildWoodenFishGenerationAnchorEntries(
session: WoodenFishSessionSnapshotResponse | null | undefined,
formPayload: WoodenFishWorkspaceCreateRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const draft = session?.draft;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'wooden-fish-hit-object',
label: '敲击物',
value:
formPayload?.hitObjectPrompt?.trim() ||
draft?.hitObjectPrompt?.trim() ||
'',
},
{
key: 'wooden-fish-hit-sound',
label: '音效',
value:
formPayload?.hitSoundPrompt?.trim() ||
draft?.hitSoundPrompt?.trim() ||
draft?.hitSoundAsset?.prompt?.trim() ||
'',
},
{
key: 'wooden-fish-words',
label: '飘字',
value:
formPayload?.floatingWords
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 8)
.join('、') ||
draft?.floatingWords
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 8)
.join('、') ||
'',
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
export function buildPuzzleGenerationAnchorEntries(
session: PuzzleAgentSessionSnapshot | null | undefined,
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
buildJumpHopPublicWorkCode,
buildWoodenFishPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from './publicWorkCode';
describe('publicWorkCode', () => {
@@ -23,4 +25,25 @@ describe('publicWorkCode', () => {
),
).toBe(true);
});
it('builds wooden fish public work codes with WF prefix', () => {
expect(buildWoodenFishPublicWorkCode('wooden-fish-profile-1234abcd')).toBe(
'WF-1234ABCD',
);
});
it('matches wooden fish public work codes and raw profile ids', () => {
expect(
isSameWoodenFishPublicWorkCode(
'wf-1234abcd',
'wooden-fish-profile-1234abcd',
),
).toBe(true);
expect(
isSameWoodenFishPublicWorkCode(
'wooden-fish-profile-1234abcd',
'wooden-fish-profile-1234abcd',
),
).toBe(true);
});
});

View File

@@ -75,6 +75,14 @@ export function buildJumpHopPublicWorkCode(profileId: string) {
return `JH-${suffix}`;
}
export function buildWoodenFishPublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `WF-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -167,3 +175,16 @@ export function isSameJumpHopPublicWorkCode(keyword: string, profileId: string)
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameWoodenFishPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildWoodenFishPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}

View File

@@ -0,0 +1,276 @@
import type {
WoodenFishActionRequest,
WoodenFishActionResponse,
WoodenFishCheckpointRunRequest,
WoodenFishFinishRunRequest,
WoodenFishGalleryCardResponse,
WoodenFishGalleryDetailResponse,
WoodenFishGalleryResponse,
WoodenFishRunResponse,
WoodenFishRuntimeRunSnapshotResponse,
WoodenFishSessionResponse,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkDetailResponse,
WoodenFishWorkMutationResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions';
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
const WOODEN_FISH_RUNTIME_API_BASE = '/api/runtime/wooden-fish';
const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
export type {
WoodenFishActionRequest,
WoodenFishActionResponse,
WoodenFishCheckpointRunRequest,
WoodenFishFinishRunRequest,
WoodenFishGalleryCardResponse,
WoodenFishGalleryDetailResponse,
WoodenFishGalleryResponse,
WoodenFishRunResponse,
WoodenFishRuntimeRunSnapshotResponse,
WoodenFishSessionResponse,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkDetailResponse,
WoodenFishWorkMutationResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
};
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
export type WoodenFishSessionSnapshot = WoodenFishSessionSnapshotResponse;
const woodenFishCreationClient = createCreationAgentClient<
WoodenFishWorkspaceCreateRequest,
WoodenFishSessionResponse,
WoodenFishSessionResponse,
WoodenFishSessionSnapshotResponse,
never,
never,
WoodenFishActionRequest,
WoodenFishActionResponse
>({
apiBase: WOODEN_FISH_API_BASE,
messages: {
createSession: '创建敲木鱼共创会话失败',
getSession: '读取敲木鱼共创会话失败',
sendMessage: '发送敲木鱼共创消息失败',
streamIncomplete: '敲木鱼共创消息流式结果不完整',
executeAction: '执行敲木鱼共创操作失败',
},
});
type FlattenedWoodenFishWorkProfileResponse = Omit<
WoodenFishWorkProfileResponse,
'summary'
> &
WoodenFishWorkSummaryResponse;
function normalizeWoodenFishWorkProfile(
work:
| WoodenFishWorkProfileResponse
| FlattenedWoodenFishWorkProfileResponse,
): WoodenFishWorkProfileResponse {
if ('summary' in work && work.summary) {
return work;
}
const flattened = work as FlattenedWoodenFishWorkProfileResponse;
const summary: WoodenFishWorkProfileResponse['summary'] = {
runtimeKind: flattened.runtimeKind,
workId: flattened.workId,
profileId: flattened.profileId,
ownerUserId: flattened.ownerUserId,
sourceSessionId: flattened.sourceSessionId ?? null,
workTitle: flattened.workTitle,
workDescription: flattened.workDescription,
themeTags: flattened.themeTags,
coverImageSrc: flattened.coverImageSrc ?? null,
publicationStatus: flattened.publicationStatus,
playCount: flattened.playCount,
updatedAt: flattened.updatedAt,
publishedAt: flattened.publishedAt ?? null,
publishReady: flattened.publishReady,
generationStatus: flattened.generationStatus,
};
return {
summary,
draft: flattened.draft,
hitObjectAsset: flattened.hitObjectAsset,
backgroundAsset:
flattened.backgroundAsset ?? flattened.draft?.backgroundAsset ?? null,
hitSoundAsset: flattened.hitSoundAsset,
floatingWords: flattened.floatingWords,
};
}
function normalizeWoodenFishActionResponse(
response: WoodenFishActionResponse,
): WoodenFishActionResponse {
return {
...response,
work: response.work ? normalizeWoodenFishWorkProfile(response.work) : null,
};
}
function normalizeWoodenFishWorkDetailResponse(
response: WoodenFishWorkDetailResponse,
): WoodenFishWorkDetailResponse {
return {
...response,
item: normalizeWoodenFishWorkProfile(response.item),
};
}
function normalizeWoodenFishWorkMutationResponse(
response: WoodenFishWorkMutationResponse,
): WoodenFishWorkMutationResponse {
return {
...response,
item: normalizeWoodenFishWorkProfile(response.item),
};
}
export function createWoodenFishCreationSession(
payload: WoodenFishWorkspaceCreateRequest,
) {
return woodenFishCreationClient.createSession(payload);
}
export function getWoodenFishCreationSession(sessionId: string) {
return woodenFishCreationClient.getSession(sessionId);
}
export function executeWoodenFishCreationAction(
sessionId: string,
payload: WoodenFishActionRequest,
) {
return woodenFishCreationClient
.executeAction(sessionId, payload)
.then(normalizeWoodenFishActionResponse);
}
export async function getWoodenFishWorkDetail(profileId: string) {
const response = await requestJson<WoodenFishWorkDetailResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取敲木鱼作品详情失败',
);
return normalizeWoodenFishWorkDetailResponse(response);
}
export async function listWoodenFishGallery() {
return requestJson<WoodenFishGalleryResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
{ method: 'GET' },
'读取敲木鱼广场失败',
{
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
export async function getWoodenFishGalleryDetail(publicWorkCode: string) {
const response = await requestJson<WoodenFishGalleryDetailResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery/${encodeURIComponent(publicWorkCode)}`,
{ method: 'GET' },
'读取敲木鱼广场详情失败',
{
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
return normalizeWoodenFishWorkDetailResponse(response);
}
export async function publishWoodenFishWork(profileId: string) {
const response = await requestJson<WoodenFishWorkMutationResponse>(
`${WOODEN_FISH_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
{ method: 'POST' },
'发布敲木鱼作品失败',
);
return normalizeWoodenFishWorkMutationResponse(response);
}
export async function startWoodenFishRuntimeRun(profileId: string) {
return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ profileId }),
},
'启动敲木鱼运行态失败',
);
}
export async function checkpointWoodenFishRun(
runId: string,
payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>,
) {
const requestPayload: WoodenFishCheckpointRunRequest = {
...payload,
clientEventId: `checkpoint-${runId}-${Date.now()}`,
};
return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/checkpoint`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(requestPayload),
},
'保存敲木鱼进度失败',
);
}
export async function finishWoodenFishRun(
runId: string,
payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>,
) {
const requestPayload: WoodenFishFinishRunRequest = {
...payload,
clientEventId: `finish-${runId}-${Date.now()}`,
};
return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/finish`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(requestPayload),
},
'结束敲木鱼运行失败',
);
}
export const woodenFishClient = {
checkpointRun: checkpointWoodenFishRun,
createSession: createWoodenFishCreationSession,
executeAction: executeWoodenFishCreationAction,
finishRun: finishWoodenFishRun,
getGalleryDetail: getWoodenFishGalleryDetail,
getSession: getWoodenFishCreationSession,
getWorkDetail: getWoodenFishWorkDetail,
listGallery: listWoodenFishGallery,
publishWork: publishWoodenFishWork,
startRun: startWoodenFishRuntimeRun,
};

View File

@@ -0,0 +1,5 @@
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC =
'/wooden-fish/default-hit-object.png';
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT =
'默认敲击物图案,圆润木质质感,透明背景';