Integrate Match3D Q1 flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-01 13:53:59 +08:00
parent 375f7493a3
commit df24467e1d
24 changed files with 2089 additions and 361 deletions

View File

@@ -2,360 +2,87 @@ import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
Match3DActionResponse,
Match3DAgentMessageResponse,
Match3DAgentSessionSnapshot,
Match3DAnchorItemResponse,
Match3DCreatorConfig,
Match3DSessionResponse,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { TextStreamOptions } from '../aiTypes';
import { createCreationAgentClient } from '../creation-agent';
const MOCK_RESPONSE_DELAY_MS = 180;
const MATCH3D_SESSION_PREFIX = 'match3d-session';
const MATCH3D_AGENT_API_BASE = '/api/creation/match3d/sessions';
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
themeText: '缤纷玩具',
clearCount: 12,
difficulty: 4,
};
const match3dAgentHttpClient = createCreationAgentClient<
CreateMatch3DSessionRequest,
Match3DSessionResponse,
Match3DSessionResponse,
Match3DAgentSessionSnapshot,
SendMatch3DMessageRequest,
Match3DSessionResponse,
ExecuteMatch3DActionRequest,
Match3DActionResponse
>({
apiBase: MATCH3D_AGENT_API_BASE,
messages: {
createSession: '创建抓大鹅共创会话失败',
getSession: '读取抓大鹅共创会话失败',
sendMessage: '发送抓大鹅共创消息失败',
streamIncomplete: '抓大鹅共创消息流式结果不完整',
executeAction: '执行抓大鹅共创操作失败',
},
});
let match3dSessionCounter = 0;
const mockSessions = new Map<string, Match3DAgentSessionSnapshot>();
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, ms));
}
function nowIso() {
return new Date().toISOString();
}
function createMessage(
sessionId: string,
role: Match3DAgentMessageResponse['role'],
text: string,
kind: Match3DAgentMessageResponse['kind'] = 'chat',
): Match3DAgentMessageResponse {
return {
id: `${sessionId}-message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
role,
kind,
text,
createdAt: nowIso(),
};
}
function buildAnchor(
key: string,
label: string,
value: string,
): Match3DAnchorItemResponse {
return {
key,
label,
value,
status: value.trim() ? 'confirmed' : 'missing',
};
}
function buildAnchorPack(config: Partial<Match3DCreatorConfig>) {
return {
theme: buildAnchor('theme', '题材主题', config.themeText ?? ''),
clearCount: buildAnchor(
'clearCount',
'需要消除次数',
typeof config.clearCount === 'number' ? String(config.clearCount) : '',
),
difficulty: buildAnchor(
'difficulty',
'难度',
typeof config.difficulty === 'number' ? String(config.difficulty) : '',
),
};
}
function normalizePositiveInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const normalized = Math.floor(value);
return normalized > 0 ? normalized : null;
}
function normalizeDifficulty(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
return Math.max(1, Math.min(10, Math.round(value)));
}
function buildConfigFromPartial(
partial: Partial<Match3DCreatorConfig>,
): Match3DCreatorConfig | null {
const themeText = partial.themeText?.trim();
const clearCount = normalizePositiveInteger(partial.clearCount);
const difficulty = normalizeDifficulty(partial.difficulty);
if (!themeText || !clearCount || !difficulty) {
return null;
}
return {
themeText,
referenceImageSrc: partial.referenceImageSrc ?? null,
clearCount,
difficulty,
};
}
function parseConfigFromText(
text: string,
current: Partial<Match3DCreatorConfig>,
): Partial<Match3DCreatorConfig> {
const next = { ...current };
const trimmedText = text.trim();
const themeMatch =
trimmedText.match(/(?:|)[:\s]*([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})/u) ??
trimmedText.match(/(?:|||使)([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})(?:|)/u);
const clearCountMatch =
trimmedText.match(/(?:|)[:\s]*(\d+)/u) ??
trimmedText.match(/(\d+)\s*(?:|)/u);
const difficultyMatch =
trimmedText.match(/(?:)[:\s]*(10|[1-9])/u) ??
trimmedText.match(/(?:|)/u);
if (themeMatch?.[1]) {
next.themeText = themeMatch[1].trim();
}
if (clearCountMatch?.[1]) {
next.clearCount = Number(clearCountMatch[1]);
}
if (difficultyMatch?.[1]) {
next.difficulty = Number(difficultyMatch[1]);
} else if (difficultyMatch?.[0]) {
next.difficulty = 7;
}
if (!next.themeText && trimmedText.length >= 2 && trimmedText.length <= 24) {
next.themeText = trimmedText;
}
return next;
}
function resolveSessionProgress(config: Partial<Match3DCreatorConfig>) {
const completed = [
Boolean(config.themeText?.trim()),
Boolean(normalizePositiveInteger(config.clearCount)),
Boolean(normalizeDifficulty(config.difficulty)),
].filter(Boolean).length;
return Math.round((completed / 3) * 100);
}
function buildAssistantReply(config: Partial<Match3DCreatorConfig>) {
const missing: string[] = [];
if (!config.themeText?.trim()) {
missing.push('题材主题');
}
if (!normalizePositiveInteger(config.clearCount)) {
missing.push('需要消除次数');
}
if (!normalizeDifficulty(config.difficulty)) {
missing.push('难度');
}
if (missing.length === 0) {
const readyConfig = buildConfigFromPartial(config) ?? DEFAULT_MATCH3D_CONFIG;
return `已确认:${readyConfig.themeText}题材,消除 ${readyConfig.clearCount} 次,共 ${readyConfig.clearCount * 3} 件物品,难度 ${readyConfig.difficulty}。可以生成结果页。`;
}
return `还需要确认:${missing.join('、')}`;
}
function updateSessionConfig(
session: Match3DAgentSessionSnapshot,
partialConfig: Partial<Match3DCreatorConfig>,
) {
const progressPercent = resolveSessionProgress(partialConfig);
const config = buildConfigFromPartial(partialConfig);
return {
...session,
progressPercent,
stage: 'collecting_config',
anchorPack: buildAnchorPack(partialConfig),
config,
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
}
function ensureMockSession(sessionId: string) {
const session = mockSessions.get(sessionId);
if (!session) {
throw new Error('抓大鹅创作会话不存在,请重新开始创作。');
}
return session;
}
function buildDraft(config: Match3DCreatorConfig) {
return {
gameName: `${config.themeText}抓大鹅`,
themeText: config.themeText,
summaryText: `${config.themeText}题材的经典三消收纳关卡。`,
tags: [config.themeText, '抓大鹅', '消除'].slice(0, 3),
coverImageSrc: config.referenceImageSrc ?? null,
clearCount: config.clearCount,
difficulty: config.difficulty,
totalItemCount: config.clearCount * 3,
};
}
export async function createMatch3DCreationSession(
/**
* 创建抓大鹅 Agent 共创会话。
* Q1 起前端只走 Axum facade避免本地 mock 成为创作真相源。
*/
export function createMatch3DCreationSession(
payload: CreateMatch3DSessionRequest = {},
): Promise<Match3DSessionResponse> {
await delay();
match3dSessionCounter += 1;
const sessionId = `${MATCH3D_SESSION_PREFIX}-${match3dSessionCounter}`;
const partialConfig: Partial<Match3DCreatorConfig> = {
themeText: payload.themeText ?? payload.seedText,
referenceImageSrc: payload.referenceImageSrc ?? null,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
};
const now = nowIso();
const session: Match3DAgentSessionSnapshot = updateSessionConfig(
{
sessionId,
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_config',
anchorPack: buildAnchorPack(partialConfig),
config: null,
draft: null,
messages: [
createMessage(
sessionId,
'assistant',
'先确认题材、需要消除次数和难度。也可以直接说“自动配置”。',
),
],
lastAssistantReply: null,
updatedAt: now,
},
partialConfig,
);
mockSessions.set(sessionId, session);
return { session };
) {
return match3dAgentHttpClient.createSession(payload);
}
export async function getMatch3DCreationSession(sessionId: string) {
await delay(80);
return { session: ensureMockSession(sessionId) };
/**
* 读取抓大鹅 Agent 会话快照。
*/
export function getMatch3DCreationSession(sessionId: string) {
return match3dAgentHttpClient.getSession(sessionId);
}
export async function streamMatch3DCreationMessage(
/**
* 非流式发送抓大鹅 Agent 消息,保留为 SSE 降级入口。
*/
export function sendMatch3DCreationMessage(
sessionId: string,
payload: SendMatch3DMessageRequest,
) {
return match3dAgentHttpClient.sendMessage(sessionId, payload);
}
/**
* 流式发送抓大鹅 Agent 消息。
*/
export function streamMatch3DCreationMessage(
sessionId: string,
payload: SendMatch3DMessageRequest,
options: TextStreamOptions = {},
): Promise<Match3DAgentSessionSnapshot> {
await delay(120);
const session = ensureMockSession(sessionId);
const text = payload.text.trim();
const currentConfig: Partial<Match3DCreatorConfig> = session.config ?? {
themeText: session.anchorPack.theme.value,
clearCount: Number(session.anchorPack.clearCount.value) || undefined,
difficulty: Number(session.anchorPack.difficulty.value) || undefined,
};
const nextConfig =
payload.quickFillRequested || //u.test(text)
? {
...DEFAULT_MATCH3D_CONFIG,
themeText: currentConfig.themeText || DEFAULT_MATCH3D_CONFIG.themeText,
}
: parseConfigFromText(text, currentConfig);
const userMessage = {
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text,
createdAt: nowIso(),
} satisfies Match3DAgentMessageResponse;
const assistantReply = buildAssistantReply(nextConfig);
options.onUpdate?.(assistantReply.slice(0, Math.ceil(assistantReply.length / 2)));
await delay(80);
options.onUpdate?.(assistantReply);
await delay(80);
const nextSession = updateSessionConfig(
{
...session,
currentTurn: session.currentTurn + 1,
messages: [
...session.messages,
userMessage,
createMessage(sessionId, 'assistant', assistantReply),
],
lastAssistantReply: assistantReply,
},
{
...nextConfig,
referenceImageSrc:
payload.referenceImageSrc ?? currentConfig.referenceImageSrc ?? null,
},
);
mockSessions.set(sessionId, nextSession);
return nextSession;
) {
return match3dAgentHttpClient.streamMessage(sessionId, payload, options);
}
export async function executeMatch3DCreationAction(
/**
* 执行抓大鹅创作操作,例如生成草稿作品。
*/
export function executeMatch3DCreationAction(
sessionId: string,
payload: ExecuteMatch3DActionRequest,
): Promise<Match3DActionResponse> {
await delay(220);
const session = ensureMockSession(sessionId);
if (payload.action !== 'match3d_compile_draft') {
throw new Error('未知抓大鹅创作操作。');
}
const config = session.config ?? buildConfigFromPartial(DEFAULT_MATCH3D_CONFIG);
if (!config) {
throw new Error('请先确认题材、需要消除次数和难度。');
}
const nextSession = {
...session,
stage: 'draft_ready',
progressPercent: 100,
config,
draft: buildDraft(config),
lastAssistantReply: '抓大鹅草稿已准备完成。',
messages: [
...session.messages,
createMessage(sessionId, 'assistant', '抓大鹅草稿已准备完成。', 'summary'),
],
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
mockSessions.set(sessionId, nextSession);
return { session: nextSession };
) {
return match3dAgentHttpClient.executeAction(sessionId, payload);
}
export const match3dCreationClient = {
createSession: createMatch3DCreationSession,
getSession: getMatch3DCreationSession,
sendMessage: sendMatch3DCreationMessage,
streamMessage: streamMatch3DCreationMessage,
executeAction: executeMatch3DCreationAction,
};

View File

@@ -6,3 +6,12 @@ export {
startLocalMatch3DRun,
stopLocalMatch3DRun,
} from './match3dLocalRuntime';
export {
clickMatch3DItem,
finishMatch3DTimeUp,
getMatch3DRun,
match3dRuntimeClient,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from './match3dRuntimeClient';

View File

@@ -0,0 +1,162 @@
import type {
Match3DClickConfirmation,
Match3DClickItemRequest,
Match3DClickItemResult,
Match3DClickRejectReason,
Match3DClickResponse,
Match3DRunResponse,
StopMatch3DRunRequest,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
switch (reason) {
case 'snapshot_version_mismatch':
return 'VersionConflict';
case 'tray_full':
return 'RejectedTrayFull';
case 'run_not_active':
return 'RunFinished';
case 'item_not_found':
case 'item_not_in_board':
return 'RejectedAlreadyMoved';
case 'item_not_clickable':
default:
return 'RejectedNotClickable';
}
}
function mapClickConfirmation(
request: Match3DClickItemRequest,
confirmation: Match3DClickConfirmation,
): Match3DClickItemResult {
return {
status: confirmation.accepted
? 'Accepted'
: normalizeRejectStatus(confirmation.rejectReason),
run: confirmation.run,
acceptedItemInstanceId: confirmation.accepted
? request.itemInstanceId
: undefined,
clearedItemInstanceIds: confirmation.clearedItemInstanceIds,
failureReason: confirmation.run.failureReason,
};
}
/**
* 基于作品启动一局抓大鹅正式 run。
*/
export function startMatch3DRun(profileId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId }),
},
'启动抓大鹅玩法失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
/**
* 读取抓大鹅运行态快照。
*/
export function getMatch3DRun(runId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' },
'读取抓大鹅运行快照失败',
{ retry: MATCH3D_RUNTIME_READ_RETRY },
);
}
/**
* 提交一次点击,由后端做权威确认;返回值适配运行壳已实现的即时反馈语义。
*/
export async function clickMatch3DItem(
runId: string,
payload: Match3DClickItemRequest,
) {
const response = await requestJson<Match3DClickResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
runId: payload.runId ?? runId,
}),
},
'确认抓大鹅点击失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
return mapClickConfirmation(payload, response.confirmation);
}
/**
* 停止当前抓大鹅运行态。
*/
export function stopMatch3DRun(
runId: string,
payload: StopMatch3DRunRequest = {
clientActionId: `match3d-stop-${Date.now()}`,
},
) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'停止抓大鹅玩法失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
/**
* 基于当前 run 重开一局。
*/
export function restartMatch3DRun(runId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`,
{ method: 'POST' },
'重新开始抓大鹅玩法失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
/**
* 前端倒计时归零后通知后端确认失败状态。
*/
export function finishMatch3DTimeUp(runId: string) {
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`,
{ method: 'POST' },
'同步抓大鹅倒计时失败',
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
);
}
export const match3dRuntimeClient = {
clickItem: clickMatch3DItem,
finishTimeUp: finishMatch3DTimeUp,
getRun: getMatch3DRun,
restartRun: restartMatch3DRun,
startRun: startMatch3DRun,
stopRun: stopMatch3DRun,
};

View File

@@ -0,0 +1,9 @@
export {
deleteMatch3DWork,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
match3dWorksClient,
publishMatch3DWork,
updateMatch3DWork,
} from './match3dWorksClient';

View File

@@ -0,0 +1,113 @@
import type {
Match3DWorkDetailResponse,
Match3DWorkMutationResponse,
Match3DWorksResponse,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const MATCH3D_WORKS_API_BASE = '/api/creation/match3d/works';
const MATCH3D_GALLERY_API_BASE = '/api/runtime/match3d/gallery';
const MATCH3D_WORKS_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const MATCH3D_WORKS_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 读取当前用户的抓大鹅作品列表。
*/
export function listMatch3DWorks() {
return requestJson<Match3DWorksResponse>(
MATCH3D_WORKS_API_BASE,
{ method: 'GET' },
'读取抓大鹅作品列表失败',
{ retry: MATCH3D_WORKS_READ_RETRY },
);
}
/**
* 读取公开抓大鹅作品列表。
*/
export function listMatch3DGallery() {
return requestJson<Match3DWorksResponse>(
MATCH3D_GALLERY_API_BASE,
{ method: 'GET' },
'读取抓大鹅广场失败',
{
retry: MATCH3D_WORKS_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
/**
* 读取抓大鹅作品详情。
*/
export function getMatch3DWorkDetail(profileId: string) {
return requestJson<Match3DWorkDetailResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取抓大鹅作品详情失败',
{ retry: MATCH3D_WORKS_READ_RETRY },
);
}
/**
* 保存结果页可编辑字段。
*/
export function updateMatch3DWork(
profileId: string,
payload: PutMatch3DWorkRequest,
) {
return requestJson<Match3DWorkMutationResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'更新抓大鹅作品失败',
{ retry: MATCH3D_WORKS_WRITE_RETRY },
);
}
/**
* 发布抓大鹅作品。发布门槛由后端最终确认。
*/
export function publishMatch3DWork(profileId: string) {
return requestJson<Match3DWorkMutationResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
{ method: 'POST' },
'发布抓大鹅作品失败',
{ retry: MATCH3D_WORKS_WRITE_RETRY },
);
}
/**
* 删除当前用户的抓大鹅作品,并返回删除后的列表。
*/
export function deleteMatch3DWork(profileId: string) {
return requestJson<Match3DWorksResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'DELETE' },
'删除抓大鹅作品失败',
{ retry: MATCH3D_WORKS_WRITE_RETRY },
);
}
export const match3dWorksClient = {
delete: deleteMatch3DWork,
getDetail: getMatch3DWorkDetail,
listGallery: listMatch3DGallery,
list: listMatch3DWorks,
publish: publishMatch3DWork,
update: updateMatch3DWork,
};

View File

@@ -21,6 +21,14 @@ export function buildBigFishPublicWorkCode(sessionId: string) {
return `BF-${suffix}`;
}
export function buildMatch3DPublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `M3-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -43,3 +51,13 @@ export function isSameBigFishPublicWorkCode(
normalizedKeyword === normalizePublicCodeText(sessionId)
);
}
export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildMatch3DPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}